Compare commits
3 Commits
925540f3c6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5541c0553c | |||
| 172730cf0f | |||
| 9ccce7cbfd |
@@ -38,17 +38,6 @@ services:
|
|||||||
# April 2026 and the schema has moved on through manual SQL since, so running them
|
# April 2026 and the schema has moved on through manual SQL since, so running them
|
||||||
# against a fresh database would build a stale schema the app cannot work with.
|
# against a fresh database would build a stale schema the app cannot work with.
|
||||||
# Bring the schema in with `pg_dump --schema-only` from production instead.
|
# Bring the schema in with `pg_dump --schema-only` from production instead.
|
||||||
environment:
|
|
||||||
# Docker sets HOSTNAME=<container-id>, and the Next.js standalone server binds to
|
|
||||||
# that single interface. With two networks Caddy then cannot reach the container.
|
|
||||||
HOSTNAME: "0.0.0.0"
|
|
||||||
# `db` and `redis` are taken in BOTH networks - by this stack in test-internal and
|
|
||||||
# by production in qrmaster-network. Production wins the lookup every time, so the
|
|
||||||
# base file's hostnames point the staging app at the production instances. Container
|
|
||||||
# names are unique per daemon and cannot be shadowed.
|
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
|
|
||||||
DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
|
|
||||||
REDIS_URL: redis://qrmaster-test-redis:6379
|
|
||||||
entrypoint: ["node", "server.js"]
|
entrypoint: ["node", "server.js"]
|
||||||
build:
|
build:
|
||||||
args:
|
args:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
- qrmaster-network
|
- qrmaster-network
|
||||||
|
|
||||||
# Next.js Application
|
# Next.js Application
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -62,9 +62,6 @@ services:
|
|||||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||||
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
|
||||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
||||||
CRON_SECRET: ${CRON_SECRET:-}
|
|
||||||
SOCIAL_MILESTONE_THRESHOLDS: ${SOCIAL_MILESTONE_THRESHOLDS:-}
|
|
||||||
SOCIAL_MILESTONE_POST_DELAY_HOURS: ${SOCIAL_MILESTONE_POST_DELAY_HOURS:-}
|
|
||||||
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
|
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
|
||||||
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
||||||
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
|
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
|
||||||
@@ -114,27 +111,8 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
networks:
|
networks:
|
||||||
- qrmaster-network
|
- qrmaster-network
|
||||||
|
|
||||||
social-worker:
|
|
||||||
build:
|
|
||||||
context: ./scripts/social-worker
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
QRMASTER_API_BASE: http://web:3000
|
|
||||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
|
||||||
SOCIAL_MILESTONE_POSTING_ENABLED: ${SOCIAL_MILESTONE_POSTING_ENABLED:-false}
|
|
||||||
SOCIAL_WORKER_INTERVAL_SECONDS: ${SOCIAL_WORKER_INTERVAL_SECONDS:-10}
|
|
||||||
X_API_KEY: ${X_API_KEY:-}
|
|
||||||
X_API_SECRET: ${X_API_SECRET:-}
|
|
||||||
X_ACCESS_TOKEN: ${X_ACCESS_TOKEN:-}
|
|
||||||
X_ACCESS_TOKEN_SECRET: ${X_ACCESS_TOKEN_SECRET:-}
|
|
||||||
depends_on:
|
|
||||||
web:
|
|
||||||
condition: service_started
|
|
||||||
networks:
|
|
||||||
- qrmaster-network
|
|
||||||
|
|
||||||
|
|
||||||
# Adminer - Database Management UI (Optional)
|
# Adminer - Database Management UI (Optional)
|
||||||
|
|||||||
54
docker/init-db.sh
Executable file → Normal file
54
docker/init-db.sh
Executable file → Normal file
@@ -1,28 +1,26 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# This script runs when the PostgreSQL container is first created
|
# This script runs when the PostgreSQL container is first created
|
||||||
# It ensures the database is properly initialized
|
# It ensures the database is properly initialized
|
||||||
#
|
|
||||||
# Keep this database-name agnostic: the staging stack (docker-compose.test.yml)
|
echo "🚀 Initializing QR Master database..."
|
||||||
# runs the same script with POSTGRES_DB=qrmaster_test. A hardcoded name aborts
|
|
||||||
# the init, and the container never becomes healthy.
|
# Create the database if it doesn't exist (already created by POSTGRES_DB)
|
||||||
# Must stay LF-only and executable - Postgres sources non-executable init
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||||
# scripts, and CRLF breaks them on the first line.
|
-- Enable required extensions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
echo "🚀 Initializing QR Master database..."
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||||
|
|
||||||
# The database itself is already created by POSTGRES_DB
|
-- Grant privileges
|
||||||
psql -v ON_ERROR_STOP=1 -v dbname="$POSTGRES_DB" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres;
|
||||||
-- Enable required extensions
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
-- Set timezone
|
||||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
ALTER DATABASE qrmaster SET timezone TO 'UTC';
|
||||||
|
EOSQL
|
||||||
-- Set timezone
|
|
||||||
ALTER DATABASE :"dbname" SET timezone TO 'UTC';
|
echo "✅ Database initialization complete!"
|
||||||
EOSQL
|
echo "📊 Database: $POSTGRES_DB"
|
||||||
|
echo "👤 User: $POSTGRES_USER"
|
||||||
echo "✅ Database initialization complete!"
|
echo "🌐 Ready to accept connections on port 5432"
|
||||||
echo "📊 Database: $POSTGRES_DB"
|
|
||||||
echo "👤 User: $POSTGRES_USER"
|
|
||||||
echo "🌐 Ready to accept connections on port 5432"
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
# Social milestone worker
|
|
||||||
|
|
||||||
The app detects QR-code scan milestones and stores customer consent. It does not
|
|
||||||
hold X or LinkedIn credentials. An external X worker can use the internal queue
|
|
||||||
after the test rollout is approved.
|
|
||||||
|
|
||||||
## Test setup (manual SQL only)
|
|
||||||
|
|
||||||
1. Apply [`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql)
|
|
||||||
to `qrmaster_test`.
|
|
||||||
2. Set distinct `CRON_SECRET` and `INTERNAL_API_SECRET` values in `.env.test`.
|
|
||||||
For an end-to-end test without 1,000 scans, also set
|
|
||||||
`SOCIAL_MILESTONE_THRESHOLDS=1` (or `1,2`). Do not set this on production.
|
|
||||||
Publishing is immediate after consent by default. Set
|
|
||||||
`SOCIAL_MILESTONE_POST_DELAY_HOURS=24` only if a revocation window is desired.
|
|
||||||
3. Deploy using the documented test compose command. `CRON_SECRET` is forwarded
|
|
||||||
to the web service by `docker-compose.yml`.
|
|
||||||
4. Trigger detection manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer $CRON_SECRET" \
|
|
||||||
https://testmodul.qrmaster.net/api/cron/social-milestones
|
|
||||||
```
|
|
||||||
|
|
||||||
The detector creates records at 1,000 and 10,000 unique scans only. It is safe
|
|
||||||
to call repeatedly because `(qrId, kind)` is unique.
|
|
||||||
|
|
||||||
## X worker contract
|
|
||||||
|
|
||||||
After an explicit rollout approval, the existing QRMaster X worker may poll:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \
|
|
||||||
https://qrmaster.net/api/internal/social-milestones
|
|
||||||
```
|
|
||||||
|
|
||||||
It receives at most one approved item per 24 hours. The worker must post `milestone.text`
|
|
||||||
without altering it, then report its result:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X PATCH -H "Authorization: Bearer $INTERNAL_API_SECRET" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"id":"<milestone-id>","result":"posted"}' \
|
|
||||||
https://qrmaster.net/api/internal/social-milestones
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not configure this worker against `testmodul`. LinkedIn has no approved
|
|
||||||
brand-posting integration in this project. Customers can self-share: X opens a
|
|
||||||
prefilled intent; the same text is copied for a LinkedIn post.
|
|
||||||
@@ -89,12 +89,6 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
lifecycleLogs UserLifecycleLog[]
|
lifecycleLogs UserLifecycleLog[]
|
||||||
socialMilestones SocialMilestone[]
|
|
||||||
|
|
||||||
// Social-success sharing preferences. A post is still never published
|
|
||||||
// without a per-milestone approval stored below.
|
|
||||||
xHandle String?
|
|
||||||
socialPromptOptOut Boolean @default(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Plan {
|
enum Plan {
|
||||||
@@ -154,45 +148,11 @@ model QRCode {
|
|||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
scans QRScan[]
|
scans QRScan[]
|
||||||
socialMilestones SocialMilestone[]
|
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
@@index([userId, type, status])
|
@@index([userId, type, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
model SocialMilestone {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
qrId String
|
|
||||||
userId String
|
|
||||||
kind String
|
|
||||||
status String @default("detected")
|
|
||||||
detectedAt DateTime @default(now())
|
|
||||||
shownAt DateTime?
|
|
||||||
respondedAt DateTime?
|
|
||||||
claimedAt DateTime?
|
|
||||||
postedAt DateTime?
|
|
||||||
withName Boolean @default(false)
|
|
||||||
consentText String?
|
|
||||||
language String @default("en")
|
|
||||||
cardData Json?
|
|
||||||
brandStatus String @default("pending")
|
|
||||||
brandApprovedAt DateTime?
|
|
||||||
brandPostedAt DateTime?
|
|
||||||
brandPostUrl String?
|
|
||||||
brandPostError String?
|
|
||||||
selfSharedAt DateTime?
|
|
||||||
shareToken String? @unique
|
|
||||||
publicShareApprovedAt DateTime?
|
|
||||||
|
|
||||||
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([qrId, kind])
|
|
||||||
@@index([status, respondedAt])
|
|
||||||
@@index([status, claimedAt])
|
|
||||||
@@index([userId, status])
|
|
||||||
}
|
|
||||||
|
|
||||||
enum QRType {
|
enum QRType {
|
||||||
STATIC
|
STATIC
|
||||||
DYNAMIC
|
DYNAMIC
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
WORKDIR /worker
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
COPY worker.py .
|
|
||||||
CMD ["python", "worker.py"]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Pillow>=10
|
|
||||||
requests>=2.31
|
|
||||||
requests-oauthlib>=2.0
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
"""Always-on QR Master X milestone worker. The web app never receives X keys."""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
from requests_oauthlib import OAuth1Session
|
|
||||||
|
|
||||||
|
|
||||||
def required(name):
|
|
||||||
value = os.getenv(name, "").strip()
|
|
||||||
if not value:
|
|
||||||
raise RuntimeError(f"Missing {name}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def api(method, url, payload=None):
|
|
||||||
response = requests.request(method, url, json=payload, headers={"Authorization": f"Bearer {required('INTERNAL_API_SECRET')}"}, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
|
|
||||||
def font(name, size):
|
|
||||||
return ImageFont.truetype(f"/usr/share/fonts/truetype/dejavu/{name}", size)
|
|
||||||
|
|
||||||
|
|
||||||
def render_card(card):
|
|
||||||
"""Render the consented immutable scan snapshot; never invent trend data."""
|
|
||||||
image = Image.new("RGB", (1200, 630), "#f8fafc")
|
|
||||||
draw = ImageDraw.Draw(image)
|
|
||||||
navy, blue, slate, border, green = "#061b31", "#0256ff", "#64748b", "#e2e8f0", "#059669"
|
|
||||||
regular, medium, bold, display = font("DejaVuSans.ttf", 28), font("DejaVuSans-Bold.ttf", 28), font("DejaVuSans-Bold.ttf", 42), font("DejaVuSans-Bold.ttf", 116)
|
|
||||||
draw.rounded_rectangle((45, 42, 1155, 588), radius=24, fill="#ffffff", outline=border, width=2)
|
|
||||||
draw.rounded_rectangle((82, 79, 114, 111), radius=7, fill=blue)
|
|
||||||
draw.text((130, 81), "QR MASTER", font=medium, fill=navy)
|
|
||||||
draw.rounded_rectangle((932, 77, 1118, 114), radius=8, fill="#ecfdf5")
|
|
||||||
draw.text((954, 84), "Verified milestone", font=font("DejaVuSans-Bold.ttf", 17), fill=green)
|
|
||||||
draw.text((84, 158), "TOTAL UNIQUE SCANS", font=font("DejaVuSans-Bold.ttf", 18), fill="#94a3b8")
|
|
||||||
total = int(card.get("totalUniqueScans") or card.get("threshold") or 0)
|
|
||||||
draw.text((78, 184), f"{total:,}", font=display, fill=navy)
|
|
||||||
label = "eindeutige Scans" if card.get("language") == "de" else "unique scans"
|
|
||||||
draw.text((86, 325), label, font=regular, fill=slate)
|
|
||||||
trend = card.get("trend") or None
|
|
||||||
if trend and len(trend.get("series", [])) > 1:
|
|
||||||
series = trend["series"]
|
|
||||||
left, top, width, height = 84, 390, 1030, 88
|
|
||||||
max_value = max(series) or 1
|
|
||||||
points = [(left + round(index * width / (len(series) - 1)), top + height - round(value / max_value * height)) for index, value in enumerate(series)]
|
|
||||||
for y in (top, top + height // 2, top + height):
|
|
||||||
draw.line((left, y, left + width, y), fill="#edf2f7", width=2)
|
|
||||||
draw.line(points, fill=blue, width=6, joint="curve")
|
|
||||||
for x, y in (points[0], points[-1]):
|
|
||||||
draw.ellipse((x - 7, y - 7, x + 7, y + 7), fill=blue)
|
|
||||||
draw.text((84, 495), f"Last {trend.get('periodDays', 7)} days · {trend.get('recentTotal', 0)} unique scans", font=font("DejaVuSans.ttf", 18), fill=slate)
|
|
||||||
else:
|
|
||||||
draw.rounded_rectangle((84, 398, 357, 452), radius=10, fill="#eff6ff")
|
|
||||||
copy = "Erste Dynamik" if card.get("language") == "de" else "Early momentum"
|
|
||||||
draw.text((106, 411), copy, font=font("DejaVuSans-Bold.ttf", 20), fill=blue)
|
|
||||||
draw.text((84, 495), "Trend appears once enough real scan history exists." if card.get("language") != "de" else "Der Trend erscheint mit ausreichend echten Scan-Daten.", font=font("DejaVuSans.ttf", 18), fill=slate)
|
|
||||||
draw.line((84, 532, 1116, 532), fill=border, width=2)
|
|
||||||
draw.text((84, 549), card.get("qrTitle") or card.get("title") or "QR code", font=medium, fill=navy)
|
|
||||||
path = Path(tempfile.mkstemp(suffix=".png")[1])
|
|
||||||
image.save(path, "PNG", optimize=True)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def post_x(text, card):
|
|
||||||
oauth = OAuth1Session(required("X_API_KEY"), client_secret=required("X_API_SECRET"), resource_owner_key=required("X_ACCESS_TOKEN"), resource_owner_secret=required("X_ACCESS_TOKEN_SECRET"))
|
|
||||||
path = render_card(card) if card else None
|
|
||||||
try:
|
|
||||||
media_id = None
|
|
||||||
if path:
|
|
||||||
with path.open("rb") as image:
|
|
||||||
upload = oauth.post("https://upload.x.com/1.1/media/upload.json", files={"media": image}, timeout=60)
|
|
||||||
upload.raise_for_status()
|
|
||||||
media_id = upload.json()["media_id_string"]
|
|
||||||
payload = {"text": text}
|
|
||||||
if media_id:
|
|
||||||
payload["media"] = {"media_ids": [media_id]}
|
|
||||||
result = oauth.post("https://api.x.com/2/tweets", json=payload, timeout=30)
|
|
||||||
result.raise_for_status()
|
|
||||||
return result.json()
|
|
||||||
finally:
|
|
||||||
if path:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def run_once():
|
|
||||||
base = required("QRMASTER_API_BASE").rstrip("/") + "/api/internal/social-milestones"
|
|
||||||
milestone = api("GET", base).get("milestone")
|
|
||||||
if not milestone:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
result = post_x(milestone["text"], milestone.get("card"))
|
|
||||||
tweet_id = result.get("data", {}).get("id")
|
|
||||||
post_url = f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None
|
|
||||||
api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url})
|
|
||||||
print(json.dumps({"posted": milestone["id"], "x": result}), flush=True)
|
|
||||||
except Exception as error:
|
|
||||||
api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]})
|
|
||||||
print(f"Milestone post failed: {error}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
interval = max(5, int(os.getenv("SOCIAL_WORKER_INTERVAL_SECONDS", "10")))
|
|
||||||
if os.getenv("SOCIAL_MILESTONE_POSTING_ENABLED", "").lower() not in {"true", "1", "yes"}:
|
|
||||||
raise RuntimeError("Set SOCIAL_MILESTONE_POSTING_ENABLED=true to run this worker")
|
|
||||||
while True:
|
|
||||||
run_once()
|
|
||||||
time.sleep(interval)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
-- Success-sharing milestones. Run once against the target database before
|
|
||||||
-- deploying the application version that uses this feature.
|
|
||||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "xHandle" TEXT;
|
|
||||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "socialPromptOptOut" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "SocialMilestone" (
|
|
||||||
"id" TEXT PRIMARY KEY,
|
|
||||||
"qrId" TEXT NOT NULL REFERENCES "QRCode"("id") ON DELETE CASCADE,
|
|
||||||
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
|
|
||||||
"kind" TEXT NOT NULL,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'detected',
|
|
||||||
"detectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"shownAt" TIMESTAMP(3),
|
|
||||||
"respondedAt" TIMESTAMP(3),
|
|
||||||
"postedAt" TIMESTAMP(3),
|
|
||||||
"withName" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"consentText" TEXT,
|
|
||||||
CONSTRAINT "SocialMilestone_qr_kind_key" UNIQUE ("qrId", "kind")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS "SocialMilestone_status_respondedAt_idx"
|
|
||||||
ON "SocialMilestone" ("status", "respondedAt");
|
|
||||||
CREATE INDEX IF NOT EXISTS "SocialMilestone_userId_status_idx"
|
|
||||||
ON "SocialMilestone" ("userId", "status");
|
|
||||||
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "claimedAt" TIMESTAMP(3);
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "language" TEXT NOT NULL DEFAULT 'en';
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "cardData" JSONB;
|
|
||||||
CREATE INDEX IF NOT EXISTS "SocialMilestone_status_claimedAt_idx"
|
|
||||||
ON "SocialMilestone" ("status", "claimedAt");
|
|
||||||
|
|
||||||
-- Version 2: independent brand and self-share state plus a consent-gated
|
|
||||||
-- unguessable URL for Open Graph previews. Execute manually once.
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandStatus" TEXT NOT NULL DEFAULT 'pending';
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandApprovedAt" TIMESTAMP(3);
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostedAt" TIMESTAMP(3);
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostUrl" TEXT;
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostError" TEXT;
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "selfSharedAt" TIMESTAMP(3);
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "shareToken" TEXT;
|
|
||||||
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "publicShareApprovedAt" TIMESTAMP(3);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS "SocialMilestone_shareToken_key"
|
|
||||||
ON "SocialMilestone" ("shareToken") WHERE "shareToken" IS NOT NULL;
|
|
||||||
@@ -16,7 +16,6 @@ import { QrCode } from 'lucide-react';
|
|||||||
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
|
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
|
||||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||||
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
||||||
import { SocialMilestoneDialog } from '@/components/dashboard/SocialMilestoneDialog';
|
|
||||||
|
|
||||||
interface QRCodeData {
|
interface QRCodeData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -323,7 +322,6 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SocialMilestoneDialog />
|
|
||||||
{/* Header with Plan Badge */}
|
{/* Header with Plan Badge */}
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
|
||||||
import { db } from '@/lib/db';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: { params: { token: string } }) {
|
|
||||||
const share = await db.socialMilestone.findFirst({
|
|
||||||
where: { shareToken: params.token, publicShareApprovedAt: { not: null } },
|
|
||||||
select: { cardData: true, language: true },
|
|
||||||
});
|
|
||||||
if (!share) return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } });
|
|
||||||
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number; trend?: { periodDays: number; recentTotal: number } | null } | null;
|
|
||||||
const german = share.language === 'de';
|
|
||||||
return new ImageResponse(<div style={{ height: '100%', width: '100%', display: 'flex', background: '#f8fafc', padding: 46, color: '#061b31' }}><div style={{ display: 'flex', flexDirection: 'column', width: '100%', border: '2px solid #e2e8f0', borderRadius: 24, background: 'white', padding: 44 }}><div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 23, fontWeight: 700 }}><span>QR MASTER</span><span style={{ color: '#059669', background: '#ecfdf5', padding: '8px 14px', borderRadius: 8 }}>Verified milestone</span></div><div style={{ display: 'flex', flexDirection: 'column', marginTop: 58 }}><span style={{ fontSize: 20, color: '#94a3b8', fontWeight: 700 }}>TOTAL UNIQUE SCANS</span><span style={{ fontSize: 116, fontWeight: 700, letterSpacing: -5 }}>{(card?.totalUniqueScans || 0).toLocaleString(german ? 'de-DE' : 'en-US')}</span><span style={{ fontSize: 29, color: '#64748b' }}>{german ? 'eindeutige Scans' : 'unique scans'}</span></div><div style={{ display: 'flex', marginTop: 'auto', paddingTop: 28, borderTop: '2px solid #e2e8f0', justifyContent: 'space-between', fontSize: 25 }}><span>{card?.qrTitle || 'QR code'}</span><span style={{ color: '#0256ff' }}>{card?.trend ? `${card.trend.recentTotal} in ${card.trend.periodDays} days` : (german ? 'Erste Dynamik' : 'Early momentum')}</span></div></div></div>, { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } });
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { Metadata } from 'next';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { db } from '@/lib/db';
|
|
||||||
import { getWwwOrigin } from '@/lib/hosts';
|
|
||||||
|
|
||||||
type Props = { params: { token: string } };
|
|
||||||
|
|
||||||
async function getShare(token: string) {
|
|
||||||
return db.socialMilestone.findFirst({
|
|
||||||
where: { shareToken: token, publicShareApprovedAt: { not: null } },
|
|
||||||
select: { cardData: true, language: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
||||||
const share = await getShare(params.token);
|
|
||||||
if (!share) return { robots: { index: false, follow: false } };
|
|
||||||
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
|
|
||||||
const count = card?.totalUniqueScans || 0;
|
|
||||||
const title = share.language === 'de'
|
|
||||||
? `${count.toLocaleString('de-DE')} eindeutige QR-Scans erreicht`
|
|
||||||
: `${count.toLocaleString('en-US')} unique QR scans reached`;
|
|
||||||
const url = `${getWwwOrigin()}/s/m/${params.token}`;
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
description: card?.qrTitle || 'A verified QR Master scan milestone.',
|
|
||||||
robots: { index: false, follow: false },
|
|
||||||
openGraph: { type: 'website', title, description: card?.qrTitle, url, images: [`${url}/og`] },
|
|
||||||
twitter: { card: 'summary_large_image', title, description: card?.qrTitle, images: [`${url}/og`] },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SocialMilestoneSharePage({ params }: Props) {
|
|
||||||
const share = await getShare(params.token);
|
|
||||||
if (!share) notFound();
|
|
||||||
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
|
|
||||||
return <main className="min-h-screen bg-slate-50 px-6 py-20 text-center text-[#061b31]"><div className="mx-auto max-w-xl rounded-xl border border-slate-200 bg-white p-10 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.35)]"><p className="text-xs font-semibold tracking-[0.14em] text-[#0256ff]">QR MASTER · VERIFIED MILESTONE</p><h1 className="mt-5 text-5xl font-semibold tracking-[-0.05em] tabular-nums">{(card?.totalUniqueScans || 0).toLocaleString(share.language === 'de' ? 'de-DE' : 'en-US')}</h1><p className="mt-2 text-slate-500">{share.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</p><p className="mt-8 text-lg font-medium">{card?.qrTitle}</p></div></main>;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { detectSocialMilestones } from '@/lib/social-milestones-server';
|
|
||||||
import { getSocialMilestoneThresholds } from '@/lib/social-milestones';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
function isAuthorized(request: NextRequest) {
|
|
||||||
const secret = process.env.CRON_SECRET;
|
|
||||||
return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detection only: this route never contacts customers or an external network.
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
const detected = await detectSocialMilestones();
|
|
||||||
return NextResponse.json({ ok: true, detected, thresholds: getSocialMilestoneThresholds() });
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { db } from '@/lib/db';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
function isAuthorized(request: NextRequest) {
|
|
||||||
const secret = process.env.INTERNAL_API_SECRET;
|
|
||||||
return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function approvalDelayHours() {
|
|
||||||
const configured = Number(process.env.SOCIAL_MILESTONE_POST_DELAY_HOURS);
|
|
||||||
return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This endpoint is intentionally a queue, not a social-media client. The
|
|
||||||
// external X worker fetches an approved payload and marks it complete only
|
|
||||||
// after its own post succeeded. The app never receives X credentials.
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
const dryRun = request.nextUrl.searchParams.get('dryRun') === 'true';
|
|
||||||
const now = Date.now();
|
|
||||||
const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000);
|
|
||||||
const milestone = await db.socialMilestone.findFirst({
|
|
||||||
where: { brandStatus: 'approved', brandApprovedAt: { lte: approvalNotBefore } },
|
|
||||||
orderBy: { brandApprovedAt: 'asc' },
|
|
||||||
include: { user: { select: { id: true } }, qr: { select: { id: true, status: true } } },
|
|
||||||
});
|
|
||||||
// Relations are required by the schema. This guard makes the intended
|
|
||||||
// revalidation explicit if retention policies are changed later.
|
|
||||||
if (!milestone || !milestone.user || !milestone.qr || milestone.qr.status !== 'ACTIVE') {
|
|
||||||
return NextResponse.json({ milestone: null });
|
|
||||||
}
|
|
||||||
if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText }, dryRun: true });
|
|
||||||
|
|
||||||
const claimed = await db.$transaction(async (tx) => {
|
|
||||||
await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)');
|
|
||||||
const result = await tx.socialMilestone.updateMany({
|
|
||||||
where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() },
|
|
||||||
});
|
|
||||||
return result.count;
|
|
||||||
});
|
|
||||||
if (!claimed) return NextResponse.json({ milestone: null, reason: 'claimed' });
|
|
||||||
return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, card: milestone.cardData } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
|
||||||
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
const body = await request.json().catch(() => null) as { id?: string; result?: 'posted' | 'failed'; postUrl?: string; error?: string } | null;
|
|
||||||
if (!body?.id || !['posted', 'failed'].includes(body.result || '')) return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
|
|
||||||
const updated = await db.socialMilestone.updateMany({
|
|
||||||
where: { id: body.id, brandStatus: 'processing' },
|
|
||||||
data: {
|
|
||||||
brandStatus: body.result!,
|
|
||||||
brandPostedAt: body.result === 'posted' ? new Date() : null,
|
|
||||||
brandPostUrl: body.result === 'posted' ? body.postUrl || null : null,
|
|
||||||
brandPostError: body.result === 'failed' ? (body.error || 'The post could not be published.') : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 });
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { db } from '@/lib/db';
|
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
|
||||||
import { getSessionUserId } from '@/lib/session';
|
|
||||||
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
|
|
||||||
|
|
||||||
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke';
|
|
||||||
|
|
||||||
async function ownedMilestone(id: string, userId: string) {
|
|
||||||
return db.socialMilestone.findFirst({
|
|
||||||
where: { id, userId },
|
|
||||||
include: { user: { select: { primaryUseCase: true } }, qr: { select: { title: true } } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clientState(milestone: { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) {
|
|
||||||
return {
|
|
||||||
brandStatus: milestone.brandStatus,
|
|
||||||
brandPostUrl: milestone.brandPostUrl,
|
|
||||||
brandPostError: milestone.brandPostError,
|
|
||||||
selfSharedAt: milestone.selfSharedAt?.toISOString() || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
|
|
||||||
const userId = getSessionUserId();
|
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
const milestone = await ownedMilestone(params.id, userId);
|
|
||||||
if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
||||||
return NextResponse.json({ milestone: clientState(milestone) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
|
|
||||||
const csrf = csrfProtection(request);
|
|
||||||
if (!csrf.valid) return NextResponse.json({ error: csrf.error }, { status: 403 });
|
|
||||||
const userId = getSessionUserId();
|
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
|
|
||||||
const body = await request.json().catch(() => null) as { action?: Action; withName?: boolean; xHandle?: string; language?: string } | null;
|
|
||||||
if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke'].includes(body.action || '')) {
|
|
||||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
||||||
}
|
|
||||||
const milestone = await ownedMilestone(params.id, userId);
|
|
||||||
if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
||||||
const threshold = milestoneThreshold(milestone.kind);
|
|
||||||
if (!threshold) return NextResponse.json({ error: 'Invalid milestone' }, { status: 400 });
|
|
||||||
const isDismissible = ['detected', 'shown'].includes(milestone.status);
|
|
||||||
|
|
||||||
if (body.action === 'revoke') {
|
|
||||||
if (!['approved', 'failed'].includes(milestone.brandStatus)) return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 });
|
|
||||||
const updated = await db.socialMilestone.update({ where: { id: milestone.id }, data: { brandStatus: 'revoked', brandPostError: null } });
|
|
||||||
return NextResponse.json({ ok: true, milestone: clientState(updated) });
|
|
||||||
}
|
|
||||||
if (body.action === 'decline' || body.action === 'opt_out') {
|
|
||||||
if (!isDismissible) return NextResponse.json({ error: 'This milestone has already been dismissed' }, { status: 409 });
|
|
||||||
const now = new Date();
|
|
||||||
await db.$transaction([
|
|
||||||
db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'declined', respondedAt: now } }),
|
|
||||||
...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []),
|
|
||||||
]);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const language = socialLocale(body.language);
|
|
||||||
const withName = body.action === 'approve_brand' && body.withName === true;
|
|
||||||
const xHandle = withName ? normalizeXHandle(body.xHandle || '') : null;
|
|
||||||
if (withName && !xHandle) return NextResponse.json({ error: 'Enter a valid X handle' }, { status: 400 });
|
|
||||||
const card = milestone.cardData || buildMilestoneCardSnapshot({
|
|
||||||
primaryUseCase: milestone.user.primaryUseCase,
|
|
||||||
qrTitle: milestone.qr.title,
|
|
||||||
totalUniqueScans: threshold,
|
|
||||||
milestoneThreshold: threshold,
|
|
||||||
reachedAt: milestone.detectedAt,
|
|
||||||
trend: null,
|
|
||||||
locale: language,
|
|
||||||
});
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
if (body.action === 'self_share') {
|
|
||||||
const token = milestone.shareToken || randomUUID().replace(/-/g, '');
|
|
||||||
const updated = await db.socialMilestone.update({
|
|
||||||
where: { id: milestone.id },
|
|
||||||
data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true, shareToken: token, milestone: clientState(updated) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) {
|
|
||||||
return NextResponse.json({ error: 'This brand post is already being processed' }, { status: 409 });
|
|
||||||
}
|
|
||||||
const consentText = buildMilestonePostForQr(
|
|
||||||
milestone.user.primaryUseCase,
|
|
||||||
(card as { totalUniqueScans?: number }).totalUniqueScans || threshold,
|
|
||||||
xHandle,
|
|
||||||
language,
|
|
||||||
milestone.qr.title,
|
|
||||||
);
|
|
||||||
const updated = await db.$transaction(async tx => {
|
|
||||||
if (withName) await tx.user.update({ where: { id: userId }, data: { xHandle } });
|
|
||||||
return tx.socialMilestone.update({
|
|
||||||
where: { id: milestone.id },
|
|
||||||
data: {
|
|
||||||
brandStatus: 'approved', brandApprovedAt: now, brandPostError: null,
|
|
||||||
withName, consentText, language, cardData: card, respondedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true, consentText, milestone: clientState(updated) });
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { db } from '@/lib/db';
|
|
||||||
import { getSessionUserId } from '@/lib/session';
|
|
||||||
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
// Returns at most one item. A missing response is treated as no consent, never as approval.
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const userId = getSessionUserId();
|
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
|
|
||||||
const user = await db.user.findUnique({
|
|
||||||
where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, primaryUseCase: true },
|
|
||||||
});
|
|
||||||
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
|
||||||
|
|
||||||
const milestone = await db.socialMilestone.findFirst({
|
|
||||||
where: { userId, status: { in: ['detected', 'shown'] } },
|
|
||||||
orderBy: { detectedAt: 'asc' },
|
|
||||||
include: { qr: { select: { title: true } } },
|
|
||||||
});
|
|
||||||
if (!milestone) return NextResponse.json({ milestone: null });
|
|
||||||
|
|
||||||
if (milestone.status === 'detected') {
|
|
||||||
await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'shown', shownAt: new Date() } });
|
|
||||||
}
|
|
||||||
const threshold = milestoneThreshold(milestone.kind);
|
|
||||||
if (!threshold) return NextResponse.json({ milestone: null });
|
|
||||||
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
milestone: {
|
|
||||||
id: milestone.id, qrTitle: milestone.qr.title, threshold,
|
|
||||||
defaultXHandle: user.xHandle,
|
|
||||||
brandStatus: milestone.brandStatus,
|
|
||||||
brandPostUrl: milestone.brandPostUrl,
|
|
||||||
brandPostError: milestone.brandPostError,
|
|
||||||
language: locale,
|
|
||||||
preview: buildMilestonePostForQr(
|
|
||||||
user.primaryUseCase,
|
|
||||||
((milestone.cardData as { totalUniqueScans?: number } | null)?.totalUniqueScans || threshold),
|
|
||||||
null,
|
|
||||||
locale,
|
|
||||||
milestone.qr.title,
|
|
||||||
),
|
|
||||||
card: milestone.cardData || buildMilestoneCardSnapshot({
|
|
||||||
primaryUseCase: user.primaryUseCase,
|
|
||||||
qrTitle: milestone.qr.title,
|
|
||||||
totalUniqueScans: threshold,
|
|
||||||
milestoneThreshold: threshold,
|
|
||||||
reachedAt: milestone.detectedAt,
|
|
||||||
trend: null,
|
|
||||||
locale,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import { getWwwOrigin } from '@/lib/hosts';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { hashIP } from '@/lib/hash';
|
import { hashIP } from '@/lib/hash';
|
||||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||||
import { detectSocialMilestones } from '@/lib/social-milestones-server';
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -261,12 +260,6 @@ async function trackScan(qrId: string, userId: string, request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// The customer sees a newly crossed milestone on their next dashboard
|
|
||||||
// visit; no separate cron invocation is required after a real scan.
|
|
||||||
if (isUnique) {
|
|
||||||
await detectSocialMilestones(qrId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activatedUsers = await db.user.updateMany({
|
const activatedUsers = await db.user.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Check, ExternalLink, LineChart, Linkedin, Send, Sparkles, X } from 'lucide-react';
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
|
||||||
import { showToast } from '@/components/ui/Toast';
|
|
||||||
|
|
||||||
type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalUniqueScans: number; milestoneThreshold: number; trend: { periodDays: number; series: number[]; recentTotal: number } | null };
|
|
||||||
type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; language: 'en' | 'de'; card: Card; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null };
|
|
||||||
type BrandState = { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
|
|
||||||
|
|
||||||
function Trend({ series }: { series: number[] }) {
|
|
||||||
const max = Math.max(...series, 1);
|
|
||||||
const points = series.map((value, index) => `${(index / Math.max(series.length - 1, 1)) * 100},${92 - (value / max) * 70}`).join(' ');
|
|
||||||
return <svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-20 w-full overflow-visible" aria-label="Real scan trend"><polyline points={points} fill="none" stroke="currentColor" strokeWidth="3" vectorEffect="non-scaling-stroke" /></svg>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SocialMilestoneDialog() {
|
|
||||||
const { fetchWithCsrf } = useCsrf();
|
|
||||||
const { locale } = useTranslation();
|
|
||||||
const [milestone, setMilestone] = useState<Milestone | null>(null);
|
|
||||||
const [withName, setWithName] = useState(false);
|
|
||||||
const [xHandle, setXHandle] = useState('');
|
|
||||||
const [saving, setSaving] = useState<'brand' | 'self' | null>(null);
|
|
||||||
const [brand, setBrand] = useState<BrandState | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`/api/social-milestones?locale=${locale}`).then(async response => {
|
|
||||||
if (response.ok) {
|
|
||||||
const next = (await response.json()).milestone as Milestone | null;
|
|
||||||
setMilestone(next);
|
|
||||||
if (next) setBrand({ brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null });
|
|
||||||
}
|
|
||||||
}).catch(() => undefined);
|
|
||||||
}, [locale]);
|
|
||||||
useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!milestone || !['approved', 'processing'].includes(brand?.brandStatus || '')) return;
|
|
||||||
const poll = async () => {
|
|
||||||
const response = await fetch(`/api/social-milestones/${milestone.id}`);
|
|
||||||
if (response.ok) setBrand((await response.json()).milestone);
|
|
||||||
};
|
|
||||||
const timer = window.setInterval(poll, 3000);
|
|
||||||
void poll();
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [milestone, brand?.brandStatus]);
|
|
||||||
|
|
||||||
const copy = milestone?.language === 'de'
|
|
||||||
? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf dem eigenen X-Account veröffentlichen?', name: 'Meinen X-Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Auf QR Master posten', queued: 'Wird auf X veröffentlicht …', posted: 'Auf X veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' }
|
|
||||||
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success from our X account?', name: 'Mention my X handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Publishing to X …', posted: 'Published on X', failed: 'Publishing failed' };
|
|
||||||
const preview = useMemo(() => {
|
|
||||||
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
|
|
||||||
return `${milestone.preview} By @${xHandle.trim().replace(/^@/, '')}.`;
|
|
||||||
}, [milestone, withName, xHandle]);
|
|
||||||
const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
|
|
||||||
if (!milestone) return null;
|
|
||||||
const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { method: 'PATCH', body: JSON.stringify({ action, withName, xHandle, language: milestone.language }) });
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok) throw new Error(result.error || 'Could not save your choice');
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
const shareSelf = async (network: 'x' | 'linkedin') => {
|
|
||||||
if (!milestone) return;
|
|
||||||
setSaving('self');
|
|
||||||
try {
|
|
||||||
const result = await update('self_share');
|
|
||||||
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`;
|
|
||||||
const text = `${preview} ${shareUrl}`;
|
|
||||||
if (network === 'x') window.open(`https://x.com/intent/post?text=${encodeURIComponent(text)}`, '_blank', 'noopener,noreferrer');
|
|
||||||
else {
|
|
||||||
await navigator.clipboard?.writeText(text);
|
|
||||||
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,noreferrer');
|
|
||||||
}
|
|
||||||
setBrand(result.milestone);
|
|
||||||
showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success');
|
|
||||||
} catch (error) { showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); }
|
|
||||||
finally { setSaving(null); }
|
|
||||||
};
|
|
||||||
const approveBrand = async () => {
|
|
||||||
setSaving('brand');
|
|
||||||
try {
|
|
||||||
const result = await update('approve_brand');
|
|
||||||
setBrand(result.milestone);
|
|
||||||
showToast(copy.queued, 'success');
|
|
||||||
} catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
|
|
||||||
finally { setSaving(null); }
|
|
||||||
};
|
|
||||||
const dismiss = async (action: 'decline' | 'opt_out') => {
|
|
||||||
try { await update(action); setMilestone(null); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!milestone) return null;
|
|
||||||
const card = milestone.card;
|
|
||||||
const count = card.totalUniqueScans || milestone.threshold;
|
|
||||||
const status = brand?.brandStatus || milestone.brandStatus || 'pending';
|
|
||||||
return <Dialog open onOpenChange={open => !open && setMilestone(null)}>
|
|
||||||
<DialogContent className="max-w-xl overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)]">
|
|
||||||
<div className="px-7 pb-5 pt-7"><DialogHeader><div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-5 w-5" /></div><DialogTitle className="text-2xl font-semibold tracking-[-0.03em] text-[#061b31]">{copy.heading}</DialogTitle><DialogDescription className="pt-1 text-sm leading-6 text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></DialogHeader></div>
|
|
||||||
<div className="space-y-5 border-y border-slate-100 px-7 py-6">
|
|
||||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
|
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-4 text-xs"><span className="font-semibold tracking-wide text-[#061b31]">QR MASTER</span><span className="rounded bg-emerald-50 px-2 py-1 font-medium text-emerald-700"><Check className="mr-1 inline h-3 w-3" />Verified scan milestone</span></div>
|
|
||||||
<div className="mt-5 text-[11px] font-semibold tracking-[0.12em] text-slate-400">TOTAL UNIQUE SCANS</div><div className="mt-1 text-5xl font-semibold tracking-[-0.05em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div><div className="mt-1 text-sm text-slate-500">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div>
|
|
||||||
{card.trend ? <div className="mt-5 text-[#0256ff]"><Trend series={card.trend.series} /><div className="mt-2 flex items-center gap-2 text-xs text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{card.trend.recentTotal} {milestone.language === 'de' ? 'eindeutige Scans in den letzten' : 'unique scans in the last'} {card.trend.periodDays} days</div></div> : <div className="mt-5 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-xs font-medium text-blue-700"><LineChart className="h-3.5 w-3.5" />{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}</div>}
|
|
||||||
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
|
||||||
</section>
|
|
||||||
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 border-l-2 border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
|
|
||||||
<label className="flex cursor-pointer items-center gap-3 text-sm font-medium text-slate-700"><input type="checkbox" checked={withName} onChange={event => setWithName(event.target.checked)} disabled={status !== 'pending'} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
|
|
||||||
{withName && <input aria-label="X handle" value={xHandle} onChange={event => setXHandle(event.target.value)} disabled={status !== 'pending'} placeholder="@yourhandle" className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-[#0256ff] focus:ring-2 focus:ring-blue-100" />}
|
|
||||||
<div className="flex flex-wrap items-center gap-2"><span className="mr-1 text-xs font-medium text-slate-500">{copy.self}</span><Button variant="outline" size="sm" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />LinkedIn</Button></div>
|
|
||||||
{status !== 'pending' && <div className={`flex items-center justify-between rounded-md px-3 py-2 text-sm ${status === 'posted' ? 'bg-emerald-50 text-emerald-800' : status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}><span>{status === 'posted' ? copy.posted : status === 'failed' ? copy.failed : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="bg-slate-50 px-7 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => dismiss('decline')} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || status !== 'pending'}><Send className="mr-1.5 h-4 w-4" />{copy.approve}</Button><button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={() => dismiss('opt_out')} disabled={saving !== null}>Do not show again</button></div></DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>;
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { db } from '@/lib/db';
|
|
||||||
import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, milestoneKind, SocialLocale } from '@/lib/social-milestones';
|
|
||||||
|
|
||||||
function excludedEmails() {
|
|
||||||
return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '')
|
|
||||||
.split(',').map(email => email.trim().toLowerCase()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates any newly crossed milestones. Safe to call repeatedly. */
|
|
||||||
export async function detectSocialMilestones(qrId?: string) {
|
|
||||||
const excluded = excludedEmails();
|
|
||||||
const candidates = await db.qRScan.groupBy({
|
|
||||||
by: ['qrId'],
|
|
||||||
where: {
|
|
||||||
isUnique: true,
|
|
||||||
...(qrId ? { qrId } : {}),
|
|
||||||
qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined },
|
|
||||||
},
|
|
||||||
_count: { _all: true },
|
|
||||||
});
|
|
||||||
const records = candidates.flatMap(({ qrId: candidateQrId, _count }) =>
|
|
||||||
getSocialMilestoneThresholds()
|
|
||||||
.filter(threshold => _count._all >= threshold)
|
|
||||||
.map(threshold => ({ qrId: candidateQrId, kind: milestoneKind(threshold) }))
|
|
||||||
);
|
|
||||||
if (!records.length) return 0;
|
|
||||||
|
|
||||||
const qrs = await db.qRCode.findMany({
|
|
||||||
where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } },
|
|
||||||
select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } },
|
|
||||||
});
|
|
||||||
const cardByQr = new Map<string, Awaited<ReturnType<typeof createCardSnapshot>>>();
|
|
||||||
await Promise.all(qrs.map(async qr => cardByQr.set(qr.id, await createCardSnapshot(qr))));
|
|
||||||
const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId]));
|
|
||||||
const created = await db.socialMilestone.createMany({
|
|
||||||
data: records.filter(record => userIdByQr.has(record.qrId)).map(record => ({
|
|
||||||
...record,
|
|
||||||
userId: userIdByQr.get(record.qrId)!,
|
|
||||||
cardData: cardByQr.get(record.qrId),
|
|
||||||
})),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
return created.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) {
|
|
||||||
const now = new Date();
|
|
||||||
const sevenDaysAgo = new Date(now);
|
|
||||||
sevenDaysAgo.setUTCHours(0, 0, 0, 0);
|
|
||||||
sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 6);
|
|
||||||
const scans = await db.qRScan.findMany({
|
|
||||||
where: { qrId: qr.id, isUnique: true },
|
|
||||||
select: { ts: true },
|
|
||||||
orderBy: { ts: 'asc' },
|
|
||||||
});
|
|
||||||
const daily = new Map<string, number>();
|
|
||||||
for (const scan of scans) {
|
|
||||||
if (scan.ts >= sevenDaysAgo) {
|
|
||||||
const key = scan.ts.toISOString().slice(0, 10);
|
|
||||||
daily.set(key, (daily.get(key) || 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const series = Array.from({ length: 7 }, (_, index) => {
|
|
||||||
const date = new Date(sevenDaysAgo);
|
|
||||||
date.setUTCDate(date.getUTCDate() + index);
|
|
||||||
return daily.get(date.toISOString().slice(0, 10)) || 0;
|
|
||||||
});
|
|
||||||
const activeDays = series.filter(Boolean).length;
|
|
||||||
const trend = activeDays >= 2 && scans.length >= 5
|
|
||||||
? { periodDays: 7, series, recentTotal: series.reduce((total, value) => total + value, 0) }
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// The snapshot is made at detection time and never silently changes after consent.
|
|
||||||
return buildMilestoneCardSnapshot({
|
|
||||||
primaryUseCase: qr.user.primaryUseCase,
|
|
||||||
qrTitle: qr.title,
|
|
||||||
totalUniqueScans: scans.length,
|
|
||||||
milestoneThreshold: scans.length,
|
|
||||||
reachedAt: now,
|
|
||||||
trend,
|
|
||||||
locale: 'en' as SocialLocale,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
export const DEFAULT_SOCIAL_MILESTONE_THRESHOLDS = [1000, 10000] as const;
|
|
||||||
|
|
||||||
export type SocialMilestoneKind = `unique_scans_${number}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Staging can set SOCIAL_MILESTONE_THRESHOLDS=1 (or e.g. 1,2) so the complete
|
|
||||||
* flow is testable without fabricating thousands of scans. Production keeps
|
|
||||||
* the conservative defaults unless its environment explicitly changes them.
|
|
||||||
*/
|
|
||||||
export function getSocialMilestoneThresholds(): number[] {
|
|
||||||
const configured = process.env.SOCIAL_MILESTONE_THRESHOLDS;
|
|
||||||
if (!configured) return [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS];
|
|
||||||
|
|
||||||
const thresholds = Array.from(new Set(
|
|
||||||
configured.split(',')
|
|
||||||
.map(value => Number(value.trim()))
|
|
||||||
.filter(value => Number.isInteger(value) && value > 0 && value <= 1_000_000)
|
|
||||||
)).sort((a, b) => a - b);
|
|
||||||
|
|
||||||
return thresholds.length ? thresholds : [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS];
|
|
||||||
}
|
|
||||||
|
|
||||||
const useCaseLabels: Record<string, string> = {
|
|
||||||
menu_pdf: 'menu QR code',
|
|
||||||
marketing_campaign: 'campaign QR code',
|
|
||||||
vcard: 'digital business-card QR code',
|
|
||||||
event: 'event QR code',
|
|
||||||
feedback: 'feedback QR code',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function milestoneKind(threshold: number): SocialMilestoneKind {
|
|
||||||
return `unique_scans_${threshold}` as SocialMilestoneKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function milestoneThreshold(kind: string): number | null {
|
|
||||||
const result = /^unique_scans_(\d+)$/.exec(kind);
|
|
||||||
return result ? Number(result[1]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SocialLocale = 'en' | 'de';
|
|
||||||
|
|
||||||
export type SocialMilestoneCard = {
|
|
||||||
version: 'milestone-card-v2';
|
|
||||||
language: SocialLocale;
|
|
||||||
qrTitle: string;
|
|
||||||
label: string;
|
|
||||||
title: string;
|
|
||||||
totalUniqueScans: number;
|
|
||||||
milestoneThreshold: number;
|
|
||||||
reachedAt: string;
|
|
||||||
trend: { periodDays: number; series: number[]; recentTotal: number } | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function socialLocale(value?: string | null): SocialLocale {
|
|
||||||
return value === 'de' ? 'de' : 'en';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usageLabel(primaryUseCase: string | null, locale: SocialLocale = 'en'): string {
|
|
||||||
if (locale === 'de') {
|
|
||||||
const german: Record<string, string> = { menu_pdf: 'Speisekarten-QR-Code', marketing_campaign: 'Kampagnen-QR-Code', vcard: 'Visitenkarten-QR-Code', event: 'Event-QR-Code', feedback: 'Feedback-QR-Code' };
|
|
||||||
return (primaryUseCase && german[primaryUseCase]) || 'QR-Code';
|
|
||||||
}
|
|
||||||
return (primaryUseCase && useCaseLabels[primaryUseCase]) || 'QR code';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMilestonePost(primaryUseCase: string | null, threshold: number, xHandle?: string | null, locale: SocialLocale = 'en'): string {
|
|
||||||
const count = threshold.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
|
|
||||||
const base = locale === 'de'
|
|
||||||
? `Ein ${usageLabel(primaryUseCase, locale)} hat gerade ${count} eindeutige Scans erreicht. 🎉`
|
|
||||||
: `A ${usageLabel(primaryUseCase, locale)} just reached ${count} unique scans. 🎉`;
|
|
||||||
return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMilestoneCard(primaryUseCase: string | null, threshold: number, locale: SocialLocale) {
|
|
||||||
return { version: 'milestone-card-v1', language: locale, threshold, label: usageLabel(primaryUseCase, locale), title: locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMilestonePostForQr(primaryUseCase: string | null, totalUniqueScans: number, xHandle: string | null | undefined, locale: SocialLocale, qrTitle: string): string {
|
|
||||||
const count = totalUniqueScans.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
|
|
||||||
const subject = qrTitle.trim() || usageLabel(primaryUseCase, locale);
|
|
||||||
const base = locale === 'de'
|
|
||||||
? `„${subject}“ hat ${count} eindeutige Scans erreicht.`
|
|
||||||
: `“${subject}” reached ${count} unique scans.`;
|
|
||||||
return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMilestoneCardSnapshot(input: {
|
|
||||||
primaryUseCase: string | null;
|
|
||||||
qrTitle: string;
|
|
||||||
totalUniqueScans: number;
|
|
||||||
milestoneThreshold: number;
|
|
||||||
reachedAt: Date;
|
|
||||||
trend: { periodDays: number; series: number[]; recentTotal: number } | null;
|
|
||||||
locale: SocialLocale;
|
|
||||||
}): SocialMilestoneCard {
|
|
||||||
return {
|
|
||||||
version: 'milestone-card-v2',
|
|
||||||
language: input.locale,
|
|
||||||
qrTitle: input.qrTitle,
|
|
||||||
label: usageLabel(input.primaryUseCase, input.locale),
|
|
||||||
title: input.locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone',
|
|
||||||
totalUniqueScans: input.totalUniqueScans,
|
|
||||||
milestoneThreshold: input.milestoneThreshold,
|
|
||||||
reachedAt: input.reachedAt.toISOString(),
|
|
||||||
trend: input.trend,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeXHandle(value: string): string | null {
|
|
||||||
const handle = value.trim().replace(/^@/, '');
|
|
||||||
return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user