From 31cba6d883cdfb3fb99a728a3c38fb61997f1bc9 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Thu, 13 Aug 2026 06:10:01 -0500 Subject: [PATCH 01/19] Anpassungen --- docker/init-db.sh | 54 ++++++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 26 deletions(-) mode change 100644 => 100755 docker/init-db.sh diff --git a/docker/init-db.sh b/docker/init-db.sh old mode 100644 new mode 100755 index 57f56a8..c3e8d25 --- a/docker/init-db.sh +++ b/docker/init-db.sh @@ -1,26 +1,28 @@ -#!/bin/bash -set -e - -# This script runs when the PostgreSQL container is first created -# It ensures the database is properly initialized - -echo "🚀 Initializing QR Master database..." - -# Create the database if it doesn't exist (already created by POSTGRES_DB) -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - -- Enable required extensions - CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - CREATE EXTENSION IF NOT EXISTS "pg_trgm"; - - -- Grant privileges - GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres; - - -- Set timezone - ALTER DATABASE qrmaster SET timezone TO 'UTC'; -EOSQL - -echo "✅ Database initialization complete!" -echo "📊 Database: $POSTGRES_DB" -echo "👤 User: $POSTGRES_USER" -echo "🌐 Ready to accept connections on port 5432" - +#!/bin/bash +set -e + +# This script runs when the PostgreSQL container is first created +# It ensures the database is properly initialized +# +# Keep this database-name agnostic: the staging stack (docker-compose.test.yml) +# runs the same script with POSTGRES_DB=qrmaster_test. A hardcoded name aborts +# the init, and the container never becomes healthy. +# Must stay LF-only and executable - Postgres sources non-executable init +# scripts, and CRLF breaks them on the first line. + +echo "🚀 Initializing QR Master database..." + +# The database itself is already created by POSTGRES_DB +psql -v ON_ERROR_STOP=1 -v dbname="$POSTGRES_DB" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + -- Enable required extensions + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + + -- Set timezone + ALTER DATABASE :"dbname" SET timezone TO 'UTC'; +EOSQL + +echo "✅ Database initialization complete!" +echo "📊 Database: $POSTGRES_DB" +echo "👤 User: $POSTGRES_USER" +echo "🌐 Ready to accept connections on port 5432" From d2c5f2848a82757d018727755a188f3e215c05d8 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Thu, 13 Aug 2026 08:32:11 -0500 Subject: [PATCH 02/19] network 0.0.0.0 --- docker-compose.test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 1c02db2..d3c7978 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -38,6 +38,8 @@ services: # 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. # Bring the schema in with `pg_dump --schema-only` from production instead. + environment: + HOSTNAME: "0.0.0.0" entrypoint: ["node", "server.js"] build: args: From 14c429ff30d08a95016deae96b8e09b0e02c67ca Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Thu, 13 Aug 2026 10:07:57 -0500 Subject: [PATCH 03/19] fix --- docker-compose.test.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d3c7978..3cedde7 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -39,7 +39,16 @@ services: # 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. environment: - HOSTNAME: "0.0.0.0" + # Docker sets HOSTNAME=, 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"] build: args: From f7d82aa5bdb68b047f41f867b678088bb08a2741 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 09:03:10 +0200 Subject: [PATCH 04/19] Add consented social milestone posting --- docker-compose.yml | 28 ++++- docs/automations/social-milestone-worker.md | 49 +++++++++ prisma/schema.prisma | 32 ++++++ scripts/social-worker/Dockerfile | 6 + scripts/social-worker/requirements.txt | 3 + scripts/social-worker/worker.py | 86 +++++++++++++++ sql/2026-08-13_social_milestones.sql | 30 +++++ src/app/(main)/(app)/dashboard/page.tsx | 2 + .../api/cron/social-milestones/route.ts | 53 +++++++++ .../api/internal/social-milestones/route.ts | 65 +++++++++++ .../api/social-milestones/[id]/route.ts | 48 ++++++++ src/app/(main)/api/social-milestones/route.ts | 41 +++++++ .../dashboard/SocialMilestoneDialog.tsx | 104 ++++++++++++++++++ src/lib/social-milestones.ts | 69 ++++++++++++ 14 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 docs/automations/social-milestone-worker.md create mode 100644 scripts/social-worker/Dockerfile create mode 100644 scripts/social-worker/requirements.txt create mode 100644 scripts/social-worker/worker.py create mode 100644 sql/2026-08-13_social_milestones.sql create mode 100644 src/app/(main)/api/cron/social-milestones/route.ts create mode 100644 src/app/(main)/api/internal/social-milestones/route.ts create mode 100644 src/app/(main)/api/social-milestones/[id]/route.ts create mode 100644 src/app/(main)/api/social-milestones/route.ts create mode 100644 src/components/dashboard/SocialMilestoneDialog.tsx create mode 100644 src/lib/social-milestones.ts diff --git a/docker-compose.yml b/docker-compose.yml index 6580a69..5304638 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,7 @@ services: - qrmaster-network # Next.js Application - web: + web: build: context: . dockerfile: Dockerfile @@ -62,6 +62,9 @@ services: COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-} 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_SECRET: ${TIKTOK_CLIENT_SECRET:-} TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback} @@ -111,8 +114,27 @@ services: interval: 10s timeout: 3s retries: 10 - networks: - - qrmaster-network + networks: + - 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) diff --git a/docs/automations/social-milestone-worker.md b/docs/automations/social-milestone-worker.md new file mode 100644 index 0000000..36315bb --- /dev/null +++ b/docs/automations/social-milestone-worker.md @@ -0,0 +1,49 @@ +# 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":"","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. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36406e2..cb6cbbe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,12 @@ model User { accounts Account[] sessions Session[] 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 { @@ -148,11 +154,37 @@ model QRCode { user User @relation(fields: [userId], references: [id], onDelete: Cascade) scans QRScan[] + socialMilestones SocialMilestone[] @@index([userId, createdAt]) @@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? + + 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 { STATIC DYNAMIC diff --git a/scripts/social-worker/Dockerfile b/scripts/social-worker/Dockerfile new file mode 100644 index 0000000..d3d3458 --- /dev/null +++ b/scripts/social-worker/Dockerfile @@ -0,0 +1,6 @@ +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"] diff --git a/scripts/social-worker/requirements.txt b/scripts/social-worker/requirements.txt new file mode 100644 index 0000000..8d2307a --- /dev/null +++ b/scripts/social-worker/requirements.txt @@ -0,0 +1,3 @@ +Pillow>=10 +requests>=2.31 +requests-oauthlib>=2.0 diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py new file mode 100644 index 0000000..7561ddc --- /dev/null +++ b/scripts/social-worker/worker.py @@ -0,0 +1,86 @@ +"""Always-on QRMaster 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 render_card(card): + image = Image.new("RGB", (1200, 675), "#061b31") + draw = ImageDraw.Draw(image) + fonts = Path("/usr/share/fonts/truetype/dejavu") + bold = ImageFont.truetype(str(fonts / "DejaVuSans-Bold.ttf"), 112) + regular = ImageFont.truetype(str(fonts / "DejaVuSans.ttf"), 34) + image_draw = draw + image_draw.rounded_rectangle((55, 55, 1145, 620), radius=28, outline="#304866", width=2) + image_draw.text((100, 105), "QR MASTER", font=regular, fill="#dce8f7") + image_draw.text((100, 210), f"{card['threshold']:,}", font=bold, fill="#ffffff") + scans = "eindeutige Scans" if card.get("language") == "de" else "unique scans" + image_draw.text((105, 350), scans, font=regular, fill="#b8c7da") + image_draw.line((100, 500, 1100, 500), fill="#304866", width=2) + image_draw.text((100, 535), f"{card['label']} · {card['title']}", font=regular, fill="#dce8f7") + 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")) + api("PATCH", base, {"id": milestone["id"], "result": "posted"}) + print(json.dumps({"posted": milestone["id"], "x": result}), flush=True) + except Exception as error: + api("PATCH", base, {"id": milestone["id"], "result": "failed"}) + 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) diff --git a/sql/2026-08-13_social_milestones.sql b/sql/2026-08-13_social_milestones.sql new file mode 100644 index 0000000..3d56422 --- /dev/null +++ b/sql/2026-08-13_social_milestones.sql @@ -0,0 +1,30 @@ +-- 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"); diff --git a/src/app/(main)/(app)/dashboard/page.tsx b/src/app/(main)/(app)/dashboard/page.tsx index 2aa229e..4820b89 100644 --- a/src/app/(main)/(app)/dashboard/page.tsx +++ b/src/app/(main)/(app)/dashboard/page.tsx @@ -16,6 +16,7 @@ import { QrCode } from 'lucide-react'; import { trackEvent, identifyUser } from '@/components/PostHogProvider'; import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans'; import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist'; +import { SocialMilestoneDialog } from '@/components/dashboard/SocialMilestoneDialog'; interface QRCodeData { id: string; @@ -322,6 +323,7 @@ export default function DashboardPage() { return (
+ {/* Header with Plan Badge */}
diff --git a/src/app/(main)/api/cron/social-milestones/route.ts b/src/app/(main)/api/cron/social-milestones/route.ts new file mode 100644 index 0000000..a546a9d --- /dev/null +++ b/src/app/(main)/api/cron/social-milestones/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSocialMilestoneThresholds, milestoneKind } 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}`; +} + +function excludedEmails() { + return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '') + .split(',').map(email => email.trim().toLowerCase()).filter(Boolean); +} + +// 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 excluded = excludedEmails(); + const thresholds = getSocialMilestoneThresholds(); + const candidates = await db.qRScan.groupBy({ + by: ['qrId'], + where: { + isUnique: true, + qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined }, + }, + _count: { _all: true }, + }); + + const records = candidates.flatMap(({ qrId, _count }) => + thresholds + .filter(threshold => _count._all >= threshold) + .map(threshold => ({ qrId, kind: milestoneKind(threshold) })) + ); + + if (records.length) { + const qrs = await db.qRCode.findMany({ + where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } }, + select: { id: true, userId: true }, + }); + const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId])); + await db.socialMilestone.createMany({ + data: records + .filter(record => userIdByQr.has(record.qrId)) + .map(record => ({ ...record, userId: userIdByQr.get(record.qrId)! })), + skipDuplicates: true, + }); + } + + return NextResponse.json({ ok: true, detected: records.length, thresholds }); +} diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts new file mode 100644 index 0000000..52d3a57 --- /dev/null +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -0,0 +1,65 @@ +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 dayAgo = new Date(now - 24 * 60 * 60 * 1000); + const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000); + const postedToday = await db.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } }); + if (postedToday > 0) return NextResponse.json({ milestone: null, reason: 'daily_limit' }); + + const milestone = await db.socialMilestone.findFirst({ + where: { status: 'approved', respondedAt: { lte: approvalNotBefore } }, + orderBy: { respondedAt: '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 }); + + // A claimed item also occupies the daily slot. This prevents two workers + // from each claiming a different milestone before either one posts. + const claimed = await db.$transaction(async (tx) => { + await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)'); + const occupied = await tx.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } }); + if (occupied) return 0; + const result = await tx.socialMilestone.updateMany({ + where: { id: milestone.id, status: 'approved' }, data: { status: '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' } | 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, status: 'processing' }, + data: { status: body.result!, postedAt: body.result === 'posted' ? new Date() : null }, + }); + if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts new file mode 100644 index 0000000..117db86 --- /dev/null +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { csrfProtection } from '@/lib/csrf'; +import { getSessionUserId } from '@/lib/session'; +import { buildMilestoneCard, buildMilestonePost, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones'; + +type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke'; + +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 db.socialMilestone.findFirst({ + where: { id: params.id, userId }, include: { user: { select: { primaryUseCase: true } } }, + }); + 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 }); + + if (body.action === 'revoke') { + if (milestone.status !== 'approved') return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 }); + await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'revoked', respondedAt: new Date() } }); + return NextResponse.json({ ok: true }); + } + if (!['detected', 'shown'].includes(milestone.status)) return NextResponse.json({ error: 'This milestone has already been answered' }, { status: 409 }); + + const withName = body.action === 'approve_brand' && body.withName === true; + const language = socialLocale(body.language); + const xHandle = withName ? normalizeXHandle(body.xHandle || '') : null; + if (withName && !xHandle) return NextResponse.json({ error: 'Enter a valid X handle' }, { status: 400 }); + const status = body.action === 'approve_brand' ? 'approved' : body.action === 'self_share' ? 'self_shared' : 'declined'; + const consentText = body.action === 'approve_brand' + ? buildMilestonePost(milestone.user.primaryUseCase, threshold, xHandle, language) + : null; + const now = new Date(); + await db.$transaction([ + db.socialMilestone.update({ where: { id: milestone.id }, data: { status, withName, consentText, language, cardData: body.action === 'approve_brand' ? buildMilestoneCard(milestone.user.primaryUseCase, threshold, language) : undefined, respondedAt: now } }), + ...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []), + ...(withName ? [db.user.update({ where: { id: userId }, data: { xHandle } })] : []), + ]); + return NextResponse.json({ ok: true, consentText }); +} diff --git a/src/app/(main)/api/social-milestones/route.ts b/src/app/(main)/api/social-milestones/route.ts new file mode 100644 index 0000000..40daa60 --- /dev/null +++ b/src/app/(main)/api/social-milestones/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSessionUserId } from '@/lib/session'; +import { buildMilestoneCard, buildMilestonePost, 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, + language: locale, + preview: buildMilestonePost(user.primaryUseCase, threshold, null, locale), + card: buildMilestoneCard(user.primaryUseCase, threshold, locale), + }, + }); +} diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx new file mode 100644 index 0000000..c640fee --- /dev/null +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { QrCode, Sparkles } 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 Milestone = { + id: string; + qrTitle: string; + threshold: number; + defaultXHandle: string | null; + preview: string; + language: 'en' | 'de'; + card: { title: string; label: string }; +}; + +export function SocialMilestoneDialog() { + const { fetchWithCsrf } = useCsrf(); + const { locale } = useTranslation(); + const [milestone, setMilestone] = useState(null); + const [withName, setWithName] = useState(false); + const [xHandle, setXHandle] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetch(`/api/social-milestones?locale=${locale}`) + .then(async (response) => response.ok && setMilestone((await response.json()).milestone)) + .catch(() => undefined); + }, [locale]); + + useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]); + + const copy = milestone?.language === 'de' + ? { heading: 'Ein echter Erfolg', subtitle: 'hat gerade einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg mit der unten stehenden Karte auf dem eigenen X-Account teilen?', name: 'Meinen X-Handle nennen', decline: 'Nein, danke', optOut: 'Nicht mehr anzeigen', self: 'Selbst teilen', approve: 'Auf QR Master posten', published: 'Der Beitrag wird jetzt auf dem QR Master X-Account veroeffentlicht.' } + : { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master share this success, including the card below, from our X account?', name: 'Mention my X handle', decline: 'No thanks', optOut: 'Do not show again', self: 'Share myself', approve: 'Post from QR Master', published: 'This will now be published from the QR Master X account.' }; + + const preview = useMemo(() => { + if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || ''; + return `${milestone.preview} By @${xHandle.trim().replace(/^@/, '')}.`; + }, [milestone, withName, xHandle]); + + const respond = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => { + if (!milestone) return; + setSaving(true); + try { + 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'); + if (action === 'self_share') { + await navigator.clipboard?.writeText(preview); + window.open(`https://x.com/intent/post?text=${encodeURIComponent(preview)}`, '_blank', 'noopener,noreferrer'); + window.open('https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.qrmaster.net', '_blank', 'noopener,noreferrer'); + showToast('Post text copied for LinkedIn.', 'success'); + } else if (action === 'approve_brand') { + showToast(copy.published, 'success'); + } + setMilestone(null); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); + } finally { + setSaving(false); + } + }; + + if (!milestone) return null; + + return !open && setMilestone(null)}> + +
+ +
+ {copy.heading} + {milestone.qrTitle} {copy.subtitle} +
+
+
+
+
QR MASTERVerified
+
{milestone.threshold.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
+
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
+
{milestone.card.label} · {milestone.card.title}
+
+

{copy.consent}

+
{preview}
+ + {withName && setXHandle(event.target.value)} placeholder="@yourhandle" className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-2 focus:ring-violet-100" />} +
+ +
+ + + + +
+
+
+
; +} diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts new file mode 100644 index 0000000..c2b91a2 --- /dev/null +++ b/src/lib/social-milestones.ts @@ -0,0 +1,69 @@ +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 = { + 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 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 = { 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 normalizeXHandle(value: string): string | null { + const handle = value.trim().replace(/^@/, ''); + return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null; +} From 72392e8cec6c54995ee59fcaaa01af20d7b936bd Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Thu, 13 Aug 2026 17:44:55 +0200 Subject: [PATCH 05/19] info instead of timo --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5304638..e57d0a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,7 +88,7 @@ services: RESEND_API_KEY: ${RESEND_API_KEY:-} SMTP_HOST: ${SMTP_HOST:-smtp.qrmaster.net} SMTP_PORT: ${SMTP_PORT:-465} - SMTP_USER: ${SMTP_USER:-timo@qrmaster.net} + SMTP_USER: ${SMTP_USER:-info@qrmaster.net} SMTP_PASS: ${SMTP_PASS:-} NEWSLETTER_ADMIN_EMAIL: ${NEWSLETTER_ADMIN_EMAIL:-} NEWSLETTER_ADMIN_PASSWORD: ${NEWSLETTER_ADMIN_PASSWORD:-} From 9d1d3a206269f72797657f987b0bfb4119397194 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Thu, 13 Aug 2026 18:30:34 +0200 Subject: [PATCH 06/19] SMTP_USER --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 35d5771..90a138d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,8 @@ ARG NEXT_PUBLIC_UMAMI_SRC="" ARG NEXT_PUBLIC_UMAMI_ID="" ENV NEXT_PUBLIC_UMAMI_SRC=$NEXT_PUBLIC_UMAMI_SRC ENV NEXT_PUBLIC_UMAMI_ID=$NEXT_PUBLIC_UMAMI_ID +ARG SMTP_USER="" +ENV SMTP_USER=$SMTP_USER # Shared session cookie across www.* and app.*. Needed at build time too: process.env is # inlined into the Edge middleware bundle, so a runtime-only value would leave the # middleware and the route handlers disagreeing about the cookie scope. From 6081b9e6aec37c25dd2a6e94f78f3a2fa7b5ccd4 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Thu, 13 Aug 2026 19:33:05 +0200 Subject: [PATCH 07/19] refactor: derive email sender address dynamically from SMTP_USER --- src/lib/email.ts | 58 ++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/src/lib/email.ts b/src/lib/email.ts index 2a254e9..102991f 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -48,6 +48,20 @@ async function waitForRateLimit() { lastEmailSent = Date.now(); } +function getEmailFrom(name = 'Timo from QR Master'): string { + const address = process.env.SMTP_USER || 'timo@qrmaster.net'; + return `${name} <${address}>`; +} + +function getEmailFromSecurity(): string { + const address = process.env.SMTP_USER || 'noreply@qrmaster.net'; + return `QR Master Security <${address}>`; +} + +function getEmailReplyTo(): string { + return process.env.SMTP_USER || 'support@qrmaster.net'; +} + /** * Password Reset Email - Security focused with clear urgency */ @@ -58,8 +72,8 @@ export async function sendPasswordResetEmail(email: string, resetToken: string) try { await resend.emails.send({ - from: 'QR Master Security ', - replyTo: 'support@qrmaster.net', + from: getEmailFromSecurity(), + replyTo: getEmailReplyTo(), to: email, subject: '🔐 Reset Your QR Master Password (Expires in 1 Hour)', html: ` @@ -190,8 +204,8 @@ export async function sendNewsletterWelcomeEmail(email: string) { try { await resend.emails.send({ - from: 'Timo from QR Master ', - replyTo: 'support@qrmaster.net', + from: getEmailFrom(), + replyTo: getEmailReplyTo(), to: email, subject: '🎉 You\'re In! Here\'s What Happens Next (AI QR Features)', html: ` @@ -362,8 +376,8 @@ export async function sendAIFeatureLaunchEmail(email: string) { try { await resend.emails.send({ - from: 'Timo from QR Master ', - replyTo: 'support@qrmaster.net', + from: getEmailFrom(), + replyTo: getEmailReplyTo(), to: email, subject: '🚀 They\'re Live! Your AI QR Features Are Ready', html: ` @@ -568,8 +582,8 @@ export async function sendEmailVerificationEmail(email: string, name: string, ve const firstName = name.trim().split(/\s+/)[0] || 'there'; await transport.sendMail({ - from: 'Timo from QR Master ', - replyTo: 'support@qrmaster.net', + from: getEmailFrom(), + replyTo: getEmailReplyTo(), to: email, subject: 'Confirm your QR Master email address', html: `
QR MASTER

Confirm your email address

Hi ${escapeHtml(firstName)},

Click the button below to finish creating your QR Master account.

CONFIRM EMAIL

This link expires in 24 hours. If you did not create an account, you can ignore this email.

`, @@ -586,8 +600,8 @@ export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUr const transport = createSmtpTransport(); await transport.sendMail({ - from: 'Timo from QR Master ', - replyTo: 'support@qrmaster.net', + from: getEmailFrom(), + replyTo: getEmailReplyTo(), to: email, subject: 'Your QR codes can now look like your brand', html: ` @@ -652,8 +666,8 @@ export async function sendNewsletterEmail({ const transport = createSmtpTransport(); await transport.sendMail({ - from: 'Timo from QR Master ', - replyTo: 'support@qrmaster.net', + from: getEmailFrom(), + replyTo: getEmailReplyTo(), to: email, subject, html: `
QR MASTER
${body}
You are receiving this email from QR Master.
Unsubscribe from product updates
`, @@ -929,8 +943,8 @@ export async function sendWelcomeEmail(email: string, name: string) { `); await transport.sendMail({ - from: 'Timo from QR Master ', - replyTo: 'support@qrmaster.net', + from: getEmailFrom(), + replyTo: getEmailReplyTo(), to: email, subject: 'Your QR Master account is ready', html, @@ -1066,8 +1080,8 @@ export async function sendActivationNudgeEmail(email: string, name: string) { `); await transport.sendMail({ - from: 'Timo from QR Master ', - replyTo: 'support@qrmaster.net', + from: getEmailFrom(), + replyTo: getEmailReplyTo(), to: email, subject: "Your 3 free codes are still sitting there", html, @@ -1237,8 +1251,8 @@ export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount `); await transport.sendMail({ - from: 'Timo from QR Master ', - replyTo: 'support@qrmaster.net', + from: getEmailFrom(), + replyTo: getEmailReplyTo(), to: email, subject: 'You just hit the free limit', html, @@ -1413,8 +1427,8 @@ export async function sendThirtyDayNudgeEmail( `); await transport.sendMail({ - from: 'Timo from QR Master ', - replyTo: 'support@qrmaster.net', + from: getEmailFrom(), + replyTo: getEmailReplyTo(), to: email, subject: `${firstName}, your codes were scanned ${scanCount} time${scanCount !== 1 ? 's' : ''} this month`, html, @@ -1528,8 +1542,8 @@ export async function sendFirstScanEmail( `); await transport.sendMail({ - from: 'Timo from QR Master ', - replyTo: 'support@qrmaster.net', + from: getEmailFrom(), + replyTo: getEmailReplyTo(), to: email, subject: 'Your QR code was just scanned for the first time', html, From e0c32542f99f88f86838589eef11024c18d34c26 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 11:47:28 +0200 Subject: [PATCH 08/19] Detect social milestones when scans arrive --- .../api/cron/social-milestones/route.ts | 44 ++----------------- src/app/(main)/r/[slug]/route.ts | 7 +++ src/lib/social-milestones-server.ts | 38 ++++++++++++++++ 3 files changed, 49 insertions(+), 40 deletions(-) create mode 100644 src/lib/social-milestones-server.ts diff --git a/src/app/(main)/api/cron/social-milestones/route.ts b/src/app/(main)/api/cron/social-milestones/route.ts index a546a9d..e553149 100644 --- a/src/app/(main)/api/cron/social-milestones/route.ts +++ b/src/app/(main)/api/cron/social-milestones/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/lib/db'; -import { getSocialMilestoneThresholds, milestoneKind } from '@/lib/social-milestones'; +import { detectSocialMilestones } from '@/lib/social-milestones-server'; +import { getSocialMilestoneThresholds } from '@/lib/social-milestones'; export const dynamic = 'force-dynamic'; @@ -9,45 +9,9 @@ function isAuthorized(request: NextRequest) { return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`; } -function excludedEmails() { - return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '') - .split(',').map(email => email.trim().toLowerCase()).filter(Boolean); -} - // 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 excluded = excludedEmails(); - const thresholds = getSocialMilestoneThresholds(); - const candidates = await db.qRScan.groupBy({ - by: ['qrId'], - where: { - isUnique: true, - qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined }, - }, - _count: { _all: true }, - }); - - const records = candidates.flatMap(({ qrId, _count }) => - thresholds - .filter(threshold => _count._all >= threshold) - .map(threshold => ({ qrId, kind: milestoneKind(threshold) })) - ); - - if (records.length) { - const qrs = await db.qRCode.findMany({ - where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } }, - select: { id: true, userId: true }, - }); - const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId])); - await db.socialMilestone.createMany({ - data: records - .filter(record => userIdByQr.has(record.qrId)) - .map(record => ({ ...record, userId: userIdByQr.get(record.qrId)! })), - skipDuplicates: true, - }); - } - - return NextResponse.json({ ok: true, detected: records.length, thresholds }); + const detected = await detectSocialMilestones(); + return NextResponse.json({ ok: true, detected, thresholds: getSocialMilestoneThresholds() }); } diff --git a/src/app/(main)/r/[slug]/route.ts b/src/app/(main)/r/[slug]/route.ts index d6ddc54..cd364ac 100644 --- a/src/app/(main)/r/[slug]/route.ts +++ b/src/app/(main)/r/[slug]/route.ts @@ -5,6 +5,7 @@ import { getWwwOrigin } from '@/lib/hosts'; import { db } from '@/lib/db'; import { hashIP } from '@/lib/hash'; import { triggerLifecycleScoring } from '@/lib/revops-server'; +import { detectSocialMilestones } from '@/lib/social-milestones-server'; export async function GET( request: NextRequest, @@ -260,6 +261,12 @@ 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({ where: { id: userId, diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts new file mode 100644 index 0000000..f3dc76e --- /dev/null +++ b/src/lib/social-milestones-server.ts @@ -0,0 +1,38 @@ +import { db } from '@/lib/db'; +import { getSocialMilestoneThresholds, milestoneKind } 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 }, + }); + 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)! })), + skipDuplicates: true, + }); + return created.count; +} From 925540f3c61c2022206a8b7c95baa8bb008cdadc Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 12:31:10 +0200 Subject: [PATCH 09/19] Improve social milestone sharing flow --- prisma/schema.prisma | 8 + scripts/social-worker/worker.py | 57 +++++-- sql/2026-08-13_social_milestones.sql | 13 ++ .../(marketing)/s/m/[token]/og/route.tsx | 15 ++ .../(main)/(marketing)/s/m/[token]/page.tsx | 38 +++++ .../api/internal/social-milestones/route.ts | 25 ++- .../api/social-milestones/[id]/route.ts | 98 +++++++++--- src/app/(main)/api/social-milestones/route.ts | 23 ++- .../dashboard/SocialMilestoneDialog.tsx | 150 ++++++++++-------- src/lib/social-milestones-server.ts | 51 +++++- src/lib/social-milestones.ts | 43 +++++ 11 files changed, 400 insertions(+), 121 deletions(-) create mode 100644 src/app/(main)/(marketing)/s/m/[token]/og/route.tsx create mode 100644 src/app/(main)/(marketing)/s/m/[token]/page.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cb6cbbe..463df9f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -175,6 +175,14 @@ model SocialMilestone { 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) diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index 7561ddc..01f61a7 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -1,4 +1,4 @@ -"""Always-on QRMaster X milestone worker. The web app never receives X keys.""" +"""Always-on QR Master X milestone worker. The web app never receives X keys.""" import json import os import tempfile @@ -23,20 +23,45 @@ def api(method, url, payload=None): return response.json() +def font(name, size): + return ImageFont.truetype(f"/usr/share/fonts/truetype/dejavu/{name}", size) + + def render_card(card): - image = Image.new("RGB", (1200, 675), "#061b31") + """Render the consented immutable scan snapshot; never invent trend data.""" + image = Image.new("RGB", (1200, 630), "#f8fafc") draw = ImageDraw.Draw(image) - fonts = Path("/usr/share/fonts/truetype/dejavu") - bold = ImageFont.truetype(str(fonts / "DejaVuSans-Bold.ttf"), 112) - regular = ImageFont.truetype(str(fonts / "DejaVuSans.ttf"), 34) - image_draw = draw - image_draw.rounded_rectangle((55, 55, 1145, 620), radius=28, outline="#304866", width=2) - image_draw.text((100, 105), "QR MASTER", font=regular, fill="#dce8f7") - image_draw.text((100, 210), f"{card['threshold']:,}", font=bold, fill="#ffffff") - scans = "eindeutige Scans" if card.get("language") == "de" else "unique scans" - image_draw.text((105, 350), scans, font=regular, fill="#b8c7da") - image_draw.line((100, 500, 1100, 500), fill="#304866", width=2) - image_draw.text((100, 535), f"{card['label']} · {card['title']}", font=regular, fill="#dce8f7") + 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 @@ -70,10 +95,12 @@ def run_once(): return try: result = post_x(milestone["text"], milestone.get("card")) - api("PATCH", base, {"id": milestone["id"], "result": "posted"}) + 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"}) + api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]}) print(f"Milestone post failed: {error}", flush=True) diff --git a/sql/2026-08-13_social_milestones.sql b/sql/2026-08-13_social_milestones.sql index 3d56422..838f377 100644 --- a/sql/2026-08-13_social_milestones.sql +++ b/sql/2026-08-13_social_milestones.sql @@ -28,3 +28,16 @@ ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "language" TEXT NOT NULL 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; diff --git a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx new file mode 100644 index 0000000..03a5db5 --- /dev/null +++ b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx @@ -0,0 +1,15 @@ +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(
QR MASTERVerified milestone
TOTAL UNIQUE SCANS{(card?.totalUniqueScans || 0).toLocaleString(german ? 'de-DE' : 'en-US')}{german ? 'eindeutige Scans' : 'unique scans'}
{card?.qrTitle || 'QR code'}{card?.trend ? `${card.trend.recentTotal} in ${card.trend.periodDays} days` : (german ? 'Erste Dynamik' : 'Early momentum')}
, { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } }); +} diff --git a/src/app/(main)/(marketing)/s/m/[token]/page.tsx b/src/app/(main)/(marketing)/s/m/[token]/page.tsx new file mode 100644 index 0000000..fe2181a --- /dev/null +++ b/src/app/(main)/(marketing)/s/m/[token]/page.tsx @@ -0,0 +1,38 @@ +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 { + 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

QR MASTER · VERIFIED MILESTONE

{(card?.totalUniqueScans || 0).toLocaleString(share.language === 'de' ? 'de-DE' : 'en-US')}

{share.language === 'de' ? 'eindeutige Scans' : 'unique scans'}

{card?.qrTitle}

; +} diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts index 52d3a57..35445c7 100644 --- a/src/app/(main)/api/internal/social-milestones/route.ts +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -20,14 +20,10 @@ 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 dayAgo = new Date(now - 24 * 60 * 60 * 1000); const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000); - const postedToday = await db.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } }); - if (postedToday > 0) return NextResponse.json({ milestone: null, reason: 'daily_limit' }); - const milestone = await db.socialMilestone.findFirst({ - where: { status: 'approved', respondedAt: { lte: approvalNotBefore } }, - orderBy: { respondedAt: 'asc' }, + 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 @@ -37,14 +33,10 @@ export async function GET(request: NextRequest) { } if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText }, dryRun: true }); - // A claimed item also occupies the daily slot. This prevents two workers - // from each claiming a different milestone before either one posts. const claimed = await db.$transaction(async (tx) => { await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)'); - const occupied = await tx.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } }); - if (occupied) return 0; const result = await tx.socialMilestone.updateMany({ - where: { id: milestone.id, status: 'approved' }, data: { status: 'processing', claimedAt: new Date() }, + where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() }, }); return result.count; }); @@ -54,11 +46,16 @@ export async function GET(request: NextRequest) { 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' } | null; + 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, status: 'processing' }, - data: { status: body.result!, postedAt: body.result === 'posted' ? new Date() : null }, + 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 }); diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts index 117db86..76236e7 100644 --- a/src/app/(main)/api/social-milestones/[id]/route.ts +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -1,11 +1,36 @@ +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 { buildMilestoneCard, buildMilestonePost, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones'; +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 }); @@ -16,33 +41,70 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke'].includes(body.action || '')) { return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); } - const milestone = await db.socialMilestone.findFirst({ - where: { id: params.id, userId }, include: { user: { select: { primaryUseCase: true } } }, - }); + 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 (milestone.status !== 'approved') return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 }); - await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'revoked', respondedAt: new Date() } }); + 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 }); } - if (!['detected', 'shown'].includes(milestone.status)) return NextResponse.json({ error: 'This milestone has already been answered' }, { status: 409 }); - const withName = body.action === 'approve_brand' && body.withName === 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 status = body.action === 'approve_brand' ? 'approved' : body.action === 'self_share' ? 'self_shared' : 'declined'; - const consentText = body.action === 'approve_brand' - ? buildMilestonePost(milestone.user.primaryUseCase, threshold, xHandle, language) - : null; + 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(); - await db.$transaction([ - db.socialMilestone.update({ where: { id: milestone.id }, data: { status, withName, consentText, language, cardData: body.action === 'approve_brand' ? buildMilestoneCard(milestone.user.primaryUseCase, threshold, language) : undefined, respondedAt: now } }), - ...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []), - ...(withName ? [db.user.update({ where: { id: userId }, data: { xHandle } })] : []), - ]); - return NextResponse.json({ ok: true, consentText }); + + 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) }); } diff --git a/src/app/(main)/api/social-milestones/route.ts b/src/app/(main)/api/social-milestones/route.ts index 40daa60..f4f4df1 100644 --- a/src/app/(main)/api/social-milestones/route.ts +++ b/src/app/(main)/api/social-milestones/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSessionUserId } from '@/lib/session'; -import { buildMilestoneCard, buildMilestonePost, milestoneThreshold, socialLocale } from '@/lib/social-milestones'; +import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones'; export const dynamic = 'force-dynamic'; @@ -33,9 +33,26 @@ export async function GET(request: NextRequest) { milestone: { id: milestone.id, qrTitle: milestone.qr.title, threshold, defaultXHandle: user.xHandle, + brandStatus: milestone.brandStatus, + brandPostUrl: milestone.brandPostUrl, + brandPostError: milestone.brandPostError, language: locale, - preview: buildMilestonePost(user.primaryUseCase, threshold, null, locale), - card: buildMilestoneCard(user.primaryUseCase, threshold, 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, + }), }, }); } diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index c640fee..d65f4a3 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -1,22 +1,22 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { QrCode, Sparkles } from 'lucide-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 Milestone = { - id: string; - qrTitle: string; - threshold: number; - defaultXHandle: string | null; - preview: string; - language: 'en' | 'de'; - card: { title: string; label: string }; -}; +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 ; +} export function SocialMilestoneDialog() { const { fetchWithCsrf } = useCsrf(); @@ -24,81 +24,95 @@ export function SocialMilestoneDialog() { const [milestone, setMilestone] = useState(null); const [withName, setWithName] = useState(false); const [xHandle, setXHandle] = useState(''); - const [saving, setSaving] = useState(false); + const [saving, setSaving] = useState<'brand' | 'self' | null>(null); + const [brand, setBrand] = useState(null); useEffect(() => { - fetch(`/api/social-milestones?locale=${locale}`) - .then(async (response) => response.ok && setMilestone((await response.json()).milestone)) - .catch(() => undefined); + 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 gerade einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg mit der unten stehenden Karte auf dem eigenen X-Account teilen?', name: 'Meinen X-Handle nennen', decline: 'Nein, danke', optOut: 'Nicht mehr anzeigen', self: 'Selbst teilen', approve: 'Auf QR Master posten', published: 'Der Beitrag wird jetzt auf dem QR Master X-Account veroeffentlicht.' } - : { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master share this success, including the card below, from our X account?', name: 'Mention my X handle', decline: 'No thanks', optOut: 'Do not show again', self: 'Share myself', approve: 'Post from QR Master', published: 'This will now be published from the QR Master X account.' }; - + ? { 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 respond = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => { + 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(true); + setSaving('self'); try { - 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'); - if (action === 'self_share') { - await navigator.clipboard?.writeText(preview); - window.open(`https://x.com/intent/post?text=${encodeURIComponent(preview)}`, '_blank', 'noopener,noreferrer'); - window.open('https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.qrmaster.net', '_blank', 'noopener,noreferrer'); - showToast('Post text copied for LinkedIn.', 'success'); - } else if (action === 'approve_brand') { - showToast(copy.published, 'success'); + 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'); } - setMilestone(null); - } catch (error) { - showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); - } finally { - setSaving(false); - } + 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; - - return !open && setMilestone(null)}> - -
- -
- {copy.heading} - {milestone.qrTitle} {copy.subtitle} -
+ const card = milestone.card; + const count = card.totalUniqueScans || milestone.threshold; + const status = brand?.brandStatus || milestone.brandStatus || 'pending'; + return !open && setMilestone(null)}> + +
{copy.heading}{milestone.qrTitle} {copy.subtitle}
+
+
+
QR MASTERVerified scan milestone
+
TOTAL UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
+ {card.trend ?
{card.trend.recentTotal} {milestone.language === 'de' ? 'eindeutige Scans in den letzten' : 'unique scans in the last'} {card.trend.periodDays} days
:
{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}
} +
{card.qrTitle}
+
+

{copy.consent}

{preview}
+ + {withName && 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" />} +
{copy.self}
+ {status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? copy.failed : copy.queued}{brand?.brandPostUrl && View}
}
-
-
-
QR MASTERVerified
-
{milestone.threshold.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
-
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
-
{milestone.card.label} · {milestone.card.title}
-
-

{copy.consent}

-
{preview}
- - {withName && setXHandle(event.target.value)} placeholder="@yourhandle" className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-2 focus:ring-violet-100" />} -
- -
- - - - -
-
+
; } diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts index f3dc76e..9ec4e70 100644 --- a/src/lib/social-milestones-server.ts +++ b/src/lib/social-milestones-server.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db'; -import { getSocialMilestoneThresholds, milestoneKind } from '@/lib/social-milestones'; +import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, milestoneKind, SocialLocale } from '@/lib/social-milestones'; function excludedEmails() { return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '') @@ -27,12 +27,57 @@ export async function detectSocialMilestones(qrId?: string) { const qrs = await db.qRCode.findMany({ where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } }, - select: { id: true, userId: true }, + select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } }, }); + const cardByQr = new Map>>(); + 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)! })), + 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(); + 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, + }); +} diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts index c2b91a2..87a0921 100644 --- a/src/lib/social-milestones.ts +++ b/src/lib/social-milestones.ts @@ -39,6 +39,18 @@ export function milestoneThreshold(kind: string): number | 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'; } @@ -63,6 +75,37 @@ export function buildMilestoneCard(primaryUseCase: string | null, threshold: num 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; From aa3b4d02ab0ba2bc5ddb780763d2bcc1f1a8ae3b Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 12:59:37 +0200 Subject: [PATCH 10/19] Render milestone charts from real scan history --- scripts/social-worker/worker.py | 96 ++++++++++++------- .../dashboard/SocialMilestoneDialog.tsx | 20 ++-- src/lib/social-milestones-server.ts | 34 ++++--- src/lib/social-milestones.ts | 10 +- 4 files changed, 100 insertions(+), 60 deletions(-) diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index 01f61a7..f13e1a2 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -1,4 +1,5 @@ """Always-on QR Master X milestone worker. The web app never receives X keys.""" +import io import json import os import tempfile @@ -24,49 +25,78 @@ def api(method, url, payload=None): def font(name, size): - return ImageFont.truetype(f"/usr/share/fonts/truetype/dejavu/{name}", size) + for candidate in (f"/usr/share/fonts/truetype/dejavu/{name}", f"C:/Windows/Fonts/{'arialbd.ttf' if 'Bold' in name else 'arial.ttf'}"): + if Path(candidate).exists(): + return ImageFont.truetype(candidate, size) + return ImageFont.load_default() + + +def logo(): + """Use the actual deployed QR Master favicon, not an invented icon.""" + try: + base = required("QRMASTER_API_BASE").rstrip("/") + response = requests.get(f"{base}/favicon.ico", timeout=10) + response.raise_for_status() + mark = Image.open(io.BytesIO(response.content)).convert("RGBA") + mark.thumbnail((56, 56)) + return mark + except Exception: + return None def render_card(card): - """Render the consented immutable scan snapshot; never invent trend data.""" - image = Image.new("RGB", (1200, 630), "#f8fafc") + """Render an immutable cumulative scan timeline from the stored snapshot.""" + image = Image.new("RGB", (1200, 630), "#f8f7f4") 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) + navy, blue, slate, mint = "#061b31", "#0256ff", "#64748b", "#059669" + regular, medium, display = font("DejaVuSans.ttf", 27), font("DejaVuSans-Bold.ttf", 27), font("DejaVuSans.ttf", 142) + mark = logo() + if mark: + image.paste(mark, (68, 58), mark) + logo_x = 140 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) + logo_x = 68 + draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy) + + total = int(card.get("totalUniqueScans") or card.get("threshold") or 0) + draw.text((66, 212), f"{total:,}", font=display, fill=navy) + draw.text((73, 374), "UNIQUE SCANS", font=medium, fill=navy) + draw.line((68, 548, 108, 548), fill=blue, width=4) + draw.text((126, 530), "QR code milestone", font=regular, fill=slate) + + trend = card.get("trend") or {} + raw_points = trend.get("points") or [] + left, top, width, height = 590, 140, 540, 330 + if raw_points: + start_ms = min(_timestamp(point.get("at")) for point in raw_points) + end_ms = max(_timestamp(point.get("at")) for point in raw_points) + span = max(1, end_ms - start_ms) + ceiling = float(trend.get("ceiling") or max(total * 1.25, 1.25)) + points = [(left + round((_timestamp(point.get("at")) - start_ms) / span * width), top + height - round(float(point.get("total", 0)) / ceiling * height)) for point in raw_points] + target_y = top + height - round(float(trend.get("target") or total) / ceiling * height) + for fraction in (0, 0.25, 0.5, 0.75, 1): + y = top + round(height * fraction) + draw.line((left, y, left + width, y), fill="#dde5ef", width=1) + draw.line((left, target_y, left + width, target_y), fill="#bfdbfe", width=2) + draw.line(points, fill=blue, width=5, joint="curve") + x, y = points[-1] + draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill="#ffffff", outline=blue, width=5) + draw.text((left - 12, target_y - 34), f"{total:,}", font=font("DejaVuSans-Bold.ttf", 20), fill=blue, anchor="ra") + draw.text((left, top + height + 27), trend.get("startLabel") or "Created", font=font("DejaVuSans.ttf", 19), fill=slate) + draw.text((left + width, top + height + 27), trend.get("endLabel") or "Reached", font=font("DejaVuSans.ttf", 19), fill=slate, anchor="ra") + draw.text((870, 530), "VERIFIED SCAN DATA", font=font("DejaVuSans-Bold.ttf", 18), fill=mint) path = Path(tempfile.mkstemp(suffix=".png")[1]) image.save(path, "PNG", optimize=True) return path +def _timestamp(value): + try: + return int(__import__("datetime").datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) + except Exception: + return 0 + + 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 diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index d65f4a3..c1886fa 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -8,14 +8,20 @@ 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 Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: 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 ; +function Trend({ trend }: { trend: NonNullable }) { + const first = new Date(trend.points[0].at).getTime(); + const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1); + const points = trend.points.map(point => { + const x = 5 + ((new Date(point.at).getTime() - first) / (last - first)) * 90; + const y = 88 - (point.total / trend.ceiling) * 72; + return `${x},${y}`; + }).join(' '); + const targetY = 88 - (trend.target / trend.ceiling) * 72; + return
{trend.startLabel}{trend.target.toLocaleString()}{trend.endLabel}
; } export function SocialMilestoneDialog() { @@ -101,9 +107,9 @@ export function SocialMilestoneDialog() {
{copy.heading}{milestone.qrTitle} {copy.subtitle}
-
QR MASTERVerified scan milestone
+
QR MASTERVerified scan milestone
TOTAL UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
- {card.trend ?
{card.trend.recentTotal} {milestone.language === 'de' ? 'eindeutige Scans in den letzten' : 'unique scans in the last'} {card.trend.periodDays} days
:
{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}
} + {card.trend ?
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans seit Erstellung' : 'Cumulative unique scans since creation'}
:
{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}
}
{card.qrTitle}

{copy.consent}

{preview}
diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts index 9ec4e70..181a26a 100644 --- a/src/lib/social-milestones-server.ts +++ b/src/lib/social-milestones-server.ts @@ -45,30 +45,28 @@ export async function detectSocialMilestones(qrId?: string) { 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(); - 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; + // Keep a representative, cumulative history in the immutable snapshot. + // It starts at QR creation and ends at the moment the milestone is detected. + const stride = Math.max(1, Math.ceil(scans.length / 24)); + const points = [{ at: qr.createdAt.toISOString(), total: 0 }]; + scans.forEach((scan, index) => { + const total = index + 1; + if (total % stride === 0 || total === scans.length) points.push({ at: scan.ts.toISOString(), total }); }); - 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; + const month = new Intl.DateTimeFormat('en', { month: 'short', year: '2-digit', timeZone: 'UTC' }); + const trend = { + points, + startLabel: month.format(qr.createdAt), + endLabel: month.format(now), + target: scans.length, + // This makes the reached total sit one grid level below the chart top. + ceiling: Math.max(1.25, scans.length * 1.25), + }; // The snapshot is made at detection time and never silently changes after consent. return buildMilestoneCardSnapshot({ diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts index 87a0921..e670788 100644 --- a/src/lib/social-milestones.ts +++ b/src/lib/social-milestones.ts @@ -48,7 +48,13 @@ export type SocialMilestoneCard = { totalUniqueScans: number; milestoneThreshold: number; reachedAt: string; - trend: { periodDays: number; series: number[]; recentTotal: number } | null; + trend: { + points: Array<{ at: string; total: number }>; + startLabel: string; + endLabel: string; + target: number; + ceiling: number; + } | null; }; export function socialLocale(value?: string | null): SocialLocale { @@ -90,7 +96,7 @@ export function buildMilestoneCardSnapshot(input: { totalUniqueScans: number; milestoneThreshold: number; reachedAt: Date; - trend: { periodDays: number; series: number[]; recentTotal: number } | null; + trend: SocialMilestoneCard['trend']; locale: SocialLocale; }): SocialMilestoneCard { return { From 8e34f97afb2760f6f8b8210b340c8a21081facc4 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 13:04:09 +0200 Subject: [PATCH 11/19] Align milestone charts and self-share flow --- scripts/social-worker/worker.py | 16 +++++++++++----- .../(main)/api/social-milestones/[id]/route.ts | 6 ++++-- .../dashboard/SocialMilestoneDialog.tsx | 12 ++++++++---- src/lib/social-milestones-server.ts | 3 ++- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index f13e1a2..cd10712 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -74,14 +74,16 @@ def render_card(card): ceiling = float(trend.get("ceiling") or max(total * 1.25, 1.25)) points = [(left + round((_timestamp(point.get("at")) - start_ms) / span * width), top + height - round(float(point.get("total", 0)) / ceiling * height)) for point in raw_points] target_y = top + height - round(float(trend.get("target") or total) / ceiling * height) - for fraction in (0, 0.25, 0.5, 0.75, 1): - y = top + round(height * fraction) - draw.line((left, y, left + width, y), fill="#dde5ef", width=1) - draw.line((left, target_y, left + width, target_y), fill="#bfdbfe", width=2) + # Exactly five levels: for 20 scans, 5 / 10 / 15 / 20 / 25. + for index in range(1, 6): + value = total * index / 4 + y = top + height - round(value / ceiling * height) + is_target = index == 4 + draw.line((left, y, left + width, y), fill="#bfdbfe" if is_target else "#dde5ef", width=2 if is_target else 1) + draw.text((left - 15, y - 12), _format_axis(value), font=font("DejaVuSans.ttf", 18), fill=blue if is_target else slate, anchor="ra") draw.line(points, fill=blue, width=5, joint="curve") x, y = points[-1] draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill="#ffffff", outline=blue, width=5) - draw.text((left - 12, target_y - 34), f"{total:,}", font=font("DejaVuSans-Bold.ttf", 20), fill=blue, anchor="ra") draw.text((left, top + height + 27), trend.get("startLabel") or "Created", font=font("DejaVuSans.ttf", 19), fill=slate) draw.text((left + width, top + height + 27), trend.get("endLabel") or "Reached", font=font("DejaVuSans.ttf", 19), fill=slate, anchor="ra") draw.text((870, 530), "VERIFIED SCAN DATA", font=font("DejaVuSans-Bold.ttf", 18), fill=mint) @@ -97,6 +99,10 @@ def _timestamp(value): return 0 +def _format_axis(value): + return f"{value:g}" if value < 1000 else f"{value:,.0f}" + + 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 diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts index 76236e7..225bc33 100644 --- a/src/app/(main)/api/social-milestones/[id]/route.ts +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'crypto'; +import { randomBytes } from 'crypto'; import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { csrfProtection } from '@/lib/csrf'; @@ -78,7 +78,9 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st const now = new Date(); if (body.action === 'self_share') { - const token = milestone.shareToken || randomUUID().replace(/-/g, ''); + // 72 random bits keep public URLs unguessable while making the share URL + // much less disruptive in an X compose window than a full UUID. + const token = milestone.shareToken || randomBytes(9).toString('base64url'); const updated = await db.socialMilestone.update({ where: { id: milestone.id }, data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language }, diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index c1886fa..fb1b755 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -21,7 +21,8 @@ function Trend({ trend }: { trend: NonNullable }) { return `${x},${y}`; }).join(' '); const targetY = 88 - (trend.target / trend.ceiling) * 72; - return
{trend.startLabel}{trend.target.toLocaleString()}{trend.endLabel}
; + const ticks = Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4); + return
{ticks.slice().reverse().map(tick => {tick.toLocaleString()})}
{ticks.map(tick => { const y = 88 - (tick / trend.ceiling) * 72; return ; })}
{trend.startLabel}{trend.endLabel}
; } export function SocialMilestoneDialog() { @@ -70,19 +71,22 @@ export function SocialMilestoneDialog() { }; const shareSelf = async (network: 'x' | 'linkedin') => { if (!milestone) return; + // Open synchronously from the user gesture. Awaiting the API first can make + // LinkedIn treat the new window as a blocked popup. + const shareWindow = window.open('', '_blank', 'noopener,noreferrer'); 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'); + if (network === 'x') shareWindow?.location.replace(`https://x.com/intent/post?text=${encodeURIComponent(text)}`); else { await navigator.clipboard?.writeText(text); - window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,noreferrer'); + shareWindow?.location.replace(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`); } 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'); } + } catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } finally { setSaving(null); } }; const approveBrand = async () => { diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts index 181a26a..fdb0e92 100644 --- a/src/lib/social-milestones-server.ts +++ b/src/lib/social-milestones-server.ts @@ -64,7 +64,8 @@ async function createCardSnapshot(qr: { id: string; title: string; createdAt: Da startLabel: month.format(qr.createdAt), endLabel: month.format(now), target: scans.length, - // This makes the reached total sit one grid level below the chart top. + // Five labelled grid lines: 1/4, 1/2, 3/4, target, then one level above. + // For 20 scans this is precisely 5, 10, 15, 20, 25. ceiling: Math.max(1.25, scans.length * 1.25), }; From 8ef5221f71ccd5bfbe9b90697b99c95c20eb6dc9 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 13:08:40 +0200 Subject: [PATCH 12/19] Show total scans in social milestones --- scripts/social-worker/worker.py | 2 ++ .../api/social-milestones/[id]/route.ts | 1 + src/app/(main)/api/social-milestones/route.ts | 26 ++++++++++++------- .../dashboard/SocialMilestoneDialog.tsx | 4 +-- src/lib/social-milestones-server.ts | 20 +++++++------- src/lib/social-milestones.ts | 3 +++ 6 files changed, 35 insertions(+), 21 deletions(-) diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index cd10712..d01b282 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -59,8 +59,10 @@ def render_card(card): draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy) total = int(card.get("totalUniqueScans") or card.get("threshold") or 0) + total_scans = int(card.get("totalScans") or total) draw.text((66, 212), f"{total:,}", font=display, fill=navy) draw.text((73, 374), "UNIQUE SCANS", font=medium, fill=navy) + draw.text((74, 410), f"{total_scans:,} total scans", font=font("DejaVuSans.ttf", 20), fill=slate) draw.line((68, 548, 108, 548), fill=blue, width=4) draw.text((126, 530), "QR code milestone", font=regular, fill=slate) diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts index 225bc33..f47b4b8 100644 --- a/src/app/(main)/api/social-milestones/[id]/route.ts +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -69,6 +69,7 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st const card = milestone.cardData || buildMilestoneCardSnapshot({ primaryUseCase: milestone.user.primaryUseCase, qrTitle: milestone.qr.title, + totalScans: threshold, totalUniqueScans: threshold, milestoneThreshold: threshold, reachedAt: milestone.detectedAt, diff --git a/src/app/(main)/api/social-milestones/route.ts b/src/app/(main)/api/social-milestones/route.ts index f4f4df1..ee86269 100644 --- a/src/app/(main)/api/social-milestones/route.ts +++ b/src/app/(main)/api/social-milestones/route.ts @@ -18,7 +18,7 @@ export async function GET(request: NextRequest) { const milestone = await db.socialMilestone.findFirst({ where: { userId, status: { in: ['detected', 'shown'] } }, orderBy: { detectedAt: 'asc' }, - include: { qr: { select: { title: true } } }, + include: { qr: { select: { id: true, title: true } } }, }); if (!milestone) return NextResponse.json({ milestone: null }); @@ -28,6 +28,20 @@ export async function GET(request: NextRequest) { const threshold = milestoneThreshold(milestone.kind); if (!threshold) return NextResponse.json({ milestone: null }); const locale = socialLocale(request.nextUrl.searchParams.get('locale')); + const allScanCount = await db.qRScan.count({ where: { qrId: milestone.qr.id } }); + const storedCard = milestone.cardData as Record | null; + const card = storedCard + ? { ...storedCard, totalScans: typeof storedCard.totalScans === 'number' ? storedCard.totalScans : allScanCount } + : buildMilestoneCardSnapshot({ + primaryUseCase: user.primaryUseCase, + qrTitle: milestone.qr.title, + totalScans: allScanCount, + totalUniqueScans: threshold, + milestoneThreshold: threshold, + reachedAt: milestone.detectedAt, + trend: null, + locale, + }); return NextResponse.json({ milestone: { @@ -44,15 +58,7 @@ export async function GET(request: NextRequest) { 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, - }), + card, }, }); } diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index fb1b755..183953c 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -8,7 +8,7 @@ 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: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null }; +type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: 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 }; @@ -112,7 +112,7 @@ export function SocialMilestoneDialog() {
QR MASTERVerified scan milestone
-
TOTAL UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
+
TOTAL UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')} {milestone.language === 'de' ? 'Scans insgesamt' : 'total scans'}
{card.trend ?
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans seit Erstellung' : 'Cumulative unique scans since creation'}
:
{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}
}
{card.qrTitle}
diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts index fdb0e92..dc59dc7 100644 --- a/src/lib/social-milestones-server.ts +++ b/src/lib/social-milestones-server.ts @@ -46,35 +46,37 @@ export async function detectSocialMilestones(qrId?: string) { async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) { const now = new Date(); const scans = await db.qRScan.findMany({ - where: { qrId: qr.id, isUnique: true }, - select: { ts: true }, + where: { qrId: qr.id }, + select: { ts: true, isUnique: true }, orderBy: { ts: 'asc' }, }); + const uniqueScans = scans.filter(scan => scan.isUnique); // Keep a representative, cumulative history in the immutable snapshot. // It starts at QR creation and ends at the moment the milestone is detected. - const stride = Math.max(1, Math.ceil(scans.length / 24)); + const stride = Math.max(1, Math.ceil(uniqueScans.length / 24)); const points = [{ at: qr.createdAt.toISOString(), total: 0 }]; - scans.forEach((scan, index) => { + uniqueScans.forEach((scan, index) => { const total = index + 1; - if (total % stride === 0 || total === scans.length) points.push({ at: scan.ts.toISOString(), total }); + if (total % stride === 0 || total === uniqueScans.length) points.push({ at: scan.ts.toISOString(), total }); }); const month = new Intl.DateTimeFormat('en', { month: 'short', year: '2-digit', timeZone: 'UTC' }); const trend = { points, startLabel: month.format(qr.createdAt), endLabel: month.format(now), - target: scans.length, + target: uniqueScans.length, // Five labelled grid lines: 1/4, 1/2, 3/4, target, then one level above. // For 20 scans this is precisely 5, 10, 15, 20, 25. - ceiling: Math.max(1.25, scans.length * 1.25), + ceiling: uniqueScans.length < 5 ? 5 : Math.max(1.25, uniqueScans.length * 1.25), }; // 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, + totalScans: scans.length, + totalUniqueScans: uniqueScans.length, + milestoneThreshold: uniqueScans.length, reachedAt: now, trend, locale: 'en' as SocialLocale, diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts index e670788..60565ad 100644 --- a/src/lib/social-milestones.ts +++ b/src/lib/social-milestones.ts @@ -45,6 +45,7 @@ export type SocialMilestoneCard = { qrTitle: string; label: string; title: string; + totalScans: number; totalUniqueScans: number; milestoneThreshold: number; reachedAt: string; @@ -93,6 +94,7 @@ export function buildMilestonePostForQr(primaryUseCase: string | null, totalUniq export function buildMilestoneCardSnapshot(input: { primaryUseCase: string | null; qrTitle: string; + totalScans: number; totalUniqueScans: number; milestoneThreshold: number; reachedAt: Date; @@ -105,6 +107,7 @@ export function buildMilestoneCardSnapshot(input: { qrTitle: input.qrTitle, label: usageLabel(input.primaryUseCase, input.locale), title: input.locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone', + totalScans: input.totalScans, totalUniqueScans: input.totalUniqueScans, milestoneThreshold: input.milestoneThreshold, reachedAt: input.reachedAt.toISOString(), From e7581e488dc64582b723076a3efb5e0c2ac582ca Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 14:03:05 +0200 Subject: [PATCH 13/19] Fix milestone sharing and test worker routing --- docker-compose.test.yml | 9 ++++ scripts/social-worker/worker.py | 19 ++++--- .../dashboard/SocialMilestoneDialog.tsx | 51 +++++++++++++------ 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 3cedde7..05b6143 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -65,6 +65,15 @@ services: - test-internal - qrmaster-network + social-worker: + container_name: qrmaster-test-social-worker + environment: + # Never resolve the ambiguous `web` alias on the shared production + # network. The test container name is unique on this Docker daemon. + QRMASTER_API_BASE: http://qrmaster-test-web:3000 + networks: !override + - test-internal + adminer: container_name: qrmaster-test-adminer ports: !reset [] diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index d01b282..e76ae1f 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -73,14 +73,15 @@ def render_card(card): start_ms = min(_timestamp(point.get("at")) for point in raw_points) end_ms = max(_timestamp(point.get("at")) for point in raw_points) span = max(1, end_ms - start_ms) - ceiling = float(trend.get("ceiling") or max(total * 1.25, 1.25)) + ceiling = 5.0 if total <= 5 else float(trend.get("ceiling") or total * 1.25) points = [(left + round((_timestamp(point.get("at")) - start_ms) / span * width), top + height - round(float(point.get("total", 0)) / ceiling * height)) for point in raw_points] target_y = top + height - round(float(trend.get("target") or total) / ceiling * height) - # Exactly five levels: for 20 scans, 5 / 10 / 15 / 20 / 25. - for index in range(1, 6): - value = total * index / 4 + # Small milestones use whole scans (1..5); larger ones keep the + # reached milestone on the fourth of five levels. + axis_values = list(range(1, 6)) if total <= 5 else [total * index / 4 for index in range(1, 6)] + for value in axis_values: y = top + height - round(value / ceiling * height) - is_target = index == 4 + is_target = value == total draw.line((left, y, left + width, y), fill="#bfdbfe" if is_target else "#dde5ef", width=2 if is_target else 1) draw.text((left - 15, y - 12), _format_axis(value), font=font("DejaVuSans.ttf", 18), fill=blue if is_target else slate, anchor="ra") draw.line(points, fill=blue, width=5, joint="curve") @@ -146,6 +147,12 @@ 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") + print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval}), flush=True) while True: - run_once() + try: + run_once() + except Exception as error: + # Stay alive and make configuration/network errors visible in the + # container logs instead of entering a silent restart loop. + print(f"Worker cycle failed: {error}", flush=True) time.sleep(interval) diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 183953c..2a7acef 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -12,17 +12,30 @@ type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: stri 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({ trend }: { trend: NonNullable }) { +function Trend({ trend, locale }: { trend: NonNullable; locale: 'en' | 'de' }) { + const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25; + const ticks = trend.target <= 5 + ? [1, 2, 3, 4, 5] + : Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4); const first = new Date(trend.points[0].at).getTime(); const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1); const points = trend.points.map(point => { - const x = 5 + ((new Date(point.at).getTime() - first) / (last - first)) * 90; - const y = 88 - (point.total / trend.ceiling) * 72; + const x = 52 + ((new Date(point.at).getTime() - first) / (last - first)) * 356; + const y = 104 - (point.total / ceiling) * 88; return `${x},${y}`; }).join(' '); - const targetY = 88 - (trend.target / trend.ceiling) * 72; - const ticks = Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4); - return
{ticks.slice().reverse().map(tick => {tick.toLocaleString()})}
{ticks.map(tick => { const y = 88 - (tick / trend.ceiling) * 72; return ; })}
{trend.startLabel}{trend.endLabel}
; + const targetY = 104 - (trend.target / ceiling) * 88; + const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); + return + {ticks.map(tick => { + const y = 104 - (tick / ceiling) * 88; + return {number.format(tick)}; + })} + + + {trend.startLabel} + {trend.endLabel} + ; } export function SocialMilestoneDialog() { @@ -73,16 +86,24 @@ export function SocialMilestoneDialog() { if (!milestone) return; // Open synchronously from the user gesture. Awaiting the API first can make // LinkedIn treat the new window as a blocked popup. - const shareWindow = window.open('', '_blank', 'noopener,noreferrer'); + const shareWindow = window.open('about:blank', '_blank'); + if (shareWindow) shareWindow.opener = null; 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') shareWindow?.location.replace(`https://x.com/intent/post?text=${encodeURIComponent(text)}`); + const targetUrl = network === 'x' + ? `https://x.com/intent/post?text=${encodeURIComponent(text)}` + : `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`; + if (network === 'x') { + if (shareWindow) shareWindow.location.href = targetUrl; + else window.location.assign(targetUrl); + } else { await navigator.clipboard?.writeText(text); - shareWindow?.location.replace(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`); + if (shareWindow) shareWindow.location.href = targetUrl; + else window.location.assign(targetUrl); } setBrand(result.milestone); showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success'); @@ -107,13 +128,13 @@ export function SocialMilestoneDialog() { const count = card.totalUniqueScans || milestone.threshold; const status = brand?.brandStatus || milestone.brandStatus || 'pending'; return !open && setMilestone(null)}> - -
{copy.heading}{milestone.qrTitle} {copy.subtitle}
-
+ +
{copy.heading}{milestone.qrTitle} {copy.subtitle}
+
QR MASTERVerified scan milestone
-
TOTAL UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')} {milestone.language === 'de' ? 'Scans insgesamt' : 'total scans'}
- {card.trend ?
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans seit Erstellung' : 'Cumulative unique scans since creation'}
:
{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}
} +
UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')} {milestone.language === 'de' ? 'Scans insgesamt' : 'total scans'}
{card.trend ? :
{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
+ {card.trend &&
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
}
{card.qrTitle}

{copy.consent}

{preview}
@@ -122,7 +143,7 @@ export function SocialMilestoneDialog() {
{copy.self}
{status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? copy.failed : copy.queued}{brand?.brandPostUrl && View}
}
-
+
; } From d8f7202bf684401688c5bedf07ad07c06e19992f Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 14:33:29 +0200 Subject: [PATCH 14/19] Fix milestone sharing previews and publisher recovery --- scripts/social-worker/Dockerfile | 1 + scripts/social-worker/worker.py | 15 +++- src/app/(main)/(app)/settings/page.tsx | 69 +++++++++++++-- .../(marketing)/s/m/[token]/og/route.tsx | 87 ++++++++++++++++++- .../(main)/(marketing)/s/m/[token]/page.tsx | 18 ++-- .../api/internal/social-milestones/route.ts | 6 ++ .../api/social-milestones/[id]/route.ts | 2 +- .../social-milestones/preferences/route.ts | 57 ++++++++++++ .../dashboard/SocialMilestoneDialog.tsx | 39 +++++---- src/lib/social-milestones.ts | 14 ++- 10 files changed, 265 insertions(+), 43 deletions(-) create mode 100644 src/app/(main)/api/social-milestones/preferences/route.ts diff --git a/scripts/social-worker/Dockerfile b/scripts/social-worker/Dockerfile index d3d3458..061217d 100644 --- a/scripts/social-worker/Dockerfile +++ b/scripts/social-worker/Dockerfile @@ -3,4 +3,5 @@ WORKDIR /worker COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY worker.py . +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 CMD python -c "import os,requests; base=os.environ['QRMASTER_API_BASE'].rstrip('/'); secret=os.environ['INTERNAL_API_SECRET']; requests.get(base + '/api/internal/social-milestones?dryRun=true', headers={'Authorization':'Bearer ' + secret}, timeout=5).raise_for_status()" CMD ["python", "worker.py"] diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index e76ae1f..fb5cdf4 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -57,14 +57,19 @@ def render_card(card): else: logo_x = 68 draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy) + badge_text = "Verified scan milestone" + badge_font = font("DejaVuSans.ttf", 19) + badge_box = draw.textbbox((0, 0), badge_text, font=badge_font) + badge_width = badge_box[2] - badge_box[0] + draw.rounded_rectangle((1120 - badge_width - 32, 61, 1132, 106), radius=7, fill="#ecfdf5") + draw.text((1116, 73), badge_text, font=badge_font, fill=mint, anchor="ra") + draw.line((68, 124, 1132, 124), fill="#e5edf5", width=2) total = int(card.get("totalUniqueScans") or card.get("threshold") or 0) total_scans = int(card.get("totalScans") or total) draw.text((66, 212), f"{total:,}", font=display, fill=navy) draw.text((73, 374), "UNIQUE SCANS", font=medium, fill=navy) draw.text((74, 410), f"{total_scans:,} total scans", font=font("DejaVuSans.ttf", 20), fill=slate) - draw.line((68, 548, 108, 548), fill=blue, width=4) - draw.text((126, 530), "QR code milestone", font=regular, fill=slate) trend = card.get("trend") or {} raw_points = trend.get("points") or [] @@ -89,7 +94,11 @@ def render_card(card): draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill="#ffffff", outline=blue, width=5) draw.text((left, top + height + 27), trend.get("startLabel") or "Created", font=font("DejaVuSans.ttf", 19), fill=slate) draw.text((left + width, top + height + 27), trend.get("endLabel") or "Reached", font=font("DejaVuSans.ttf", 19), fill=slate, anchor="ra") - draw.text((870, 530), "VERIFIED SCAN DATA", font=font("DejaVuSans-Bold.ttf", 18), fill=mint) + draw.line((68, 536, 1132, 536), fill="#e5edf5", width=2) + title = str(card.get("qrTitle") or "QR code") + if len(title) > 52: + title = title[:49].rstrip() + "..." + draw.text((68, 558), title, font=medium, fill=navy) path = Path(tempfile.mkstemp(suffix=".png")[1]) image.save(path, "PNG", optimize=True) return path diff --git a/src/app/(main)/(app)/settings/page.tsx b/src/app/(main)/(app)/settings/page.tsx index 1f52914..de68722 100644 --- a/src/app/(main)/(app)/settings/page.tsx +++ b/src/app/(main)/(app)/settings/page.tsx @@ -14,7 +14,10 @@ export default function SettingsPage() { const { fetchWithCsrf } = useCsrf(); const [activeTab, setActiveTab] = useState('profile'); const [loading, setLoading] = useState(false); - const [showPasswordModal, setShowPasswordModal] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [socialPromptsEnabled, setSocialPromptsEnabled] = useState(true); + const [socialTestResetAvailable, setSocialTestResetAvailable] = useState(false); + const [socialSaving, setSocialSaving] = useState(false); // Profile states const [name, setName] = useState(''); @@ -49,10 +52,17 @@ export default function SettingsPage() { // Fetch usage stats from API const statsResponse = await fetch('/api/user/stats'); - if (statsResponse.ok) { - const data = await statsResponse.json(); - setUsageStats(data); - } + if (statsResponse.ok) { + const data = await statsResponse.json(); + setUsageStats(data); + } + + const socialResponse = await fetch('/api/social-milestones/preferences'); + if (socialResponse.ok) { + const data = await socialResponse.json(); + setSocialPromptsEnabled(data.promptsEnabled !== false); + setSocialTestResetAvailable(data.testResetAvailable === true); + } } catch (e) { console.error('Failed to load user data:', e); } @@ -92,7 +102,25 @@ export default function SettingsPage() { } finally { setLoading(false); } - }; + }; + + const updateSocialPrompts = async (action: 'enable' | 'disable' | 'reset_test') => { + setSocialSaving(true); + try { + const response = await fetchWithCsrf('/api/social-milestones/preferences', { + method: 'PATCH', + body: JSON.stringify({ action }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Could not update milestone prompts'); + setSocialPromptsEnabled(data.promptsEnabled !== false); + showToast(action === 'reset_test' ? 'Milestone test reset. Open the dashboard to test it again.' : 'Milestone preference updated.', 'success'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not update milestone prompts', 'error'); + } finally { + setSocialSaving(false); + } + }; const handleManageSubscription = async () => { setLoading(true); @@ -245,9 +273,32 @@ export default function SettingsPage() {

- - - {/* Security */} + + + + + Milestone sharing + + +
+
+

Show scan milestone prompts

+

Choose whether QR Master may ask you to share verified scan achievements. Nothing is published without your confirmation.

+
+ +
+ {socialTestResetAvailable &&
+
+

Test environment: reopen the latest milestone and clear its publishing state.

+ +
+
} +
+
+ + {/* Security */} Security diff --git a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx index 03a5db5..74d01dc 100644 --- a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx +++ b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx @@ -3,13 +3,96 @@ import { db } from '@/lib/db'; export const runtime = 'nodejs'; +type Trend = { + points: Array<{ at: string; total: number }>; + startLabel: string; + endLabel: string; + target: number; +}; + +type Card = { + qrTitle?: string; + totalScans?: number; + totalUniqueScans?: number; + milestoneThreshold?: number; + trend?: Trend | null; +}; + +function chart(card: Card, german: boolean) { + const target = Math.max(1, Number(card.totalUniqueScans || card.milestoneThreshold || 1)); + const trend = card.trend; + const rawPoints = Array.isArray(trend?.points) ? trend.points : []; + if (!trend || rawPoints.length === 0) return null; + + const ceiling = target <= 5 ? 5 : target * 1.25; + const ticks = target <= 5 + ? [1, 2, 3, 4, 5] + : Array.from({ length: 5 }, (_, index) => target * (index + 1) / 4); + const timestamps = rawPoints.map(point => new Date(point.at).getTime()).filter(Number.isFinite); + const first = timestamps.length ? Math.min(...timestamps) : 0; + const last = Math.max(timestamps.length ? Math.max(...timestamps) : first, first + 1); + const plotLeft = 72; + const plotRight = 540; + const plotTop = 20; + const plotBottom = 236; + const points = rawPoints.map(point => { + const time = new Date(point.at).getTime(); + const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft); + const y = plotBottom - Math.min(Math.max(Number(point.total) || 0, 0), ceiling) / ceiling * (plotBottom - plotTop); + return `${x},${y}`; + }).join(' '); + const number = new Intl.NumberFormat(german ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); + + return
+ + {ticks.map(tick => { + const y = plotBottom - tick / ceiling * (plotBottom - plotTop); + const reached = tick === target; + return + {number.format(tick)} + + ; + })} + + {points && } + {trend.startLabel || (german ? 'Erstellt' : 'Created')} + {trend.endLabel || (german ? 'Erreicht' : 'Reached')} + +
{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
+
; +} + 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 card = (share.cardData || {}) as Card; const german = share.language === 'de'; - return new ImageResponse(
QR MASTERVerified milestone
TOTAL UNIQUE SCANS{(card?.totalUniqueScans || 0).toLocaleString(german ? 'de-DE' : 'en-US')}{german ? 'eindeutige Scans' : 'unique scans'}
{card?.qrTitle || 'QR code'}{card?.trend ? `${card.trend.recentTotal} in ${card.trend.periodDays} days` : (german ? 'Erste Dynamik' : 'Early momentum')}
, { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } }); + const unique = Math.max(0, Number(card.totalUniqueScans || card.milestoneThreshold || 0)); + const total = Math.max(unique, Number(card.totalScans || unique)); + const locale = german ? 'de-DE' : 'en-US'; + + return new ImageResponse( +
+
+
+ QR MASTER + ✓ {german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'} +
+
+
+ UNIQUE SCANS + {unique.toLocaleString(locale)} + {total.toLocaleString(locale)} {german ? 'Scans insgesamt' : 'total scans'} +
+ {chart(card, german)} +
+
{card.qrTitle || (german ? 'QR-Code' : 'QR code')}
+
+
, + { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } }, + ); } diff --git a/src/app/(main)/(marketing)/s/m/[token]/page.tsx b/src/app/(main)/(marketing)/s/m/[token]/page.tsx index fe2181a..33bca42 100644 --- a/src/app/(main)/(marketing)/s/m/[token]/page.tsx +++ b/src/app/(main)/(marketing)/s/m/[token]/page.tsx @@ -8,7 +8,7 @@ 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 }, + select: { cardData: true, language: true, publicShareApprovedAt: true }, }); } @@ -20,13 +20,17 @@ export async function generateMetadata({ params }: Props): Promise { const title = share.language === 'de' ? `${count.toLocaleString('de-DE')} eindeutige QR-Scans erreicht` : `${count.toLocaleString('en-US')} unique QR scans reached`; + const description = share.language === 'de' + ? `${card?.qrTitle || 'Ein QR-Code'} hat einen verifizierten Scan-Meilenstein mit QR Master erreicht.` + : `${card?.qrTitle || 'A QR code'} reached a verified scan milestone with QR Master.`; const url = `${getWwwOrigin()}/s/m/${params.token}`; + const imageUrl = `${url}/og?v=${share.publicShareApprovedAt?.getTime() || 0}`; return { title, - description: card?.qrTitle || 'A verified QR Master scan milestone.', + description, 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`] }, + openGraph: { type: 'website', title, description, url, images: [{ url: imageUrl, width: 1200, height: 630, alt: title }] }, + twitter: { card: 'summary_large_image', title, description, images: [imageUrl] }, }; } @@ -34,5 +38,9 @@ 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

QR MASTER · VERIFIED MILESTONE

{(card?.totalUniqueScans || 0).toLocaleString(share.language === 'de' ? 'de-DE' : 'en-US')}

{share.language === 'de' ? 'eindeutige Scans' : 'unique scans'}

{card?.qrTitle}

; + const imageUrl = `${getWwwOrigin()}/s/m/${params.token}/og?v=${share.publicShareApprovedAt?.getTime() || 0}`; + const alt = share.language === 'de' + ? `${card?.qrTitle || 'QR-Code'}: ${(card?.totalUniqueScans || 0).toLocaleString('de-DE')} eindeutige Scans` + : `${card?.qrTitle || 'QR code'}: ${(card?.totalUniqueScans || 0).toLocaleString('en-US')} unique scans`; + return
{alt}

{share.language === 'de' ? 'Verifizierter Scan-Meilenstein von QR Master' : 'Verified scan milestone from QR Master'}

; } diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts index 35445c7..a7e6335 100644 --- a/src/app/(main)/api/internal/social-milestones/route.ts +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -20,6 +20,12 @@ 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(); + // A worker can be interrupted after claiming a row. Surface that state as a + // retryable failure instead of leaving the dashboard in "processing" forever. + await db.socialMilestone.updateMany({ + where: { brandStatus: 'processing', claimedAt: { lt: new Date(now - 5 * 60 * 1000) } }, + data: { brandStatus: 'failed', brandPostError: 'The publisher was interrupted before it confirmed the post. Please retry.' }, + }); const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000); const milestone = await db.socialMilestone.findFirst({ where: { brandStatus: 'approved', brandApprovedAt: { lte: approvalNotBefore } }, diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts index f47b4b8..171c579 100644 --- a/src/app/(main)/api/social-milestones/[id]/route.ts +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -86,7 +86,7 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st where: { id: milestone.id }, data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language }, }); - return NextResponse.json({ ok: true, shareToken: token, milestone: clientState(updated) }); + return NextResponse.json({ ok: true, shareToken: token, shareVersion: now.getTime(), milestone: clientState(updated) }); } if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) { diff --git a/src/app/(main)/api/social-milestones/preferences/route.ts b/src/app/(main)/api/social-milestones/preferences/route.ts new file mode 100644 index 0000000..a6744c2 --- /dev/null +++ b/src/app/(main)/api/social-milestones/preferences/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { csrfProtection } from '@/lib/csrf'; +import { db } from '@/lib/db'; +import { getSessionUserId } from '@/lib/session'; +import { getSocialMilestoneThresholds } from '@/lib/social-milestones'; + +export const dynamic = 'force-dynamic'; + +function testResetAvailable() { + return getSocialMilestoneThresholds().some(threshold => threshold < 100); +} + +export async function GET() { + const userId = getSessionUserId(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await db.user.findUnique({ where: { id: userId }, select: { socialPromptOptOut: true } }); + if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json({ promptsEnabled: !user.socialPromptOptOut, testResetAvailable: testResetAvailable() }); +} + +export async function PATCH(request: NextRequest) { + 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?: 'enable' | 'disable' | 'reset_test' } | null; + if (!body?.action || !['enable', 'disable', 'reset_test'].includes(body.action)) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } + + if (body.action === 'reset_test') { + if (!testResetAvailable()) return NextResponse.json({ error: 'Test reset is not available in this environment' }, { status: 403 }); + const latest = await db.socialMilestone.findFirst({ + where: { userId }, + orderBy: { detectedAt: 'desc' }, + select: { id: true }, + }); + await db.$transaction([ + db.user.update({ where: { id: userId }, data: { socialPromptOptOut: false } }), + ...(latest ? [db.socialMilestone.update({ + where: { id: latest.id }, + data: { + status: 'detected', shownAt: null, respondedAt: null, + brandStatus: 'pending', brandApprovedAt: null, claimedAt: null, + brandPostedAt: null, brandPostUrl: null, brandPostError: null, + consentText: null, withName: false, + selfSharedAt: null, publicShareApprovedAt: null, shareToken: null, + }, + })] : []), + ]); + return NextResponse.json({ ok: true, promptsEnabled: true, resetMilestone: Boolean(latest) }); + } + + const promptsEnabled = body.action === 'enable'; + await db.user.update({ where: { id: userId }, data: { socialPromptOptOut: !promptsEnabled } }); + return NextResponse.json({ ok: true, promptsEnabled }); +} diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 2a7acef..8622a88 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -70,10 +70,11 @@ export function SocialMilestoneDialog() { 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' }; + : { 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: 'Waiting for the X publisher …', 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(/^@/, '')}.`; + const mention = milestone.language === 'de' ? 'Glückwunsch' : 'Congratulations'; + return `${milestone.preview}\n\n${mention} @${xHandle.trim().replace(/^@/, '')}.`; }, [milestone, withName, xHandle]); const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => { if (!milestone) return null; @@ -91,22 +92,15 @@ export function SocialMilestoneDialog() { setSaving('self'); try { const result = await update('self_share'); - const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`; - const text = `${preview} ${shareUrl}`; + const shareUrl = `${window.location.origin}/s/m/${result.shareToken}?v=${result.shareVersion}`; + const text = `${preview}\n\n${shareUrl}`; const targetUrl = network === 'x' ? `https://x.com/intent/post?text=${encodeURIComponent(text)}` : `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`; - if (network === 'x') { - if (shareWindow) shareWindow.location.href = targetUrl; - else window.location.assign(targetUrl); - } - else { - await navigator.clipboard?.writeText(text); - if (shareWindow) shareWindow.location.href = targetUrl; - else window.location.assign(targetUrl); - } + if (shareWindow) shareWindow.location.href = targetUrl; + else window.location.assign(targetUrl); setBrand(result.milestone); - showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success'); + showToast(network === 'linkedin' ? 'LinkedIn share window opened.' : 'X share composer opened.', 'success'); } catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } finally { setSaving(null); } }; @@ -122,11 +116,18 @@ export function SocialMilestoneDialog() { 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'); } }; + const optOut = () => { + const message = milestone?.language === 'de' + ? 'Meilenstein-Hinweise dauerhaft ausblenden? Du kannst sie später in den Einstellungen wieder aktivieren.' + : 'Turn off milestone prompts? You can enable them again later in Settings.'; + if (window.confirm(message)) void dismiss('opt_out'); + }; if (!milestone) return null; const card = milestone.card; const count = card.totalUniqueScans || milestone.threshold; const status = brand?.brandStatus || milestone.brandStatus || 'pending'; + const canApprove = ['pending', 'failed', 'revoked'].includes(status); return !open && setMilestone(null)}>
{copy.heading}{milestone.qrTitle} {copy.subtitle}
@@ -137,13 +138,13 @@ export function SocialMilestoneDialog() { {card.trend &&
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
}
{card.qrTitle}
-

{copy.consent}

{preview}
- - {withName && 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" />} +

{copy.consent}

{preview}
+ + {withName && setXHandle(event.target.value)} disabled={!canApprove} 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" />}
{copy.self}
- {status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? copy.failed : copy.queued}{brand?.brandPostUrl && View}
} + {status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}{brand?.brandPostUrl && View}
}
-
+
; } diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts index 60565ad..0e2616d 100644 --- a/src/lib/social-milestones.ts +++ b/src/lib/social-milestones.ts @@ -84,11 +84,17 @@ export function buildMilestoneCard(primaryUseCase: string | null, threshold: num 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 rawSubject = qrTitle.trim() || usageLabel(primaryUseCase, locale); + const subject = rawSubject.length > 72 ? `${rawSubject.slice(0, 69).trimEnd()}…` : rawSubject; + const scanLabel = locale === 'de' + ? `${count} ${totalUniqueScans === 1 ? 'verifizierten eindeutigen Scan' : 'verifizierte eindeutige Scans'}` + : `${count} verified unique ${totalUniqueScans === 1 ? 'scan' : 'scans'}`; const base = locale === 'de' - ? `„${subject}“ hat ${count} eindeutige Scans erreicht.` - : `“${subject}” reached ${count} unique scans.`; - return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base; + ? `QR-Meilenstein erreicht.\n\n„${subject}“ hat ${scanLabel} erzielt.\n\nErstellt und gemessen mit QR Master.` + : `QR milestone unlocked.\n\n“${subject}” has reached ${scanLabel}.\n\nCreated and measured with QR Master.`; + if (!xHandle) return base; + const mention = locale === 'de' ? 'Glückwunsch' : 'Congratulations'; + return `${base}\n\n${mention} @${xHandle.replace(/^@/, '')}.`; } export function buildMilestoneCardSnapshot(input: { From 4ec70ed30f90ad3128c78d8bedf95cc4b3f5a632 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 18:19:55 +0200 Subject: [PATCH 15/19] Harden milestone sharing and X publishing --- scripts/social-worker/worker.py | 37 ++++++++++++-- .../(main)/(marketing)/s/m/[token]/page.tsx | 4 +- .../api/internal/social-milestones/route.ts | 13 ++++- .../api/social-milestones/[id]/route.ts | 36 +++++++------ src/app/(main)/api/social-milestones/route.ts | 48 ++++++++++------- .../dashboard/SocialMilestoneDialog.tsx | 51 ++++++++++++++----- src/lib/social-milestones-server.ts | 49 ++++++++++++++---- src/lib/social-milestones.ts | 16 ++++++ 8 files changed, 191 insertions(+), 63 deletions(-) diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index fb5cdf4..1845605 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -115,8 +115,35 @@ def _format_axis(value): return f"{value:g}" if value < 1000 else f"{value:,.0f}" -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")) +def oauth_client(): + return 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")) + + +def find_existing_post(oauth, milestone): + """Reconcile an uncertain prior attempt before creating another X post.""" + token = str(milestone.get("shareToken") or "").strip() + if not token: + raise RuntimeError("Milestone has no share token for duplicate-safe publishing") + identity = oauth.get("https://api.x.com/2/users/me", timeout=30) + identity.raise_for_status() + user_id = identity.json().get("data", {}).get("id") + if not user_id: + raise RuntimeError("X did not return the authenticated user id") + timeline = oauth.get( + f"https://api.x.com/2/users/{user_id}/tweets", + params={"max_results": 100, "tweet.fields": "created_at,entities", "exclude": "retweets,replies"}, + timeout=30, + ) + timeline.raise_for_status() + for post in timeline.json().get("data") or []: + urls = (post.get("entities") or {}).get("urls") or [] + expanded = " ".join(str(url.get("expanded_url") or url.get("unwound_url") or "") for url in urls) + if token in expanded: + return post + return None + + +def post_x(text, card, oauth): path = render_card(card) if card else None try: media_id = None @@ -142,11 +169,13 @@ def run_once(): if not milestone: return try: - result = post_x(milestone["text"], milestone.get("card")) + oauth = oauth_client() + existing = find_existing_post(oauth, milestone) + result = {"data": existing, "reconciled": True} if existing else post_x(milestone["text"], milestone.get("card"), oauth) 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) + print(json.dumps({"posted": milestone["id"], "reconciled": bool(existing), "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) diff --git a/src/app/(main)/(marketing)/s/m/[token]/page.tsx b/src/app/(main)/(marketing)/s/m/[token]/page.tsx index 33bca42..044821a 100644 --- a/src/app/(main)/(marketing)/s/m/[token]/page.tsx +++ b/src/app/(main)/(marketing)/s/m/[token]/page.tsx @@ -18,8 +18,8 @@ export async function generateMetadata({ params }: Props): Promise { 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`; + ? `${count.toLocaleString('de-DE')} ${count === 1 ? 'eindeutiger QR-Scan' : 'eindeutige QR-Scans'} erreicht` + : `${count.toLocaleString('en-US')} unique QR ${count === 1 ? 'scan' : 'scans'} reached`; const description = share.language === 'de' ? `${card?.qrTitle || 'Ein QR-Code'} hat einen verifizierten Scan-Meilenstein mit QR Master erreicht.` : `${card?.qrTitle || 'A QR code'} reached a verified scan milestone with QR Master.`; diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts index a7e6335..3994644 100644 --- a/src/app/(main)/api/internal/social-milestones/route.ts +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { getWwwOrigin } from '@/lib/hosts'; export const dynamic = 'force-dynamic'; @@ -37,7 +38,8 @@ export async function GET(request: NextRequest) { 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 shareUrl = milestone.shareToken ? `${getWwwOrigin()}/s/m/${milestone.shareToken}` : null; + if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, shareUrl }, dryRun: true }); const claimed = await db.$transaction(async (tx) => { await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)'); @@ -47,7 +49,14 @@ export async function GET(request: NextRequest) { 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 } }); + return NextResponse.json({ milestone: { + id: milestone.id, + text: milestone.consentText, + card: milestone.cardData, + shareToken: milestone.shareToken, + shareUrl, + approvedAt: milestone.brandApprovedAt?.toISOString() || null, + } }); } export async function PATCH(request: NextRequest) { diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts index 171c579..65c26ad 100644 --- a/src/app/(main)/api/social-milestones/[id]/route.ts +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -2,15 +2,17 @@ import { randomBytes } from 'crypto'; import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { csrfProtection } from '@/lib/csrf'; +import { getWwwOrigin } from '@/lib/hosts'; import { getSessionUserId } from '@/lib/session'; -import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones'; +import { buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones'; +import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server'; 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 } } }, + include: { user: { select: { primaryUseCase: true } }, qr: { select: { id: true, title: true, createdAt: true } } }, }); } @@ -63,49 +65,51 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st } const language = socialLocale(body.language); - const withName = body.action === 'approve_brand' && body.withName === true; + const withName = 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({ + const card = await ensureSocialMilestoneCard({ + milestoneId: milestone.id, + cardData: milestone.cardData, + kind: milestone.kind, + detectedAt: milestone.detectedAt, + language, + qr: milestone.qr, primaryUseCase: milestone.user.primaryUseCase, - qrTitle: milestone.qr.title, - totalScans: threshold, - totalUniqueScans: threshold, - milestoneThreshold: threshold, - reachedAt: milestone.detectedAt, - trend: null, - locale: language, }); const now = new Date(); + const token = milestone.shareToken || randomBytes(9).toString('base64url'); + const shareUrl = `${getWwwOrigin()}/s/m/${token}`; if (body.action === 'self_share') { // 72 random bits keep public URLs unguessable while making the share URL // much less disruptive in an X compose window than a full UUID. - const token = milestone.shareToken || randomBytes(9).toString('base64url'); 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, shareVersion: now.getTime(), milestone: clientState(updated) }); + return NextResponse.json({ ok: true, shareToken: token, shareUrl, shareVersion: now.getTime(), 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( + const postText = buildMilestonePostForQr( milestone.user.primaryUseCase, (card as { totalUniqueScans?: number }).totalUniqueScans || threshold, xHandle, language, milestone.qr.title, ); + const consentText = `${postText}\n\n${shareUrl}`; 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, + brandStatus: 'approved', brandApprovedAt: milestone.brandApprovedAt || now, brandPostError: null, + status: 'approved', withName, consentText, language, cardData: card, respondedAt: now, + shareToken: token, publicShareApprovedAt: now, }, }); }); diff --git a/src/app/(main)/api/social-milestones/route.ts b/src/app/(main)/api/social-milestones/route.ts index ee86269..5e6c40b 100644 --- a/src/app/(main)/api/social-milestones/route.ts +++ b/src/app/(main)/api/social-milestones/route.ts @@ -1,7 +1,10 @@ +import { randomBytes } from 'crypto'; import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { getWwwOrigin } from '@/lib/hosts'; import { getSessionUserId } from '@/lib/session'; -import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones'; +import { buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones'; +import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server'; export const dynamic = 'force-dynamic'; @@ -16,9 +19,15 @@ export async function GET(request: NextRequest) { if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null }); const milestone = await db.socialMilestone.findFirst({ - where: { userId, status: { in: ['detected', 'shown'] } }, + where: { + userId, + OR: [ + { status: { in: ['detected', 'shown'] } }, + { status: 'approved', brandStatus: { in: ['approved', 'processing', 'failed'] } }, + ], + }, orderBy: { detectedAt: 'asc' }, - include: { qr: { select: { id: true, title: true } } }, + include: { qr: { select: { id: true, title: true, createdAt: true } } }, }); if (!milestone) return NextResponse.json({ milestone: null }); @@ -28,20 +37,22 @@ export async function GET(request: NextRequest) { const threshold = milestoneThreshold(milestone.kind); if (!threshold) return NextResponse.json({ milestone: null }); const locale = socialLocale(request.nextUrl.searchParams.get('locale')); - const allScanCount = await db.qRScan.count({ where: { qrId: milestone.qr.id } }); - const storedCard = milestone.cardData as Record | null; - const card = storedCard - ? { ...storedCard, totalScans: typeof storedCard.totalScans === 'number' ? storedCard.totalScans : allScanCount } - : buildMilestoneCardSnapshot({ - primaryUseCase: user.primaryUseCase, - qrTitle: milestone.qr.title, - totalScans: allScanCount, - totalUniqueScans: threshold, - milestoneThreshold: threshold, - reachedAt: milestone.detectedAt, - trend: null, - locale, - }); + const shareToken = milestone.shareToken || randomBytes(9).toString('base64url'); + if (!milestone.shareToken) { + await db.socialMilestone.update({ where: { id: milestone.id }, data: { shareToken } }); + } + const shareUrl = `${getWwwOrigin()}/s/m/${shareToken}`; + const card = await ensureSocialMilestoneCard({ + milestoneId: milestone.id, + cardData: milestone.cardData, + kind: milestone.kind, + detectedAt: milestone.detectedAt, + language: locale, + qr: milestone.qr, + primaryUseCase: user.primaryUseCase, + refresh: ['detected', 'shown'].includes(milestone.status), + snapshotAt: new Date(), + }); return NextResponse.json({ milestone: { @@ -51,9 +62,10 @@ export async function GET(request: NextRequest) { brandPostUrl: milestone.brandPostUrl, brandPostError: milestone.brandPostError, language: locale, + shareUrl, preview: buildMilestonePostForQr( user.primaryUseCase, - ((milestone.cardData as { totalUniqueScans?: number } | null)?.totalUniqueScans || threshold), + card.totalUniqueScans || threshold, null, locale, milestone.qr.title, diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 8622a88..67d282b 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { Check, ExternalLink, LineChart, Linkedin, Send, Sparkles, X } from 'lucide-react'; +import { Check, Copy, 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'; @@ -9,7 +9,7 @@ import { useTranslation } from '@/hooks/useTranslation'; import { showToast } from '@/components/ui/Toast'; type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: 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 Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; shareUrl: 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({ trend, locale }: { trend: NonNullable; locale: 'en' | 'de' }) { @@ -44,7 +44,7 @@ export function SocialMilestoneDialog() { const [milestone, setMilestone] = useState(null); const [withName, setWithName] = useState(false); const [xHandle, setXHandle] = useState(''); - const [saving, setSaving] = useState<'brand' | 'self' | null>(null); + const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | null>(null); const [brand, setBrand] = useState(null); useEffect(() => { @@ -71,11 +71,12 @@ export function SocialMilestoneDialog() { 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: 'Waiting for the X publisher …', posted: 'Published on X', failed: 'Publishing failed' }; - const preview = useMemo(() => { + const postCopy = useMemo(() => { if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || ''; const mention = milestone.language === 'de' ? 'Glückwunsch' : 'Congratulations'; return `${milestone.preview}\n\n${mention} @${xHandle.trim().replace(/^@/, '')}.`; }, [milestone, withName, xHandle]); + const preview = milestone ? `${postCopy}\n\n${milestone.shareUrl}` : ''; 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 }) }); @@ -83,6 +84,12 @@ export function SocialMilestoneDialog() { if (!response.ok) throw new Error(result.error || 'Could not save your choice'); return result; }; + const prepareSelfShare = async () => { + const result = await update('self_share'); + const shareUrl = `${result.shareUrl}?v=${result.shareVersion}`; + setBrand(result.milestone); + return { shareUrl, text: `${postCopy}\n\n${shareUrl}` }; + }; const shareSelf = async (network: 'x' | 'linkedin') => { if (!milestone) return; // Open synchronously from the user gesture. Awaiting the API first can make @@ -91,19 +98,39 @@ export function SocialMilestoneDialog() { if (shareWindow) shareWindow.opener = null; setSaving('self'); try { - const result = await update('self_share'); - const shareUrl = `${window.location.origin}/s/m/${result.shareToken}?v=${result.shareVersion}`; - const text = `${preview}\n\n${shareUrl}`; + const { shareUrl, text } = await prepareSelfShare(); const targetUrl = network === 'x' ? `https://x.com/intent/post?text=${encodeURIComponent(text)}` : `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`; if (shareWindow) shareWindow.location.href = targetUrl; else window.location.assign(targetUrl); - setBrand(result.milestone); showToast(network === 'linkedin' ? 'LinkedIn share window opened.' : 'X share composer opened.', 'success'); } catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } finally { setSaving(null); } }; + const copyLinkedInText = async () => { + setSaving('copy'); + try { + const { text } = await prepareSelfShare(); + try { + await navigator.clipboard.writeText(text); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const copied = document.execCommand('copy'); + textarea.remove(); + if (!copied) throw new Error('Copying is blocked by this browser'); + } + showToast(milestone?.language === 'de' ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not copy the LinkedIn text', 'error'); + } finally { setSaving(null); } + }; const approveBrand = async () => { setSaving('brand'); try { @@ -139,12 +166,12 @@ export function SocialMilestoneDialog() {
{card.qrTitle}

{copy.consent}

{preview}
- - {withName && setXHandle(event.target.value)} disabled={!canApprove} 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" />} -
{copy.self}
+ + {withName && setXHandle(event.target.value)} disabled={saving !== null} maxLength={16} pattern="@?[A-Za-z0-9_]{1,15}" 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" />} +
{copy.self}

{milestone.language === 'de' ? 'Für LinkedIn zuerst den Text kopieren, dann LinkedIn öffnen und einfügen.' : 'For LinkedIn, copy the post text first, then open LinkedIn and paste it.'}

{status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}{brand?.brandPostUrl && View}
}
-
+
{status === 'pending' && }
; } diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts index dc59dc7..a50b5da 100644 --- a/src/lib/social-milestones-server.ts +++ b/src/lib/social-milestones-server.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db'; -import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, milestoneKind, SocialLocale } from '@/lib/social-milestones'; +import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, isCompleteSocialMilestoneCard, milestoneKind, milestoneThreshold, SocialLocale, SocialMilestoneCard, socialLocale } from '@/lib/social-milestones'; function excludedEmails() { return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '') @@ -30,7 +30,7 @@ export async function detectSocialMilestones(qrId?: string) { select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } }, }); const cardByQr = new Map>>(); - await Promise.all(qrs.map(async qr => cardByQr.set(qr.id, await createCardSnapshot(qr)))); + await Promise.all(qrs.map(async qr => cardByQr.set(qr.id, await createCardSnapshot(qr, new Date())))); 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 => ({ @@ -43,10 +43,14 @@ export async function detectSocialMilestones(qrId?: string) { return created.count; } -async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) { - const now = new Date(); +async function createCardSnapshot( + qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }, + reachedAt: Date, + locale: SocialLocale = 'en', + configuredThreshold?: number, +) { const scans = await db.qRScan.findMany({ - where: { qrId: qr.id }, + where: { qrId: qr.id, ts: { lte: reachedAt } }, select: { ts: true, isUnique: true }, orderBy: { ts: 'asc' }, }); @@ -63,7 +67,7 @@ async function createCardSnapshot(qr: { id: string; title: string; createdAt: Da const trend = { points, startLabel: month.format(qr.createdAt), - endLabel: month.format(now), + endLabel: month.format(reachedAt), target: uniqueScans.length, // Five labelled grid lines: 1/4, 1/2, 3/4, target, then one level above. // For 20 scans this is precisely 5, 10, 15, 20, 25. @@ -76,9 +80,36 @@ async function createCardSnapshot(qr: { id: string; title: string; createdAt: Da qrTitle: qr.title, totalScans: scans.length, totalUniqueScans: uniqueScans.length, - milestoneThreshold: uniqueScans.length, - reachedAt: now, + milestoneThreshold: configuredThreshold || uniqueScans.length, + reachedAt, trend, - locale: 'en' as SocialLocale, + locale, }); } + +/** + * Old test rows may contain the v1 card or a partial v2 snapshot. Repair once, + * persist it, and return the exact same immutable payload to popup, OG and X. + */ +export async function ensureSocialMilestoneCard(input: { + milestoneId: string; + cardData: unknown; + kind: string; + detectedAt: Date; + language: string; + qr: { id: string; title: string; createdAt: Date }; + primaryUseCase: string | null; + refresh?: boolean; + snapshotAt?: Date; +}): Promise { + if (!input.refresh && isCompleteSocialMilestoneCard(input.cardData)) return input.cardData; + const threshold = milestoneThreshold(input.kind) || 1; + const card = await createCardSnapshot( + { ...input.qr, user: { primaryUseCase: input.primaryUseCase } }, + input.snapshotAt || input.detectedAt, + socialLocale(input.language), + threshold, + ); + await db.socialMilestone.update({ where: { id: input.milestoneId }, data: { cardData: card } }); + return card; +} diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts index 0e2616d..d0dcb02 100644 --- a/src/lib/social-milestones.ts +++ b/src/lib/social-milestones.ts @@ -58,6 +58,22 @@ export type SocialMilestoneCard = { } | null; }; +export function isCompleteSocialMilestoneCard(value: unknown): value is SocialMilestoneCard { + if (!value || typeof value !== 'object') return false; + const card = value as Partial; + const trend = card.trend; + return card.version === 'milestone-card-v2' + && typeof card.qrTitle === 'string' + && typeof card.totalScans === 'number' + && Number.isFinite(card.totalScans) + && typeof card.totalUniqueScans === 'number' + && Number.isFinite(card.totalUniqueScans) + && Boolean(trend) + && Array.isArray(trend?.points) + && trend.points.length >= 2 + && trend.points.every(point => typeof point?.at === 'string' && typeof point?.total === 'number'); +} + export function socialLocale(value?: string | null): SocialLocale { return value === 'de' ? 'de' : 'en'; } From 13879e3d3a5a98b9a67e620c70c9cd25bf2a580e Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 19:35:29 +0200 Subject: [PATCH 16/19] Fix milestone publisher and social previews --- .../(marketing)/s/m/[token]/og/route.tsx | 90 +----------------- .../api/internal/social-milestones/route.ts | 8 +- .../dashboard/SocialMilestoneDialog.tsx | 73 +++++++++------ src/lib/social-milestone-image.tsx | 93 +++++++++++++++++++ 4 files changed, 150 insertions(+), 114 deletions(-) create mode 100644 src/lib/social-milestone-image.tsx diff --git a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx index 74d01dc..a7d9d0a 100644 --- a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx +++ b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx @@ -1,67 +1,9 @@ -import { ImageResponse } from 'next/og'; import { db } from '@/lib/db'; +import { createSocialMilestoneImage } from '@/lib/social-milestone-image'; +import type { SocialMilestoneImageCard } from '@/lib/social-milestone-image'; export const runtime = 'nodejs'; -type Trend = { - points: Array<{ at: string; total: number }>; - startLabel: string; - endLabel: string; - target: number; -}; - -type Card = { - qrTitle?: string; - totalScans?: number; - totalUniqueScans?: number; - milestoneThreshold?: number; - trend?: Trend | null; -}; - -function chart(card: Card, german: boolean) { - const target = Math.max(1, Number(card.totalUniqueScans || card.milestoneThreshold || 1)); - const trend = card.trend; - const rawPoints = Array.isArray(trend?.points) ? trend.points : []; - if (!trend || rawPoints.length === 0) return null; - - const ceiling = target <= 5 ? 5 : target * 1.25; - const ticks = target <= 5 - ? [1, 2, 3, 4, 5] - : Array.from({ length: 5 }, (_, index) => target * (index + 1) / 4); - const timestamps = rawPoints.map(point => new Date(point.at).getTime()).filter(Number.isFinite); - const first = timestamps.length ? Math.min(...timestamps) : 0; - const last = Math.max(timestamps.length ? Math.max(...timestamps) : first, first + 1); - const plotLeft = 72; - const plotRight = 540; - const plotTop = 20; - const plotBottom = 236; - const points = rawPoints.map(point => { - const time = new Date(point.at).getTime(); - const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft); - const y = plotBottom - Math.min(Math.max(Number(point.total) || 0, 0), ceiling) / ceiling * (plotBottom - plotTop); - return `${x},${y}`; - }).join(' '); - const number = new Intl.NumberFormat(german ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); - - return
- - {ticks.map(tick => { - const y = plotBottom - tick / ceiling * (plotBottom - plotTop); - const reached = tick === target; - return - {number.format(tick)} - - ; - })} - - {points && } - {trend.startLabel || (german ? 'Erstellt' : 'Created')} - {trend.endLabel || (german ? 'Erreicht' : 'Reached')} - -
{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
-
; -} - export async function GET(_request: Request, { params }: { params: { token: string } }) { const share = await db.socialMilestone.findFirst({ where: { shareToken: params.token, publicShareApprovedAt: { not: null } }, @@ -69,30 +11,8 @@ export async function GET(_request: Request, { params }: { params: { token: stri }); if (!share) return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } }); - const card = (share.cardData || {}) as Card; - const german = share.language === 'de'; - const unique = Math.max(0, Number(card.totalUniqueScans || card.milestoneThreshold || 0)); - const total = Math.max(unique, Number(card.totalScans || unique)); - const locale = german ? 'de-DE' : 'en-US'; - - return new ImageResponse( -
-
-
- QR MASTER - ✓ {german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'} -
-
-
- UNIQUE SCANS - {unique.toLocaleString(locale)} - {total.toLocaleString(locale)} {german ? 'Scans insgesamt' : 'total scans'} -
- {chart(card, german)} -
-
{card.qrTitle || (german ? 'QR-Code' : 'QR code')}
-
-
, - { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } }, + return createSocialMilestoneImage( + (share.cardData || {}) as SocialMilestoneImageCard, + share.language === 'de', ); } diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts index 3994644..ae9c488 100644 --- a/src/app/(main)/api/internal/social-milestones/route.ts +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -42,7 +42,13 @@ export async function GET(request: NextRequest) { if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, shareUrl }, dryRun: true }); const claimed = await db.$transaction(async (tx) => { - await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)'); + // The blocking advisory-lock function returns PostgreSQL `void`, which + // Prisma cannot deserialize. The try variant returns a real boolean and + // keeps the lock scoped to this transaction. + const [lock] = await tx.$queryRaw>` + SELECT pg_try_advisory_xact_lock(920241) AS acquired + `; + if (!lock?.acquired) return 0; const result = await tx.socialMilestone.updateMany({ where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() }, }); diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 67d282b..9d48fdc 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -20,24 +20,42 @@ function Trend({ trend, locale }: { trend: NonNullable; locale: ' const first = new Date(trend.points[0].at).getTime(); const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1); const points = trend.points.map(point => { - const x = 52 + ((new Date(point.at).getTime() - first) / (last - first)) * 356; - const y = 104 - (point.total / ceiling) * 88; + const x = 60 + ((new Date(point.at).getTime() - first) / (last - first)) * 410; + const y = 142 - (point.total / ceiling) * 126; return `${x},${y}`; }).join(' '); - const targetY = 104 - (trend.target / ceiling) * 88; + const targetY = 142 - (trend.target / ceiling) * 126; const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); - return + return {ticks.map(tick => { - const y = 104 - (tick / ceiling) * 88; - return {number.format(tick)}; + const y = 142 - (tick / ceiling) * 126; + return {number.format(tick)}; })} - - - {trend.startLabel} - {trend.endLabel} + + + {trend.startLabel} + {trend.endLabel} ; } +async function copyShareText(text: string) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const copied = document.execCommand('copy'); + textarea.remove(); + if (!copied) throw new Error('Copying is blocked by this browser'); + } +} + export function SocialMilestoneDialog() { const { fetchWithCsrf } = useCsrf(); const { locale } = useTranslation(); @@ -92,6 +110,12 @@ export function SocialMilestoneDialog() { }; const shareSelf = async (network: 'x' | 'linkedin') => { if (!milestone) return; + // LinkedIn's public share dialog accepts only a URL. Start copying the + // prepared commentary while this click still owns browser focus, then + // open the LinkedIn share dialog after public-share consent is persisted. + const linkedinCopy = network === 'linkedin' + ? copyShareText(postCopy).then(() => true).catch(() => false) + : Promise.resolve(true); // Open synchronously from the user gesture. Awaiting the API first can make // LinkedIn treat the new window as a blocked popup. const shareWindow = window.open('about:blank', '_blank'); @@ -99,12 +123,17 @@ export function SocialMilestoneDialog() { setSaving('self'); try { const { shareUrl, text } = await prepareSelfShare(); + const copied = await linkedinCopy; const targetUrl = network === 'x' ? `https://x.com/intent/post?text=${encodeURIComponent(text)}` : `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`; if (shareWindow) shareWindow.location.href = targetUrl; else window.location.assign(targetUrl); - showToast(network === 'linkedin' ? 'LinkedIn share window opened.' : 'X share composer opened.', 'success'); + showToast(network === 'linkedin' + ? copied + ? (milestone.language === 'de' ? 'LinkedIn-Text kopiert. Jetzt nur noch einfügen.' : 'LinkedIn text copied. Paste it into the composer.') + : (milestone.language === 'de' ? 'LinkedIn ist geöffnet. Bitte nutze „Text kopieren“ im QR-Master-Fenster.' : 'LinkedIn is open. Please use “Copy text” in the QR Master window.') + : 'X share composer opened.', copied ? 'success' : 'error'); } catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } finally { setSaving(null); } }; @@ -112,20 +141,7 @@ export function SocialMilestoneDialog() { setSaving('copy'); try { const { text } = await prepareSelfShare(); - try { - await navigator.clipboard.writeText(text); - } catch { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - const copied = document.execCommand('copy'); - textarea.remove(); - if (!copied) throw new Error('Copying is blocked by this browser'); - } + await copyShareText(text); showToast(milestone?.language === 'de' ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success'); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not copy the LinkedIn text', 'error'); @@ -156,19 +172,20 @@ export function SocialMilestoneDialog() { const status = brand?.brandStatus || milestone.brandStatus || 'pending'; const canApprove = ['pending', 'failed', 'revoked'].includes(status); return !open && setMilestone(null)}> - +
{copy.heading}{milestone.qrTitle} {copy.subtitle}
QR MASTERVerified scan milestone
-
UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')} {milestone.language === 'de' ? 'Scans insgesamt' : 'total scans'}
{card.trend ? :
{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
+
UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
+
{card.trend ? :
{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
{card.trend &&
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
}
{card.qrTitle}

{copy.consent}

{preview}
{withName && setXHandle(event.target.value)} disabled={saving !== null} maxLength={16} pattern="@?[A-Za-z0-9_]{1,15}" 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" />} -
{copy.self}

{milestone.language === 'de' ? 'Für LinkedIn zuerst den Text kopieren, dann LinkedIn öffnen und einfügen.' : 'For LinkedIn, copy the post text first, then open LinkedIn and paste it.'}

+
{copy.self}

{milestone.language === 'de' ? 'LinkedIn erlaubt kein automatisches Text-Vorausfüllen. Der Button kopiert den fertigen Text und öffnet den Beitrag.' : 'LinkedIn does not allow text prefill. The button copies the finished text and opens the composer.'}

{status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}{brand?.brandPostUrl && View}
}
{status === 'pending' && }
diff --git a/src/lib/social-milestone-image.tsx b/src/lib/social-milestone-image.tsx new file mode 100644 index 0000000..b47a391 --- /dev/null +++ b/src/lib/social-milestone-image.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { ImageResponse } from 'next/og'; + +type Trend = { + points: Array<{ at: string; total: number }>; + startLabel: string; + endLabel: string; + target: number; +}; + +export type SocialMilestoneImageCard = { + qrTitle?: string; + totalScans?: number; + totalUniqueScans?: number; + milestoneThreshold?: number; + trend?: Trend | null; +}; + +function chart(card: SocialMilestoneImageCard, german: boolean) { + const target = Math.max(1, Number(card.totalUniqueScans || card.milestoneThreshold || 1)); + const trend = card.trend; + const rawPoints = Array.isArray(trend?.points) ? trend.points : []; + if (!trend || rawPoints.length === 0) return null; + + const ceiling = target <= 5 ? 5 : target * 1.25; + const ticks = target <= 5 + ? [1, 2, 3, 4, 5] + : Array.from({ length: 5 }, (_, index) => target * (index + 1) / 4); + const timestamps = rawPoints.map(point => new Date(point.at).getTime()).filter(Number.isFinite); + const first = timestamps.length ? Math.min(...timestamps) : 0; + const last = Math.max(timestamps.length ? Math.max(...timestamps) : first, first + 1); + const plotLeft = 72; + const plotRight = 540; + const plotTop = 18; + const plotBottom = 238; + const points = rawPoints.map(point => { + const time = new Date(point.at).getTime(); + const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft); + const y = plotBottom - Math.min(Math.max(Number(point.total) || 0, 0), ceiling) / ceiling * (plotBottom - plotTop); + return `${x},${y}`; + }).join(' '); + const number = new Intl.NumberFormat(german ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); + const endPoint = points.split(' ').at(-1)?.split(',').map(Number) || [plotRight, plotBottom]; + + // Satori cannot render SVG nodes in the deployed Node runtime. SVG + // draws geometry only; the aligned labels are ordinary positioned text. + return
+ {ticks.map(tick => { + const y = plotBottom - tick / ceiling * (plotBottom - plotTop); + const reached = tick === target; + return
+
{number.format(tick)}
+
+
; + })} + + + {points && } + +
+ {trend.startLabel || (german ? 'Erstellt' : 'Created')} + {trend.endLabel || (german ? 'Erreicht' : 'Reached')} +
+
{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
+
; +} + +export function createSocialMilestoneImage(card: SocialMilestoneImageCard, german: boolean) { + const unique = Math.max(0, Number(card.totalUniqueScans || card.milestoneThreshold || 0)); + const total = Math.max(unique, Number(card.totalScans || unique)); + const locale = german ? 'de-DE' : 'en-US'; + + return new ImageResponse( +
+
+
+ QR MASTER + {german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'} +
+
+
+ UNIQUE SCANS + {unique.toLocaleString(locale)} + {total.toLocaleString(locale)} {german ? 'Scans insgesamt' : 'total scans'} +
+ {chart(card, german)} +
+
{card.qrTitle || (german ? 'QR-Code' : 'QR code')}
+
+
, + { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } }, + ); +} From 55c04761cebe135e95428d305562aeda843cb16e Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 23:43:20 +0200 Subject: [PATCH 17/19] Polish milestone dialog and one-time delivery --- .../api/social-milestones/[id]/route.ts | 8 ++- src/app/(main)/api/social-milestones/route.ts | 24 ++++---- .../dashboard/SocialMilestoneDialog.tsx | 55 +++++++++++-------- src/components/ui/Dialog.tsx | 19 ++++--- src/lib/social-milestone-image.tsx | 34 ++++++------ 5 files changed, 77 insertions(+), 63 deletions(-) diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts index 65c26ad..0d11102 100644 --- a/src/app/(main)/api/social-milestones/[id]/route.ts +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -16,8 +16,9 @@ async function ownedMilestone(id: string, userId: string) { }); } -function clientState(milestone: { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) { +function clientState(milestone: { status: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) { return { + promptStatus: milestone.status, brandStatus: milestone.brandStatus, brandPostUrl: milestone.brandPostUrl, brandPostError: milestone.brandPostError, @@ -86,7 +87,10 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st // much less disruptive in an X compose window than a full UUID. const updated = await db.socialMilestone.update({ where: { id: milestone.id }, - data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language }, + data: { + status: 'self_shared', respondedAt: milestone.respondedAt || now, + selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language, + }, }); return NextResponse.json({ ok: true, shareToken: token, shareUrl, shareVersion: now.getTime(), milestone: clientState(updated) }); } diff --git a/src/app/(main)/api/social-milestones/route.ts b/src/app/(main)/api/social-milestones/route.ts index 5e6c40b..2102eb0 100644 --- a/src/app/(main)/api/social-milestones/route.ts +++ b/src/app/(main)/api/social-milestones/route.ts @@ -19,28 +19,18 @@ export async function GET(request: NextRequest) { if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null }); const milestone = await db.socialMilestone.findFirst({ - where: { - userId, - OR: [ - { status: { in: ['detected', 'shown'] } }, - { status: 'approved', brandStatus: { in: ['approved', 'processing', 'failed'] } }, - ], - }, + // `shown` is a durable delivery receipt: one milestone may auto-open only + // once, even across refreshes, tabs and later dashboard visits. + where: { userId, status: 'detected' }, orderBy: { detectedAt: 'asc' }, include: { qr: { select: { id: true, title: true, createdAt: 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')); const shareToken = milestone.shareToken || randomBytes(9).toString('base64url'); - if (!milestone.shareToken) { - await db.socialMilestone.update({ where: { id: milestone.id }, data: { shareToken } }); - } const shareUrl = `${getWwwOrigin()}/s/m/${shareToken}`; const card = await ensureSocialMilestoneCard({ milestoneId: milestone.id, @@ -50,15 +40,21 @@ export async function GET(request: NextRequest) { language: locale, qr: milestone.qr, primaryUseCase: user.primaryUseCase, - refresh: ['detected', 'shown'].includes(milestone.status), + refresh: true, snapshotAt: new Date(), }); + const delivered = await db.socialMilestone.updateMany({ + where: { id: milestone.id, status: 'detected' }, + data: { status: 'shown', shownAt: new Date(), shareToken }, + }); + if (!delivered.count) return NextResponse.json({ milestone: null }); return NextResponse.json({ milestone: { id: milestone.id, qrTitle: milestone.qr.title, threshold, defaultXHandle: user.xHandle, brandStatus: milestone.brandStatus, + promptStatus: 'shown', brandPostUrl: milestone.brandPostUrl, brandPostError: milestone.brandPostError, language: locale, diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 9d48fdc..071e409 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; -import { Check, Copy, ExternalLink, LineChart, Linkedin, Send, Sparkles, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Check, Copy, ExternalLink, LineChart, Linkedin, QrCode, Send, 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'; @@ -9,8 +9,8 @@ import { useTranslation } from '@/hooks/useTranslation'; import { showToast } from '@/components/ui/Toast'; type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null }; -type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; shareUrl: 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 }; +type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; shareUrl: string; language: 'en' | 'de'; card: Card; promptStatus: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null }; +type BrandState = { promptStatus: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null }; function Trend({ trend, locale }: { trend: NonNullable; locale: 'en' | 'de' }) { const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25; @@ -64,13 +64,16 @@ export function SocialMilestoneDialog() { const [xHandle, setXHandle] = useState(''); const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | null>(null); const [brand, setBrand] = useState(null); + const loadedMilestone = useRef(false); useEffect(() => { + if (loadedMilestone.current) return; + loadedMilestone.current = true; 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 }); + if (next) setBrand({ promptStatus: next.promptStatus, brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null }); } }).catch(() => undefined); }, [locale]); @@ -170,25 +173,33 @@ export function SocialMilestoneDialog() { const card = milestone.card; const count = card.totalUniqueScans || milestone.threshold; const status = brand?.brandStatus || milestone.brandStatus || 'pending'; + const promptStatus = brand?.promptStatus || milestone.promptStatus; const canApprove = ['pending', 'failed', 'revoked'].includes(status); - return !open && setMilestone(null)}> - -
{copy.heading}{milestone.qrTitle} {copy.subtitle}
-
-
-
QR MASTERVerified scan milestone
-
UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
-
{card.trend ? :
{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
- {card.trend &&
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
} -
{card.qrTitle}
-
-

{copy.consent}

{preview}
- - {withName && setXHandle(event.target.value)} disabled={saving !== null} maxLength={16} pattern="@?[A-Za-z0-9_]{1,15}" 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" />} -
{copy.self}

{milestone.language === 'de' ? 'LinkedIn erlaubt kein automatisches Text-Vorausfüllen. Der Button kopiert den fertigen Text und öffnet den Beitrag.' : 'LinkedIn does not allow text prefill. The button copies the finished text and opens the composer.'}

- {status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}{brand?.brandPostUrl && View}
} + return !open && setMilestone(null)} containerClassName="max-w-[960px]"> + +
+ +
+
{copy.heading}{milestone.qrTitle} {copy.subtitle}
+ +
-
{status === 'pending' && }
+
+
+
QR MASTERVerified scan milestone
+
UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
+
{card.trend ? :
{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
+ {card.trend &&
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
} +
{card.qrTitle}
+
+
+

{copy.consent}

{preview}
+
{withName && setXHandle(event.target.value)} disabled={saving !== null} maxLength={16} pattern="@?[A-Za-z0-9_]{1,15}" 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" />}
+
{copy.self}

{milestone.language === 'de' ? 'LinkedIn öffnet den Editor und kopiert den fertigen Text automatisch.' : 'LinkedIn opens the composer and copies the finished text automatically.'}

+ {status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}{brand?.brandPostUrl && View}
} +
+
+
{promptStatus === 'shown' && }
; } diff --git a/src/components/ui/Dialog.tsx b/src/components/ui/Dialog.tsx index a23f659..c95b772 100644 --- a/src/components/ui/Dialog.tsx +++ b/src/components/ui/Dialog.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { cn } from '@/lib/utils'; -interface DialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - children: React.ReactNode; -} - -export const Dialog: React.FC = ({ open, onOpenChange, children }) => { +interface DialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + containerClassName?: string; +} + +export const Dialog: React.FC = ({ open, onOpenChange, children, containerClassName }) => { if (!open) return null; return ( @@ -16,7 +17,7 @@ export const Dialog: React.FC = ({ open, onOpenChange, children }) className="fixed inset-0 bg-black/50" onClick={() => onOpenChange(false)} /> -
+
{children}
@@ -89,4 +90,4 @@ export const DialogFooter = React.forwardRef( /> ) ); -DialogFooter.displayName = 'DialogFooter'; \ No newline at end of file +DialogFooter.displayName = 'DialogFooter'; diff --git a/src/lib/social-milestone-image.tsx b/src/lib/social-milestone-image.tsx index b47a391..b5da71f 100644 --- a/src/lib/social-milestone-image.tsx +++ b/src/lib/social-milestone-image.tsx @@ -30,9 +30,9 @@ function chart(card: SocialMilestoneImageCard, german: boolean) { const first = timestamps.length ? Math.min(...timestamps) : 0; const last = Math.max(timestamps.length ? Math.max(...timestamps) : first, first + 1); const plotLeft = 72; - const plotRight = 540; - const plotTop = 18; - const plotBottom = 238; + const plotRight = 620; + const plotTop = 12; + const plotBottom = 218; const points = rawPoints.map(point => { const time = new Date(point.at).getTime(); const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft); @@ -44,20 +44,20 @@ function chart(card: SocialMilestoneImageCard, german: boolean) { // Satori cannot render SVG nodes in the deployed Node runtime. SVG // draws geometry only; the aligned labels are ordinary positioned text. - return
+ return
{ticks.map(tick => { const y = plotBottom - tick / ceiling * (plotBottom - plotTop); const reached = tick === target; - return
-
{number.format(tick)}
-
+ return
+
{number.format(tick)}
+
; })} - + {points && } -
+
{trend.startLabel || (german ? 'Erstellt' : 'Created')} {trend.endLabel || (german ? 'Erreicht' : 'Reached')}
@@ -69,23 +69,25 @@ export function createSocialMilestoneImage(card: SocialMilestoneImageCard, germa const unique = Math.max(0, Number(card.totalUniqueScans || card.milestoneThreshold || 0)); const total = Math.max(unique, Number(card.totalScans || unique)); const locale = german ? 'de-DE' : 'en-US'; + const uniqueText = unique.toLocaleString(locale); + const uniqueFontSize = uniqueText.length >= 9 ? 78 : uniqueText.length >= 6 ? 96 : uniqueText.length >= 4 ? 108 : 124; return new ImageResponse( -
-
-
+
+
+
QR MASTER {german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'}
-
-
+
+
UNIQUE SCANS - {unique.toLocaleString(locale)} + {uniqueText} {total.toLocaleString(locale)} {german ? 'Scans insgesamt' : 'total scans'}
{chart(card, german)}
-
{card.qrTitle || (german ? 'QR-Code' : 'QR code')}
+
{card.qrTitle || (german ? 'QR-Code' : 'QR code')}
, { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } }, From eb932ebdaacf244a1833157fcb0c22627f840e83 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Sun, 16 Aug 2026 13:46:13 +0200 Subject: [PATCH 18/19] Publish milestones per channel and add Instagram Consent is bound to the channel it was given for: approving a post on X says nothing about Instagram. Publishing state moves from the SocialMilestone row into SocialMilestonePost, one row per channel, where a missing row means no consent. The dialog asks per channel, shows the text each one will publish and keeps a separate handle for each; Instagram captions end in hashtags because a link there is not clickable. Also fixes three problems in the existing X path: - A QR code already past several thresholds produced one prompt per threshold, and since the post quotes the current scan count, every one of them would have published the same number. Only the highest threshold is announced now. - Detection ran after every unique scan and re-read the QR code's full scan history just to hit skipDuplicates. Known milestones are filtered first. - A failed post stayed failed forever because the consent dialog only opens once. The queue now retries three times on its own, spaces first attempts by SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per channel with restart and revoke. The worker no longer renders the card itself; it downloads the image the app renders at /s/m//og, which also serves the new square and portrait formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and SOCIAL_WORKER_CHANNELS both name it. Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before deploying this version. Co-Authored-By: Claude Opus 5 --- .env.example | 38 ++- .gitignore | 3 + docker-compose.yml | 67 ++-- docs/automations/social-accounts-and-jobs.md | 2 +- docs/automations/social-milestone-worker.md | 92 +++++- prisma/schema.prisma | 29 ++ scripts/social-worker/worker.py | 293 +++++++++++------- sql/2026-08-16_social_milestone_channels.sql | 42 +++ sql/2026-08-16_social_milestone_retries.sql | 15 + src/app/(main)/(app)/settings/page.tsx | 113 +++++++ .../(marketing)/s/m/[token]/og/route.tsx | 7 +- .../api/internal/social-milestones/route.ts | 146 +++++++-- .../(main)/api/social-assets/[id]/route.ts | 2 +- src/app/(main)/api/social-assets/route.ts | 4 +- .../api/social-milestones/[id]/route.ts | 131 ++++++-- .../api/social-milestones/history/route.ts | 51 +++ .../social-milestones/preferences/route.ts | 23 +- src/app/(main)/api/social-milestones/route.ts | 37 ++- .../dashboard/SocialMilestoneDialog.tsx | 200 +++++++++--- src/lib/social-milestone-image.tsx | 53 +++- src/lib/social-milestones-server.ts | 40 ++- src/lib/social-milestones.ts | 69 +++++ 22 files changed, 1159 insertions(+), 298 deletions(-) create mode 100644 sql/2026-08-16_social_milestone_channels.sql create mode 100644 sql/2026-08-16_social_milestone_retries.sql create mode 100644 src/app/(main)/api/social-milestones/history/route.ts diff --git a/.env.example b/.env.example index 728f3b1..95aab1e 100644 --- a/.env.example +++ b/.env.example @@ -16,22 +16,50 @@ REDIS_URL=redis://redis:6379 IP_SALT=CHANGE_ME_SALT ENABLE_DEMO=true -# SMTP (for welcome + retention emails via nodemailer) +# SMTP & Email Senders (for welcome + retention emails via nodemailer / resend) SMTP_HOST=smtp.qrmaster.net SMTP_PORT=465 SMTP_USER=timo@qrmaster.net SMTP_PASS= +EMAIL_FROM="Timo from QR Master " +EMAIL_FROM_SECURITY="QR Master Security " +EMAIL_REPLY_TO="support@qrmaster.net" # Cron job protection — generate with: openssl rand -base64 32 CRON_SECRET= +# Leave empty in production for 1,000 / 10,000 unique scans. Test only, e.g. 1,2. +SOCIAL_MILESTONE_THRESHOLDS= +# Leave empty for immediate publishing after consent. Set 24 to enable a revocation window. +SOCIAL_MILESTONE_POST_DELAY_HOURS= +# Hours between two brand posts (default 24). Set 0 on test to publish back to back. +SOCIAL_MILESTONE_MIN_GAP_HOURS= +SOCIAL_MILESTONE_POSTING_ENABLED=false +SOCIAL_WORKER_INTERVAL_SECONDS=10 +X_API_KEY= +X_API_SECRET= +X_ACCESS_TOKEN= +X_ACCESS_TOKEN_SECRET= + +# Channels the consent dialog offers (app) and the worker publishes (worker). +# Keep both in sync: x / x,instagram +SOCIAL_MILESTONE_CHANNELS=x +SOCIAL_WORKER_CHANNELS=x +# Instagram Business account for QRMaster.net, see docs/automations/social-accounts-and-jobs.md +INSTAGRAM_USER_ID= +INSTAGRAM_ACCESS_TOKEN= +GRAPH_API_VERSION=v22.0 +# Guards POST/DELETE on /api/social-assets, the public image host Instagram +# pulls from. Unrelated to TikTok posting; falls back to TIKTOK_ADMIN_KEY. +SOCIAL_ASSET_ADMIN_KEY= + # TikTok OAuth / posting (server-side only) # Source of truth for cron posting: QRMaster server .env # Production example: https://qrmaster.net/api/tiktok/callback # Local dev example: http://localhost:3000/api/tiktok/callback # Tokens are saved in the DB after the OAuth callback; do not store access tokens here. -TIKTOK_CLIENT_KEY= -TIKTOK_CLIENT_SECRET= +TIKTOK_CLIENT_KEY= +TIKTOK_CLIENT_SECRET= TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback -TIKTOK_ADMIN_KEY= -TIKTOK_EXPECTED_OPEN_ID= +TIKTOK_ADMIN_KEY= +TIKTOK_EXPECTED_OPEN_ID= diff --git a/.gitignore b/.gitignore index a24cd47..0d6fa9d 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ src/lib/blog-data.snapshot-*.ts /public/Real Estate/ /public/restaurant/ /.qr-master-api-health-state + +# Python worker bytecode +__pycache__/ diff --git a/docker-compose.yml b/docker-compose.yml index e57d0a1..7206a71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,7 @@ services: - qrmaster-network # Next.js Application - web: + web: build: context: . dockerfile: Dockerfile @@ -62,13 +62,20 @@ services: COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-} 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:-} + CRON_SECRET: ${CRON_SECRET:-} + SOCIAL_MILESTONE_THRESHOLDS: ${SOCIAL_MILESTONE_THRESHOLDS:-} + SOCIAL_MILESTONE_POST_DELAY_HOURS: ${SOCIAL_MILESTONE_POST_DELAY_HOURS:-} + SOCIAL_MILESTONE_MIN_GAP_HOURS: ${SOCIAL_MILESTONE_MIN_GAP_HOURS:-} + # Channels the consent dialog may ask for. Only extend this once the + # worker actually publishes that channel. + SOCIAL_MILESTONE_CHANNELS: ${SOCIAL_MILESTONE_CHANNELS:-x} TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback} TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-} + # Guards the asset upload route that Instagram (and TikTok) pull media + # from. Falls back to the TikTok key so existing setups keep working. + SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-} TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-} IP_SALT: ${IP_SALT:-your-salt-change-in-production} ENABLE_DEMO: ${ENABLE_DEMO:-false} @@ -114,27 +121,37 @@ services: interval: 10s timeout: 3s retries: 10 - networks: - - 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 + networks: + - 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:-} + # Channels this worker publishes. Must stay a subset of the app's + # SOCIAL_MILESTONE_CHANNELS - a channel the dialog offers but nobody + # publishes would leave approvals sitting in the queue. + SOCIAL_WORKER_CHANNELS: ${SOCIAL_WORKER_CHANNELS:-x} + INSTAGRAM_USER_ID: ${INSTAGRAM_USER_ID:-} + INSTAGRAM_ACCESS_TOKEN: ${INSTAGRAM_ACCESS_TOKEN:-} + GRAPH_API_VERSION: ${GRAPH_API_VERSION:-v22.0} + # Instagram downloads the image itself, so the worker hosts it through + # /api/social-assets on the verified domain. Same key as the web service. + SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-${TIKTOK_ADMIN_KEY:-}} + depends_on: + web: + condition: service_started + networks: + - qrmaster-network # Adminer - Database Management UI (Optional) diff --git a/docs/automations/social-accounts-and-jobs.md b/docs/automations/social-accounts-and-jobs.md index 22736dc..1352394 100644 --- a/docs/automations/social-accounts-and-jobs.md +++ b/docs/automations/social-accounts-and-jobs.md @@ -29,7 +29,7 @@ | `C:\Users\timo\Documents\meta_instagram_tokens.env` | Meta Facebook + Instagram long-lived/page tokens | | `C:\Users\timo\Documents\r2_social_media.env` | R2 upload credentials for IG assets | | `C:\Users\timo\Documents\instagram_r2_carousel_post.py` | uses both env files above | -| `C:\Users\timo\Documents\XApiAutopost\.env` | X/Twitter QRMaster creds | +| `C:\Users\timo\x-api-autopost\.env` | X/Twitter QRMaster creds (`X_API_KEY`, `X_API_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET`) | | `C:\Users\timo\Documents\greenlens-x-autopost\.env` | X/Twitter GreenLens creds | | `C:\Users\timo\Documents\greenlens\Greenlens\.env` | GreenLens TikTok + plant import admin creds | diff --git a/docs/automations/social-milestone-worker.md b/docs/automations/social-milestone-worker.md index 36315bb..67f8361 100644 --- a/docs/automations/social-milestone-worker.md +++ b/docs/automations/social-milestone-worker.md @@ -1,18 +1,32 @@ # 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. +hold X or Meta credentials. The external worker publishes what was approved — +per channel, never more. + +## Consent is bound to a channel + +Approving a post on X says nothing about Instagram: different audience, +different disclosure about the customer's business. Every channel therefore has +its own checkbox in the dialog, its own text, its own handle and its own row in +`SocialMilestonePost`. **No row means no consent.** A channel is only offered +where a publisher is configured — `SOCIAL_MILESTONE_CHANNELS` (app) must stay in +sync with `SOCIAL_WORKER_CHANNELS` (worker), otherwise approvals pile up in the +queue with nobody to publish them. ## Test setup (manual SQL only) -1. Apply [`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql) - to `qrmaster_test`. +1. Apply, in this order, to `qrmaster_test`: + [`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql), + [`sql/2026-08-16_social_milestone_retries.sql`](../../sql/2026-08-16_social_milestone_retries.sql), + [`sql/2026-08-16_social_milestone_channels.sql`](../../sql/2026-08-16_social_milestone_channels.sql). 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. + Set `SOCIAL_MILESTONE_MIN_GAP_HOURS=0` on test, otherwise the second + milestone waits a full day behind the first one. 3. Deploy using the documented test compose command. `CRON_SECRET` is forwarded to the web service by `docker-compose.yml`. 4. Trigger detection manually: @@ -23,27 +37,77 @@ curl -H "Authorization: Bearer $CRON_SECRET" \ ``` The detector creates records at 1,000 and 10,000 unique scans only. It is safe -to call repeatedly because `(qrId, kind)` is unique. +to call repeatedly because `(qrId, kind)` is unique. A QR code that is already +past several thresholds on first detection only produces the highest one. -## X worker contract - -After an explicit rollout approval, the existing QRMaster X worker may poll: +## Queue contract ```bash curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ - https://qrmaster.net/api/internal/social-milestones + "https://qrmaster.net/api/internal/social-milestones?channel=instagram" ``` -It receives at most one approved item per 24 hours. The worker must post `milestone.text` -without altering it, then report its result: +Returns at most one approved post for that channel, claims it, and expects a +result report for the returned `id`: ```bash curl -X PATCH -H "Authorization: Bearer $INTERNAL_API_SECRET" \ -H "Content-Type: application/json" \ - -d '{"id":"","result":"posted"}' \ + -d '{"id":"","result":"posted","postUrl":"https://..."}' \ https://qrmaster.net/api/internal/social-milestones ``` +First attempts are spaced by `SOCIAL_MILESTONE_MIN_GAP_HOURS` (default 24) per +channel, so the brand timeline cannot be flooded when several customers consent +on the same day. A blocked poll answers +`{"milestone": null, "reason": "spacing", "nextPostAt": "..."}`. + +`milestone.text` is published **verbatim** — it is the text the customer read +before consenting. The image is not part of the payload: the worker downloads it +from `/s/m//og`, the same renderer that serves the +popup and the link preview. `?format=` takes `landscape` (1200×630, default), +`square` (1080×1080) or `portrait` (1080×1350). + +## Instagram + +Publishing runs against the QRMaster.net Business account (see +[social-accounts-and-jobs.md](social-accounts-and-jobs.md)) in three steps: +`POST /{ig-user-id}/media` → poll `status_code` until `FINISHED` → +`POST /{ig-user-id}/media_publish`. + +Worth knowing before enabling it: + +- **JPEG only.** The worker flattens the rendered PNG onto white and uploads it + through `POST /api/social-assets`; Meta downloads `image_url` itself, so it has + to be publicly readable on the verified domain. `SOCIAL_ASSET_ADMIN_KEY` is the + existing `TIKTOK_ADMIN_KEY`. +- **No clickable links in captions.** The Instagram text therefore ends in + hashtags instead of the share URL, and mentions use the customer's Instagram + handle, not their X handle. +- **50 posts / 24 h**, verifiable via `GET /{ig-user-id}/content_publishing_limit`. +- **Reconciliation is caption-based.** Before a repeated attempt the worker + compares the caption against the last 25 media items. Two milestones with an + identical caption — same QR title, same scan count — would be treated as the + same post; the 24 h spacing makes that combination unlikely but not impossible. +- Required environment: `SOCIAL_WORKER_CHANNELS=x,instagram`, `INSTAGRAM_USER_ID`, + `INSTAGRAM_ACCESS_TOKEN`, plus `SOCIAL_MILESTONE_CHANNELS=x,instagram` on the + web service so the dialog asks for it in the first place. + 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. +brand-posting integration in this project. Customers can self-share on all three: +X opens a prefilled intent, LinkedIn gets the text copied, Instagram opens the +system share sheet on a phone and falls back to an image download. + +## Failed posts + +A reported failure — and a claim the worker never confirmed — counts as one +attempt. The queue re-schedules the post itself after 5, then 10 minutes and only +parks it in `failed` once three attempts are used up. Before posting again the +worker reconciles against the account (share token on X, caption on Instagram), +so a failure reported after a successful post cannot duplicate it. + +The consent dialog opens once per milestone, so a customer who has closed it can +no longer see or restart a failed post from there. Settings → Milestone sharing +lists every milestone with one line per channel and offers "Try again" (re-queues +the approved text unchanged) and "Cancel" (revokes that channel before anything +is published). diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 463df9f..b2e165b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,6 +94,7 @@ model User { // Social-success sharing preferences. A post is still never published // without a per-milestone approval stored below. xHandle String? + instagramHandle String? socialPromptOptOut Boolean @default(false) } @@ -183,14 +184,42 @@ model SocialMilestone { selfSharedAt DateTime? shareToken String? @unique publicShareApprovedAt DateTime? + attempts Int @default(0) + nextAttemptAt DateTime? qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) + posts SocialMilestonePost[] @@unique([qrId, kind]) @@index([status, respondedAt]) @@index([status, claimedAt]) @@index([userId, status]) + @@index([brandStatus, brandApprovedAt]) +} + +model SocialMilestonePost { + id String @id @default(cuid()) + milestoneId String + /// "x" | "instagram". Consent is bound to the channel it was given for. + channel String + /// approved | processing | posted | failed | revoked + status String @default("approved") + /// The exact text the customer read before consenting. + consentText String + handle String? + approvedAt DateTime @default(now()) + claimedAt DateTime? + postedAt DateTime? + postUrl String? + error String? + attempts Int @default(0) + nextAttemptAt DateTime? + + milestone SocialMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade) + + @@unique([milestoneId, channel]) + @@index([channel, status, approvedAt]) } enum QRType { diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index 1845605..0a09511 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -1,4 +1,10 @@ -"""Always-on QR Master X milestone worker. The web app never receives X keys.""" +"""Always-on QR Master milestone publisher. + +Polls the app's internal queue per channel and publishes what a customer has +explicitly approved for that channel. The web app never receives X or Meta +credentials; this worker never composes text of its own. +""" +import base64 import io import json import os @@ -7,9 +13,11 @@ import time from pathlib import Path import requests -from PIL import Image, ImageDraw, ImageFont +from PIL import Image from requests_oauthlib import OAuth1Session +CHANNELS = ("x", "instagram") + def required(name): value = os.getenv(name, "").strip() @@ -18,108 +26,60 @@ def required(name): return value +def enabled_channels(): + configured = [value.strip().lower() for value in os.getenv("SOCIAL_WORKER_CHANNELS", "x").split(",")] + return [channel for channel in configured if channel in CHANNELS] or ["x"] + + 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): - for candidate in (f"/usr/share/fonts/truetype/dejavu/{name}", f"C:/Windows/Fonts/{'arialbd.ttf' if 'Bold' in name else 'arial.ttf'}"): - if Path(candidate).exists(): - return ImageFont.truetype(candidate, size) - return ImageFont.load_default() +def graph(path): + return f"https://graph.facebook.com/{os.getenv('GRAPH_API_VERSION', 'v22.0')}/{path}" -def logo(): - """Use the actual deployed QR Master favicon, not an invented icon.""" +def graph_error(response): + """Meta answers with a JSON error body that says far more than the status.""" try: - base = required("QRMASTER_API_BASE").rstrip("/") - response = requests.get(f"{base}/favicon.ico", timeout=10) - response.raise_for_status() - mark = Image.open(io.BytesIO(response.content)).convert("RGBA") - mark.thumbnail((56, 56)) - return mark - except Exception: + error = response.json().get("error") or {} + detail = error.get("error_user_msg") or error.get("message") + if detail: + return f"{detail} (code {error.get('code')})" + except ValueError: + pass + return f"HTTP {response.status_code}" + + +def milestone_image(milestone, image_format="landscape"): + """Download the card the app renders at /s/m//og. + + The popup, the link preview and the published post therefore show the exact + same image: one renderer, one source of truth, nothing to keep in sync here. + The internal base is used on purpose - the public host is not necessarily + reachable from inside the worker network. + """ + token = str(milestone.get("shareToken") or "").strip() + if not token: return None - - -def render_card(card): - """Render an immutable cumulative scan timeline from the stored snapshot.""" - image = Image.new("RGB", (1200, 630), "#f8f7f4") - draw = ImageDraw.Draw(image) - navy, blue, slate, mint = "#061b31", "#0256ff", "#64748b", "#059669" - regular, medium, display = font("DejaVuSans.ttf", 27), font("DejaVuSans-Bold.ttf", 27), font("DejaVuSans.ttf", 142) - mark = logo() - if mark: - image.paste(mark, (68, 58), mark) - logo_x = 140 - else: - logo_x = 68 - draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy) - badge_text = "Verified scan milestone" - badge_font = font("DejaVuSans.ttf", 19) - badge_box = draw.textbbox((0, 0), badge_text, font=badge_font) - badge_width = badge_box[2] - badge_box[0] - draw.rounded_rectangle((1120 - badge_width - 32, 61, 1132, 106), radius=7, fill="#ecfdf5") - draw.text((1116, 73), badge_text, font=badge_font, fill=mint, anchor="ra") - draw.line((68, 124, 1132, 124), fill="#e5edf5", width=2) - - total = int(card.get("totalUniqueScans") or card.get("threshold") or 0) - total_scans = int(card.get("totalScans") or total) - draw.text((66, 212), f"{total:,}", font=display, fill=navy) - draw.text((73, 374), "UNIQUE SCANS", font=medium, fill=navy) - draw.text((74, 410), f"{total_scans:,} total scans", font=font("DejaVuSans.ttf", 20), fill=slate) - - trend = card.get("trend") or {} - raw_points = trend.get("points") or [] - left, top, width, height = 590, 140, 540, 330 - if raw_points: - start_ms = min(_timestamp(point.get("at")) for point in raw_points) - end_ms = max(_timestamp(point.get("at")) for point in raw_points) - span = max(1, end_ms - start_ms) - ceiling = 5.0 if total <= 5 else float(trend.get("ceiling") or total * 1.25) - points = [(left + round((_timestamp(point.get("at")) - start_ms) / span * width), top + height - round(float(point.get("total", 0)) / ceiling * height)) for point in raw_points] - target_y = top + height - round(float(trend.get("target") or total) / ceiling * height) - # Small milestones use whole scans (1..5); larger ones keep the - # reached milestone on the fourth of five levels. - axis_values = list(range(1, 6)) if total <= 5 else [total * index / 4 for index in range(1, 6)] - for value in axis_values: - y = top + height - round(value / ceiling * height) - is_target = value == total - draw.line((left, y, left + width, y), fill="#bfdbfe" if is_target else "#dde5ef", width=2 if is_target else 1) - draw.text((left - 15, y - 12), _format_axis(value), font=font("DejaVuSans.ttf", 18), fill=blue if is_target else slate, anchor="ra") - draw.line(points, fill=blue, width=5, joint="curve") - x, y = points[-1] - draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill="#ffffff", outline=blue, width=5) - draw.text((left, top + height + 27), trend.get("startLabel") or "Created", font=font("DejaVuSans.ttf", 19), fill=slate) - draw.text((left + width, top + height + 27), trend.get("endLabel") or "Reached", font=font("DejaVuSans.ttf", 19), fill=slate, anchor="ra") - draw.line((68, 536, 1132, 536), fill="#e5edf5", width=2) - title = str(card.get("qrTitle") or "QR code") - if len(title) > 52: - title = title[:49].rstrip() + "..." - draw.text((68, 558), title, font=medium, fill=navy) + base = required("QRMASTER_API_BASE").rstrip("/") + response = requests.get(f"{base}/s/m/{token}/og", params={"format": image_format}, timeout=60) + response.raise_for_status() path = Path(tempfile.mkstemp(suffix=".png")[1]) - image.save(path, "PNG", optimize=True) + path.write_bytes(response.content) return path -def _timestamp(value): - try: - return int(__import__("datetime").datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) - except Exception: - return 0 - - -def _format_axis(value): - return f"{value:g}" if value < 1000 else f"{value:,.0f}" +# --- X --------------------------------------------------------------------- def oauth_client(): return 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")) -def find_existing_post(oauth, milestone): +def find_existing_x_post(oauth, milestone): """Reconcile an uncertain prior attempt before creating another X post.""" token = str(milestone.get("shareToken") or "").strip() if not token: @@ -139,12 +99,20 @@ def find_existing_post(oauth, milestone): urls = (post.get("entities") or {}).get("urls") or [] expanded = " ".join(str(url.get("expanded_url") or url.get("unwound_url") or "") for url in urls) if token in expanded: - return post + return f"https://x.com/i/web/status/{post.get('id')}" return None -def post_x(text, card, oauth): - path = render_card(card) if card else None +def publish_x(milestone): + oauth = oauth_client() + # Reading the timeline costs far more X quota than writing a post, so + # reconcile only when this post was already attempted before. + if milestone.get("attempts"): + existing = find_existing_x_post(oauth, milestone) + if existing: + return existing, True + + path = milestone_image(milestone) try: media_id = None if path: @@ -152,45 +120,160 @@ def post_x(text, card, oauth): 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} + payload = {"text": milestone["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() + tweet_id = result.json().get("data", {}).get("id") + return (f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None), False finally: if path: path.unlink(missing_ok=True) -def run_once(): +# --- Instagram ------------------------------------------------------------- + + +def instagram_jpeg(path): + """Instagram accepts JPEG only, so the rendered PNG is flattened onto white. + + The card is drawn at 1080x1350 (4:5), the tallest ratio Instagram allows. + """ + with Image.open(path) as image: + rgba = image.convert("RGBA") + canvas = Image.new("RGB", rgba.size, "white") + canvas.paste(rgba, mask=rgba.split()[3]) + buffer = io.BytesIO() + canvas.save(buffer, "JPEG", quality=92, optimize=True) + target = Path(tempfile.mkstemp(suffix=".jpg")[1]) + target.write_bytes(buffer.getvalue()) + return target + + +def host_asset(path): + """Meta downloads `image_url` itself, so the file must be publicly readable. + + /api/social-assets stores it in Postgres and serves it from the verified + qrmaster.net domain - no object storage and no deploy needed per post. + """ + base = required("QRMASTER_API_BASE").rstrip("/") + response = requests.post( + f"{base}/api/social-assets", + headers={"x-admin-key": required("SOCIAL_ASSET_ADMIN_KEY")}, + json={"files": [{"filename": path.name, "mimeType": "image/jpeg", "dataBase64": base64.b64encode(path.read_bytes()).decode()}]}, + timeout=60, + ) + if not response.ok: + raise RuntimeError(f"Asset upload failed: {response.text[:200]}") + url = ((response.json().get("assets") or [{}])[0]).get("url") + if not url: + raise RuntimeError("Asset upload returned no URL") + return url + + +def find_existing_instagram_post(caption): + """Reconcile by caption: a repeated attempt must not post twice. + + Instagram has nothing like a client-side idempotency key, and a share token + in the caption would only be visible clutter - the caption itself is the + identifying detail. + """ + response = requests.get( + graph(f"{required('INSTAGRAM_USER_ID')}/media"), + params={"fields": "id,caption,permalink", "limit": 25, "access_token": required("INSTAGRAM_ACCESS_TOKEN")}, + timeout=30, + ) + if not response.ok: + raise RuntimeError(graph_error(response)) + for media in response.json().get("data") or []: + if (media.get("caption") or "").strip() == caption.strip(): + return media.get("permalink") + return None + + +def publish_instagram(milestone): + caption = milestone["text"] + if milestone.get("attempts"): + existing = find_existing_instagram_post(caption) + if existing: + return existing, True + + user_id = required("INSTAGRAM_USER_ID") + token = required("INSTAGRAM_ACCESS_TOKEN") + png = milestone_image(milestone, "portrait") + if not png: + raise RuntimeError("Milestone has no share token, so no image can be published") + jpeg = None + try: + jpeg = instagram_jpeg(png) + image_url = host_asset(jpeg) + container = requests.post(graph(f"{user_id}/media"), data={"image_url": image_url, "caption": caption, "access_token": token}, timeout=60) + if not container.ok: + raise RuntimeError(graph_error(container)) + creation_id = container.json().get("id") + if not creation_id: + raise RuntimeError("Instagram did not return a container id") + + # Meta fetches and processes the image asynchronously. + deadline = time.time() + 120 + while True: + status = requests.get(graph(creation_id), params={"fields": "status_code,status", "access_token": token}, timeout=30) + if not status.ok: + raise RuntimeError(graph_error(status)) + code = status.json().get("status_code") + if code == "FINISHED": + break + if code in {"ERROR", "EXPIRED"}: + raise RuntimeError(f"Instagram rejected the media container: {status.json().get('status') or code}") + if time.time() > deadline: + raise RuntimeError("Instagram did not finish processing the image within 120s") + time.sleep(5) + + published = requests.post(graph(f"{user_id}/media_publish"), data={"creation_id": creation_id, "access_token": token}, timeout=60) + if not published.ok: + raise RuntimeError(graph_error(published)) + media_id = published.json().get("id") + permalink = None + if media_id: + link = requests.get(graph(media_id), params={"fields": "permalink", "access_token": token}, timeout=30) + permalink = link.json().get("permalink") if link.ok else None + return permalink, False + finally: + png.unlink(missing_ok=True) + if jpeg: + jpeg.unlink(missing_ok=True) + + +PUBLISHERS = {"x": publish_x, "instagram": publish_instagram} + + +def run_once(channel): base = required("QRMASTER_API_BASE").rstrip("/") + "/api/internal/social-milestones" - milestone = api("GET", base).get("milestone") + milestone = api("GET", f"{base}?channel={channel}").get("milestone") if not milestone: return try: - oauth = oauth_client() - existing = find_existing_post(oauth, milestone) - result = {"data": existing, "reconciled": True} if existing else post_x(milestone["text"], milestone.get("card"), oauth) - tweet_id = result.get("data", {}).get("id") - post_url = f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None + post_url, reconciled = PUBLISHERS[channel](milestone) api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url}) - print(json.dumps({"posted": milestone["id"], "reconciled": bool(existing), "x": result}), flush=True) + print(json.dumps({"posted": milestone["id"], "channel": channel, "reconciled": reconciled, "url": post_url}), 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) + print(f"Milestone post failed ({channel}): {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") - print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval}), flush=True) + channels = enabled_channels() + print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval, "channels": channels}), flush=True) while True: - try: - run_once() - except Exception as error: - # Stay alive and make configuration/network errors visible in the - # container logs instead of entering a silent restart loop. - print(f"Worker cycle failed: {error}", flush=True) + for channel in channels: + try: + run_once(channel) + except Exception as error: + # Stay alive and make configuration/network errors visible in the + # container logs instead of entering a silent restart loop. + print(f"Worker cycle failed ({channel}): {error}", flush=True) time.sleep(interval) diff --git a/sql/2026-08-16_social_milestone_channels.sql b/sql/2026-08-16_social_milestone_channels.sql new file mode 100644 index 0000000..7fb84af --- /dev/null +++ b/sql/2026-08-16_social_milestone_channels.sql @@ -0,0 +1,42 @@ +-- Per-channel publishing for milestone posts. Run once against the target +-- database BEFORE deploying the application version that uses this table. +-- +-- Consent is channel-bound: agreeing to a post on X says nothing about +-- Instagram - different audience, different disclosure. Publishing state +-- therefore moves out of the SocialMilestone row into one row per channel. +-- The old "brand*" columns stay in place as a fallback and are backfilled +-- below; nothing reads them any more. +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "instagramHandle" TEXT; + +CREATE TABLE IF NOT EXISTS "SocialMilestonePost" ( + "id" TEXT PRIMARY KEY, + "milestoneId" TEXT NOT NULL REFERENCES "SocialMilestone"("id") ON DELETE CASCADE, + "channel" TEXT NOT NULL, -- 'x' | 'instagram' + "status" TEXT NOT NULL DEFAULT 'approved', -- approved | processing | posted | failed | revoked + -- The text the customer read before consenting. Published verbatim. + "consentText" TEXT NOT NULL, + "handle" TEXT, + "approvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "claimedAt" TIMESTAMP(3), + "postedAt" TIMESTAMP(3), + "postUrl" TEXT, + "error" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "nextAttemptAt" TIMESTAMP(3), + -- A row exists only where consent exists. No row means: not approved. + CONSTRAINT "SocialMilestonePost_milestone_channel_key" UNIQUE ("milestoneId", "channel") +); + +CREATE INDEX IF NOT EXISTS "SocialMilestonePost_channel_status_approvedAt_idx" + ON "SocialMilestonePost" ("channel", "status", "approvedAt"); + +-- Existing X consents keep working. `processing` becomes `approved` again: the +-- publisher reconciles against the timeline before it posts, so a re-claim +-- cannot duplicate a post that already went out. +INSERT INTO "SocialMilestonePost" ("id", "milestoneId", "channel", "status", "consentText", "approvedAt", "postedAt", "postUrl", "error", "attempts") +SELECT gen_random_uuid()::text, "id", 'x', + CASE WHEN "brandStatus" = 'processing' THEN 'approved' ELSE "brandStatus" END, + "consentText", COALESCE("brandApprovedAt", "detectedAt"), "brandPostedAt", "brandPostUrl", "brandPostError", "attempts" +FROM "SocialMilestone" +WHERE "consentText" IS NOT NULL AND "brandStatus" IN ('approved', 'processing', 'posted', 'failed', 'revoked') +ON CONFLICT ("milestoneId", "channel") DO NOTHING; diff --git a/sql/2026-08-16_social_milestone_retries.sql b/sql/2026-08-16_social_milestone_retries.sql new file mode 100644 index 0000000..315b20b --- /dev/null +++ b/sql/2026-08-16_social_milestone_retries.sql @@ -0,0 +1,15 @@ +-- Milestone publishing retries. Run once against the target database before +-- deploying the application version that uses these columns. +-- +-- docker-compose exec db psql -U postgres -d qrmaster -f - < sql/2026-08-16_social_milestone_retries.sql +-- +-- A failed X post used to stay failed forever: the consent dialog only opens +-- for freshly detected milestones, so nobody ever saw the retry button again. +-- The publisher now re-queues a failed attempt on its own until it runs out of +-- attempts, and the customer can retry a permanently failed post from Settings. +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "nextAttemptAt" TIMESTAMP(3); + +-- The publisher polls for the oldest approved milestone that is due. +CREATE INDEX IF NOT EXISTS "SocialMilestone_brandStatus_brandApprovedAt_idx" + ON "SocialMilestone" ("brandStatus", "brandApprovedAt"); diff --git a/src/app/(main)/(app)/settings/page.tsx b/src/app/(main)/(app)/settings/page.tsx index de68722..8f6809e 100644 --- a/src/app/(main)/(app)/settings/page.tsx +++ b/src/app/(main)/(app)/settings/page.tsx @@ -10,6 +10,41 @@ import ChangePasswordModal from '@/components/settings/ChangePasswordModal'; type TabType = 'profile' | 'subscription'; +type MilestonePost = { + channel: string; + status: string; + postUrl: string | null; + error: string | null; + postedAt: string | null; +}; + +type MilestoneHistoryItem = { + id: string; + qrTitle: string; + uniqueScans: number; + detectedAt: string; + promptStatus: string; + selfSharedAt: string | null; + shareUrl: string | null; + posts: MilestonePost[]; +}; + +const CHANNEL_LABELS: Record = { x: 'X', instagram: 'Instagram' }; + +function milestoneStateLabel(milestone: MilestoneHistoryItem) { + if (milestone.posts.some(post => post.status !== 'revoked')) return 'Approved for publishing'; + if (milestone.selfSharedAt) return 'Shared by you'; + if (milestone.promptStatus === 'declined') return 'Declined'; + return 'Waiting for your decision'; +} + +function postStateLabel(post: MilestonePost) { + if (post.status === 'posted') return 'published'; + if (post.status === 'failed') return 'publishing failed'; + if (post.status === 'revoked') return 'revoked before publishing'; + return 'queued'; +} + export default function SettingsPage() { const { fetchWithCsrf } = useCsrf(); const [activeTab, setActiveTab] = useState('profile'); @@ -18,6 +53,8 @@ export default function SettingsPage() { const [socialPromptsEnabled, setSocialPromptsEnabled] = useState(true); const [socialTestResetAvailable, setSocialTestResetAvailable] = useState(false); const [socialSaving, setSocialSaving] = useState(false); + const [milestones, setMilestones] = useState([]); + const [milestoneBusy, setMilestoneBusy] = useState(null); // Profile states const [name, setName] = useState(''); @@ -62,6 +99,12 @@ export default function SettingsPage() { const data = await socialResponse.json(); setSocialPromptsEnabled(data.promptsEnabled !== false); setSocialTestResetAvailable(data.testResetAvailable === true); + } + + const historyResponse = await fetch('/api/social-milestones/history'); + if (historyResponse.ok) { + const data = await historyResponse.json(); + setMilestones(Array.isArray(data.milestones) ? data.milestones : []); } } catch (e) { console.error('Failed to load user data:', e); @@ -119,6 +162,30 @@ export default function SettingsPage() { showToast(error instanceof Error ? error.message : 'Could not update milestone prompts', 'error'); } finally { setSocialSaving(false); + } + }; + + const updateMilestone = async (id: string, action: 'retry' | 'revoke', channel: string) => { + setMilestoneBusy(`${id}:${channel}`); + try { + const response = await fetchWithCsrf(`/api/social-milestones/${id}`, { + method: 'PATCH', + body: JSON.stringify({ action, channel }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Could not update this milestone'); + setMilestones(current => current.map(milestone => milestone.id === id ? { + ...milestone, + posts: milestone.posts.map(post => { + const next = (data.milestone?.posts || []).find((entry: MilestonePost) => entry.channel === post.channel); + return next ? { ...post, status: next.status, postUrl: next.postUrl, error: next.error } : post; + }), + } : milestone)); + showToast(action === 'retry' ? 'Post queued again.' : 'Post revoked. Nothing will be published.', 'success'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not update this milestone', 'error'); + } finally { + setMilestoneBusy(null); } }; @@ -289,6 +356,52 @@ export default function SettingsPage() { {socialPromptsEnabled ? 'Turn off' : 'Turn on'}
+ {milestones.length > 0 &&
+

Your milestones

+

Every scan milestone we detected and what happened to it.

+
    + {milestones.map(milestone => ( +
  • +
    +
    +

    {milestone.qrTitle}

    +

    + {milestone.uniqueScans.toLocaleString('en-US')} unique scans · {new Date(milestone.detectedAt).toLocaleDateString('en-US')} · {milestoneStateLabel(milestone)} +

    +
    + {milestone.shareUrl && ( + Open card + )} +
    + {/* One line per channel: consent, and everything that can be + withdrawn or restarted, is per channel. */} + {milestone.posts.map(post => ( +
    +
    +

    + {CHANNEL_LABELS[post.channel] || post.channel} — {postStateLabel(post)} +

    + {post.status === 'failed' && post.error && ( +

    {post.error}

    + )} +
    +
    + {post.postUrl && ( + View post + )} + {post.status === 'failed' && ( + + )} + {['approved', 'failed'].includes(post.status) && ( + + )} +
    +
    + ))} +
  • + ))} +
+
} {socialTestResetAvailable &&

Test environment: reopen the latest milestone and clear its publishing state.

diff --git a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx index a7d9d0a..de450b7 100644 --- a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx +++ b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx @@ -1,18 +1,21 @@ import { db } from '@/lib/db'; -import { createSocialMilestoneImage } from '@/lib/social-milestone-image'; +import { createSocialMilestoneImage, socialMilestoneImageFormat } from '@/lib/social-milestone-image'; import type { SocialMilestoneImageCard } from '@/lib/social-milestone-image'; export const runtime = 'nodejs'; -export async function GET(_request: Request, { params }: { params: { token: string } }) { +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' } }); + // `format` serves the aspect ratios the networks accept: the default 1.91:1 + // for link previews, 1:1 and 4:5 for an Instagram post. return createSocialMilestoneImage( (share.cardData || {}) as SocialMilestoneImageCard, share.language === 'de', + socialMilestoneImageFormat(new URL(request.url).searchParams.get('format')), ); } diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts index ae9c488..422a8d7 100644 --- a/src/app/(main)/api/internal/social-milestones/route.ts +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getWwwOrigin } from '@/lib/hosts'; +import { isSocialChannel, SOCIAL_CHANNELS, SocialChannel } from '@/lib/social-milestones'; export const dynamic = 'force-dynamic'; @@ -14,54 +15,109 @@ function approvalDelayHours() { return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 0; } +/** Timeline spacing between two brand posts. Set to 0 to publish back to back. */ +function minGapHours() { + const configured = Number(process.env.SOCIAL_MILESTONE_MIN_GAP_HOURS); + return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 24; +} + +// Route modules may only export request handlers, so these stay local. +const MAX_PUBLISH_ATTEMPTS = 3; + +/** One lock per channel, otherwise two publishers would block each other. */ +function claimLockId(channel: SocialChannel) { + return 920241 + SOCIAL_CHANNELS.indexOf(channel); +} + +/** 5, 10, then 20 minutes. A transient outage resolves without a human. */ +function retryDelayMs(attempts: number) { + return Math.min(60, 5 * 2 ** Math.max(0, attempts - 1)) * 60 * 1000; +} + // 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. +// external worker fetches an approved payload and marks it complete only after +// its own post succeeded. The app never receives X or Meta credentials. export async function GET(request: NextRequest) { if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const channelParam = request.nextUrl.searchParams.get('channel') || 'x'; + if (!isSocialChannel(channelParam)) return NextResponse.json({ error: 'Unknown channel' }, { status: 400 }); + const channel: SocialChannel = channelParam; const dryRun = request.nextUrl.searchParams.get('dryRun') === 'true'; const now = Date.now(); - // A worker can be interrupted after claiming a row. Surface that state as a - // retryable failure instead of leaving the dashboard in "processing" forever. - await db.socialMilestone.updateMany({ - where: { brandStatus: 'processing', claimedAt: { lt: new Date(now - 5 * 60 * 1000) } }, - data: { brandStatus: 'failed', brandPostError: 'The publisher was interrupted before it confirmed the post. Please retry.' }, - }); + + // A worker can be interrupted after claiming a row. Count that as a spent + // attempt and re-queue it instead of leaving the dashboard in "processing" + // forever. The worker reconciles against the account before it posts again, + // so an interruption after a successful post cannot duplicate it. + await db.$executeRaw` + UPDATE "SocialMilestonePost" + SET "attempts" = "attempts" + 1, + "status" = CASE WHEN "attempts" + 1 < ${MAX_PUBLISH_ATTEMPTS} THEN 'approved' ELSE 'failed' END, + "nextAttemptAt" = CASE WHEN "attempts" + 1 < ${MAX_PUBLISH_ATTEMPTS} THEN ${new Date(now + retryDelayMs(1))} ELSE NULL END, + "error" = 'The publisher was interrupted before it confirmed the post.', + "claimedAt" = NULL + WHERE "channel" = ${channel} AND "status" = 'processing' AND "claimedAt" < ${new Date(now - 5 * 60 * 1000)} + `; + 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 } } }, + const post = await db.socialMilestonePost.findFirst({ + where: { + channel, + status: 'approved', + approvedAt: { lte: approvalNotBefore }, + OR: [{ nextAttemptAt: null }, { nextAttemptAt: { lte: new Date(now) } }], + // A paused or deleted QR code stops being advertised. + milestone: { qr: { status: 'ACTIVE' } }, + }, + orderBy: { approvedAt: 'asc' }, + include: { milestone: { select: { id: true, shareToken: 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 (!post) return NextResponse.json({ milestone: null }); + + // Several customers can consent on the same afternoon. Spacing keeps the + // brand timeline readable, per channel. A retry is exempt: nothing of it went + // out yet, and delaying recovery by a full day would strand the post. + if (post.attempts === 0 && minGapHours() > 0) { + const previous = await db.socialMilestonePost.findFirst({ + where: { channel, postedAt: { gt: new Date(now - minGapHours() * 60 * 60 * 1000) } }, + orderBy: { postedAt: 'desc' }, + select: { postedAt: true }, + }); + if (previous?.postedAt) { + const nextPostAt = new Date(previous.postedAt.getTime() + minGapHours() * 60 * 60 * 1000); + return NextResponse.json({ milestone: null, reason: 'spacing', nextPostAt: nextPostAt.toISOString() }); + } } - const shareUrl = milestone.shareToken ? `${getWwwOrigin()}/s/m/${milestone.shareToken}` : null; - if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, shareUrl }, dryRun: true }); + + const shareUrl = post.milestone.shareToken ? `${getWwwOrigin()}/s/m/${post.milestone.shareToken}` : null; + if (dryRun) return NextResponse.json({ milestone: { id: post.id, channel, text: post.consentText, shareUrl }, dryRun: true }); const claimed = await db.$transaction(async (tx) => { // The blocking advisory-lock function returns PostgreSQL `void`, which // Prisma cannot deserialize. The try variant returns a real boolean and // keeps the lock scoped to this transaction. const [lock] = await tx.$queryRaw>` - SELECT pg_try_advisory_xact_lock(920241) AS acquired + SELECT pg_try_advisory_xact_lock(${claimLockId(channel)}) AS acquired `; if (!lock?.acquired) return 0; - const result = await tx.socialMilestone.updateMany({ - where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() }, + const result = await tx.socialMilestonePost.updateMany({ + where: { id: post.id, status: 'approved' }, data: { status: '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, - shareToken: milestone.shareToken, + id: post.id, + milestoneId: post.milestone.id, + channel, + text: post.consentText, + shareToken: post.milestone.shareToken, shareUrl, - approvedAt: milestone.brandApprovedAt?.toISOString() || null, + // Tells the worker whether an earlier attempt may already have published + // this post, so it only spends read quota when reconciling. + attempts: post.attempts, + approvedAt: post.approvedAt.toISOString(), } }); } @@ -69,15 +125,37 @@ 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' }, + const claimed = await db.socialMilestonePost.findFirst({ + where: { id: body.id, status: 'processing' }, + select: { id: true, attempts: true }, + }); + if (!claimed) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 }); + + if (body.result === 'posted') { + const updated = await db.socialMilestonePost.updateMany({ + where: { id: claimed.id, status: 'processing' }, + data: { status: 'posted', postedAt: new Date(), postUrl: body.postUrl || null, error: null, nextAttemptAt: null }, + }); + if (!updated.count) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 }); + return NextResponse.json({ ok: true }); + } + + // Re-queue on its own until the attempts are used up. Only then does the post + // rest in `failed`, where the customer can restart it manually. + const attempts = claimed.attempts + 1; + const retry = attempts < MAX_PUBLISH_ATTEMPTS; + const updated = await db.socialMilestonePost.updateMany({ + where: { id: claimed.id, status: '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, + status: retry ? 'approved' : 'failed', + attempts, + nextAttemptAt: retry ? new Date(Date.now() + retryDelayMs(attempts)) : null, + postedAt: null, + postUrl: null, + error: body.error || 'The post could not be published.', + claimedAt: null, }, }); - if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 }); - return NextResponse.json({ ok: true }); + if (!updated.count) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 }); + return NextResponse.json({ ok: true, attempts, retryScheduled: retry }); } diff --git a/src/app/(main)/api/social-assets/[id]/route.ts b/src/app/(main)/api/social-assets/[id]/route.ts index 2a846b1..89f8d06 100644 --- a/src/app/(main)/api/social-assets/[id]/route.ts +++ b/src/app/(main)/api/social-assets/[id]/route.ts @@ -38,7 +38,7 @@ export async function DELETE( request: NextRequest, { params }: { params: { id: string } } ) { - const adminKey = process.env.TIKTOK_ADMIN_KEY; + const adminKey = process.env.SOCIAL_ASSET_ADMIN_KEY || process.env.TIKTOK_ADMIN_KEY; const provided = request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key'); if (!adminKey || provided !== adminKey) { diff --git a/src/app/(main)/api/social-assets/route.ts b/src/app/(main)/api/social-assets/route.ts index c37c656..b3ab765 100644 --- a/src/app/(main)/api/social-assets/route.ts +++ b/src/app/(main)/api/social-assets/route.ts @@ -7,7 +7,9 @@ import { db } from '@/lib/db'; // are served from qrmaster.net via GET /api/social-assets/[id]. const isAdminRequest = (request: NextRequest) => { - const adminKey = process.env.TIKTOK_ADMIN_KEY; + // Asset hosting is not a TikTok feature - Instagram needs it too. The old + // TikTok key stays valid so existing deployments keep working. + const adminKey = process.env.SOCIAL_ASSET_ADMIN_KEY || process.env.TIKTOK_ADMIN_KEY; if (!adminKey) return false; const provided = request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key'); diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts index 0d11102..35febe4 100644 --- a/src/app/(main)/api/social-milestones/[id]/route.ts +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -4,28 +4,51 @@ import { db } from '@/lib/db'; import { csrfProtection } from '@/lib/csrf'; import { getWwwOrigin } from '@/lib/hosts'; import { getSessionUserId } from '@/lib/session'; -import { buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones'; +import { + buildChannelPost, getEnabledSocialChannels, isSocialChannel, milestoneThreshold, + normalizeChannelHandle, SocialChannel, socialLocale, +} from '@/lib/social-milestones'; import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server'; -type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke'; +type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke' | 'retry'; +type Body = { + action?: Action; + withName?: boolean; + channels?: string[]; + handles?: Record; + channel?: string; + language?: string; +}; async function ownedMilestone(id: string, userId: string) { return db.socialMilestone.findFirst({ where: { id, userId }, - include: { user: { select: { primaryUseCase: true } }, qr: { select: { id: true, title: true, createdAt: true } } }, + include: { + user: { select: { primaryUseCase: true } }, + qr: { select: { id: true, title: true, createdAt: true } }, + posts: { select: { channel: true, status: true, postUrl: true, error: true } }, + }, }); } -function clientState(milestone: { status: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) { +type ClientMilestone = { status: string; selfSharedAt: Date | null; posts: Array<{ channel: string; status: string; postUrl: string | null; error: string | null }> }; + +function clientState(milestone: ClientMilestone) { return { promptStatus: milestone.status, - brandStatus: milestone.brandStatus, - brandPostUrl: milestone.brandPostUrl, - brandPostError: milestone.brandPostError, selfSharedAt: milestone.selfSharedAt?.toISOString() || null, + posts: milestone.posts.map(post => ({ channel: post.channel, status: post.status, postUrl: post.postUrl, error: post.error })), }; } +async function stateOf(milestoneId: string) { + const milestone = await db.socialMilestone.findUnique({ + where: { id: milestoneId }, + select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } }, + }); + return milestone ? clientState(milestone) : null; +} + export async function GET(_request: NextRequest, { params }: { params: { id: string } }) { const userId = getSessionUserId(); if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); @@ -40,8 +63,8 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st 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 || '')) { + const body = await request.json().catch(() => null) as Body | null; + if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke', 'retry'].includes(body.action || '')) { return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); } const milestone = await ownedMilestone(params.id, userId); @@ -50,11 +73,26 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st 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) }); + // Revoke and retry act on a single channel: consent for X is not consent for + // Instagram, and withdrawing one must not touch the other. + if (body.action === 'revoke' || body.action === 'retry') { + if (!isSocialChannel(body.channel)) return NextResponse.json({ error: 'Unknown channel' }, { status: 400 }); + const post = await db.socialMilestonePost.findUnique({ + where: { milestoneId_channel: { milestoneId: milestone.id, channel: body.channel } }, + }); + if (!post) return NextResponse.json({ error: 'Nothing was approved for this channel' }, { status: 404 }); + if (body.action === 'revoke') { + if (!['approved', 'failed'].includes(post.status)) return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 }); + await db.socialMilestonePost.update({ where: { id: post.id }, data: { status: 'revoked', error: null, nextAttemptAt: null } }); + } else { + if (post.status !== 'failed') return NextResponse.json({ error: 'Only failed posts can be restarted' }, { status: 409 }); + // Restarts reuse the approved text unchanged - a retry must never + // publish something the customer did not read before consenting. + await db.socialMilestonePost.update({ where: { id: post.id }, data: { status: 'approved', attempts: 0, nextAttemptAt: null, error: null } }); + } + return NextResponse.json({ ok: true, milestone: await stateOf(milestone.id) }); } + 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(); @@ -67,8 +105,6 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st const language = socialLocale(body.language); const withName = 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 = await ensureSocialMilestoneCard({ milestoneId: milestone.id, cardData: milestone.cardData, @@ -79,43 +115,76 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st primaryUseCase: milestone.user.primaryUseCase, }); const now = new Date(); + // 72 random bits keep public URLs unguessable while making the share URL + // much less disruptive in an X compose window than a full UUID. const token = milestone.shareToken || randomBytes(9).toString('base64url'); const shareUrl = `${getWwwOrigin()}/s/m/${token}`; if (body.action === 'self_share') { - // 72 random bits keep public URLs unguessable while making the share URL - // much less disruptive in an X compose window than a full UUID. const updated = await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'self_shared', respondedAt: milestone.respondedAt || now, selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language, }, + select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } }, }); return NextResponse.json({ ok: true, shareToken: token, shareUrl, shareVersion: now.getTime(), milestone: clientState(updated) }); } - if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) { - return NextResponse.json({ error: 'This brand post is already being processed' }, { status: 409 }); + const enabled = getEnabledSocialChannels(); + const channels = (body.channels || []).filter(isSocialChannel).filter(channel => enabled.includes(channel)); + if (!channels.length) return NextResponse.json({ error: 'Choose at least one channel' }, { status: 400 }); + + const handles = new Map(); + for (const channel of channels) { + if (!withName) { + handles.set(channel, null); + continue; + } + const handle = normalizeChannelHandle(channel, body.handles?.[channel] || ''); + if (!handle) return NextResponse.json({ error: `Enter a valid ${channel === 'instagram' ? 'Instagram' : 'X'} handle` }, { status: 400 }); + handles.set(channel, handle); } - const postText = buildMilestonePostForQr( - milestone.user.primaryUseCase, - (card as { totalUniqueScans?: number }).totalUniqueScans || threshold, - xHandle, - language, - milestone.qr.title, - ); - const consentText = `${postText}\n\n${shareUrl}`; + + const locked = milestone.posts.filter(post => channels.includes(post.channel as SocialChannel) && ['processing', 'posted'].includes(post.status)); + if (locked.length) return NextResponse.json({ error: 'This post is already being processed' }, { status: 409 }); + const updated = await db.$transaction(async tx => { - if (withName) await tx.user.update({ where: { id: userId }, data: { xHandle } }); + if (withName) { + await tx.user.update({ + where: { id: userId }, + data: { + ...(handles.has('x') ? { xHandle: handles.get('x') } : {}), + ...(handles.has('instagram') ? { instagramHandle: handles.get('instagram') } : {}), + }, + }); + } + for (const channel of channels) { + const consentText = buildChannelPost({ + channel, + primaryUseCase: milestone.user.primaryUseCase, + totalUniqueScans: (card as { totalUniqueScans?: number }).totalUniqueScans || threshold, + locale: language, + qrTitle: milestone.qr.title, + shareUrl, + handle: handles.get(channel) || null, + }); + await tx.socialMilestonePost.upsert({ + where: { milestoneId_channel: { milestoneId: milestone.id, channel } }, + create: { milestoneId: milestone.id, channel, consentText, handle: handles.get(channel) || null, approvedAt: now, status: 'approved' }, + // A re-approval after a correction or a withdrawal starts over. + update: { consentText, handle: handles.get(channel) || null, status: 'approved', attempts: 0, nextAttemptAt: null, error: null }, + }); + } return tx.socialMilestone.update({ where: { id: milestone.id }, data: { - brandStatus: 'approved', brandApprovedAt: milestone.brandApprovedAt || now, brandPostError: null, - status: 'approved', withName, consentText, language, cardData: card, respondedAt: now, + status: 'approved', withName, language, cardData: card, respondedAt: now, shareToken: token, publicShareApprovedAt: now, }, + select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } }, }); }); - return NextResponse.json({ ok: true, consentText, milestone: clientState(updated) }); + return NextResponse.json({ ok: true, milestone: clientState(updated) }); } diff --git a/src/app/(main)/api/social-milestones/history/route.ts b/src/app/(main)/api/social-milestones/history/route.ts new file mode 100644 index 0000000..263b270 --- /dev/null +++ b/src/app/(main)/api/social-milestones/history/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getWwwOrigin } from '@/lib/hosts'; +import { getSessionUserId } from '@/lib/session'; +import { milestoneThreshold } from '@/lib/social-milestones'; + +export const dynamic = 'force-dynamic'; + +// The consent dialog opens once per milestone. Everything that happened before +// - a declined prompt, a queued post, a post that ran out of attempts - is only +// visible here, which is also the only place a failed post can be restarted. +export async function GET() { + const userId = getSessionUserId(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const milestones = await db.socialMilestone.findMany({ + where: { userId }, + orderBy: { detectedAt: 'desc' }, + take: 50, + select: { + id: true, kind: true, status: true, detectedAt: true, cardData: true, + selfSharedAt: true, shareToken: true, publicShareApprovedAt: true, + qr: { select: { title: true } }, + posts: { select: { channel: true, status: true, postUrl: true, error: true, postedAt: true } }, + }, + }); + + return NextResponse.json({ + milestones: milestones.map(milestone => { + const card = milestone.cardData as { totalUniqueScans?: number } | null; + return { + id: milestone.id, + qrTitle: milestone.qr.title, + uniqueScans: card?.totalUniqueScans || milestoneThreshold(milestone.kind) || 0, + detectedAt: milestone.detectedAt.toISOString(), + promptStatus: milestone.status, + selfSharedAt: milestone.selfSharedAt?.toISOString() || null, + posts: milestone.posts.map(post => ({ + channel: post.channel, + status: post.status, + postUrl: post.postUrl, + error: post.error, + postedAt: post.postedAt?.toISOString() || null, + })), + shareUrl: milestone.shareToken && milestone.publicShareApprovedAt + ? `${getWwwOrigin()}/s/m/${milestone.shareToken}` + : null, + }; + }), + }); +} diff --git a/src/app/(main)/api/social-milestones/preferences/route.ts b/src/app/(main)/api/social-milestones/preferences/route.ts index a6744c2..1480785 100644 --- a/src/app/(main)/api/social-milestones/preferences/route.ts +++ b/src/app/(main)/api/social-milestones/preferences/route.ts @@ -37,16 +37,19 @@ export async function PATCH(request: NextRequest) { }); await db.$transaction([ db.user.update({ where: { id: userId }, data: { socialPromptOptOut: false } }), - ...(latest ? [db.socialMilestone.update({ - where: { id: latest.id }, - data: { - status: 'detected', shownAt: null, respondedAt: null, - brandStatus: 'pending', brandApprovedAt: null, claimedAt: null, - brandPostedAt: null, brandPostUrl: null, brandPostError: null, - consentText: null, withName: false, - selfSharedAt: null, publicShareApprovedAt: null, shareToken: null, - }, - })] : []), + // Dropping the per-channel approvals is what makes the reset complete: + // no row means no consent, which is exactly the pre-prompt state. + ...(latest ? [ + db.socialMilestonePost.deleteMany({ where: { milestoneId: latest.id } }), + db.socialMilestone.update({ + where: { id: latest.id }, + data: { + status: 'detected', shownAt: null, respondedAt: null, + consentText: null, withName: false, + selfSharedAt: null, publicShareApprovedAt: null, shareToken: null, + }, + }), + ] : []), ]); return NextResponse.json({ ok: true, promptsEnabled: true, resetMilestone: Boolean(latest) }); } diff --git a/src/app/(main)/api/social-milestones/route.ts b/src/app/(main)/api/social-milestones/route.ts index 2102eb0..1aac316 100644 --- a/src/app/(main)/api/social-milestones/route.ts +++ b/src/app/(main)/api/social-milestones/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getWwwOrigin } from '@/lib/hosts'; import { getSessionUserId } from '@/lib/session'; -import { buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones'; +import { getEnabledSocialChannels, milestonePostParts, milestoneThreshold, SOCIAL_CHANNELS, socialLocale } from '@/lib/social-milestones'; import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server'; export const dynamic = 'force-dynamic'; @@ -14,7 +14,7 @@ export async function GET(request: NextRequest) { 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 }, + where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, instagramHandle: true, primaryUseCase: true }, }); if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null }); @@ -49,23 +49,34 @@ export async function GET(request: NextRequest) { }); if (!delivered.count) return NextResponse.json({ milestone: null }); + // One entry per channel: its own text, its own handle. The dialog recombines + // head + mention + tail while the customer types, so what is on screen is + // exactly what the server will store as the consent text. `available` marks + // the channels a publisher is configured for - the others are still listed + // because sharing them yourself works without any publisher. + const enabled = getEnabledSocialChannels(); + const channels = SOCIAL_CHANNELS.map(channel => ({ + channel, + available: enabled.includes(channel), + defaultHandle: (channel === 'instagram' ? user.instagramHandle : user.xHandle) || '', + ...milestonePostParts({ + channel, + primaryUseCase: user.primaryUseCase, + totalUniqueScans: card.totalUniqueScans || threshold, + locale, + qrTitle: milestone.qr.title, + shareUrl, + }), + })); + return NextResponse.json({ milestone: { id: milestone.id, qrTitle: milestone.qr.title, threshold, - defaultXHandle: user.xHandle, - brandStatus: milestone.brandStatus, promptStatus: 'shown', - brandPostUrl: milestone.brandPostUrl, - brandPostError: milestone.brandPostError, language: locale, shareUrl, - preview: buildMilestonePostForQr( - user.primaryUseCase, - card.totalUniqueScans || threshold, - null, - locale, - milestone.qr.title, - ), + channels, + posts: [], card, }, }); diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 071e409..31b8337 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -1,16 +1,21 @@ 'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { Check, Copy, ExternalLink, LineChart, Linkedin, QrCode, Send, X } from 'lucide-react'; +import { Check, Copy, ExternalLink, Instagram, LineChart, Linkedin, QrCode, Send, 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 Channel = 'x' | 'instagram'; type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null }; -type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; shareUrl: string; language: 'en' | 'de'; card: Card; promptStatus: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null }; -type BrandState = { promptStatus: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null }; +type ChannelOption = { channel: Channel; available: boolean; defaultHandle: string; head: string; tail: string; mentionWord: string }; +type PostState = { channel: string; status: string; postUrl: string | null; error: string | null }; +type Milestone = { id: string; qrTitle: string; threshold: number; shareUrl: string; language: 'en' | 'de'; card: Card; promptStatus: string; channels: ChannelOption[]; posts: PostState[] }; +type BrandState = { promptStatus: string; selfSharedAt: string | null; posts: PostState[] }; + +const CHANNEL_LABELS: Record = { x: 'X', instagram: 'Instagram' }; function Trend({ trend, locale }: { trend: NonNullable; locale: 'en' | 'de' }) { const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25; @@ -61,8 +66,9 @@ export function SocialMilestoneDialog() { const { locale } = useTranslation(); const [milestone, setMilestone] = useState(null); const [withName, setWithName] = useState(false); - const [xHandle, setXHandle] = useState(''); - const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | null>(null); + const [handles, setHandles] = useState>({}); + const [selected, setSelected] = useState([]); + const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | 'instagram' | null>(null); const [brand, setBrand] = useState(null); const loadedMilestone = useRef(false); @@ -73,13 +79,23 @@ export function SocialMilestoneDialog() { if (response.ok) { const next = (await response.json()).milestone as Milestone | null; setMilestone(next); - if (next) setBrand({ promptStatus: next.promptStatus, brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null }); + if (next) setBrand({ promptStatus: next.promptStatus, selfSharedAt: null, posts: next.posts || [] }); } }).catch(() => undefined); }, [locale]); - useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]); useEffect(() => { - if (!milestone || !['approved', 'processing'].includes(brand?.brandStatus || '')) return; + if (!milestone) return; + setHandles(Object.fromEntries(milestone.channels.map(option => [option.channel, option.defaultHandle]))); + // Instagram stays unticked on purpose: consent for one channel is not + // consent for the next, so the second one has to be an actual decision. + setSelected(milestone.channels.filter(option => option.available && option.channel === 'x').map(option => option.channel)); + }, [milestone]); + + const posts = brand?.posts || []; + const postFor = (channel: Channel) => posts.find(post => post.channel === channel); + const pending = posts.some(post => ['approved', 'processing'].includes(post.status)); + useEffect(() => { + if (!milestone || !pending) return; const poll = async () => { const response = await fetch(`/api/social-milestones/${milestone.id}`); if (response.ok) setBrand((await response.json()).milestone); @@ -87,20 +103,41 @@ export function SocialMilestoneDialog() { const timer = window.setInterval(poll, 3000); void poll(); return () => window.clearInterval(timer); - }, [milestone, brand?.brandStatus]); + }, [milestone, pending]); - 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: 'Waiting for the X publisher …', posted: 'Published on X', failed: 'Publishing failed' }; - const postCopy = useMemo(() => { - if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || ''; - const mention = milestone.language === 'de' ? 'Glückwunsch' : 'Congratulations'; - return `${milestone.preview}\n\n${mention} @${xHandle.trim().replace(/^@/, '')}.`; - }, [milestone, withName, xHandle]); - const preview = milestone ? `${postCopy}\n\n${milestone.shareUrl}` : ''; - const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => { + const german = milestone?.language === 'de'; + const copy = german + ? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf den eigenen Kanälen veröffentlichen?', name: 'Meinen Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Von QR Master posten', queued: 'Wird veröffentlicht …', posted: 'Veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' } + : { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success on its own channels?', name: 'Mention my handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Publishing …', posted: 'Published', failed: 'Publishing failed' }; + + const optionFor = (channel: Channel) => milestone?.channels.find(option => option.channel === channel); + const mentionOf = (channel: Channel) => { + const option = optionFor(channel); + const handle = (handles[channel] || '').trim().replace(/^@/, ''); + return option && withName && handle ? `\n\n${option.mentionWord} @${handle}.` : ''; + }; + /** Without the channel suffix - used where the share URL is added by hand. */ + const composeBody = (channel: Channel) => { + const option = optionFor(channel); + return option ? `${option.head}${mentionOf(channel)}` : ''; + }; + // Head + mention + tail is exactly how the server assembles the consent text. + const compose = (channel: Channel) => { + const option = optionFor(channel); + return option ? `${composeBody(channel)}${option.tail}` : ''; + }; + const previews = useMemo( + () => selected.map(channel => ({ channel, text: compose(channel) })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selected, handles, withName, milestone], + ); + + const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out', extra?: Record) => { 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 response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { + method: 'PATCH', + body: JSON.stringify({ action, withName, handles, channels: selected, language: milestone.language, ...extra }), + }); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Could not save your choice'); return result; @@ -109,15 +146,22 @@ export function SocialMilestoneDialog() { const result = await update('self_share'); const shareUrl = `${result.shareUrl}?v=${result.shareVersion}`; setBrand(result.milestone); - return { shareUrl, text: `${postCopy}\n\n${shareUrl}` }; + return { + shareUrl, + // 4:5 is the tallest ratio Instagram accepts and the one that keeps the + // numbers readable in a phone feed. + imageUrl: `${result.shareUrl}/og?format=portrait&v=${result.shareVersion}`, + text: `${composeBody('x')}\n\n${shareUrl}`, + }; }; const shareSelf = async (network: 'x' | 'linkedin') => { if (!milestone) return; // LinkedIn's public share dialog accepts only a URL. Start copying the // prepared commentary while this click still owns browser focus, then // open the LinkedIn share dialog after public-share consent is persisted. + const commentary = composeBody('x'); const linkedinCopy = network === 'linkedin' - ? copyShareText(postCopy).then(() => true).catch(() => false) + ? copyShareText(commentary).then(() => true).catch(() => false) : Promise.resolve(true); // Open synchronously from the user gesture. Awaiting the API first can make // LinkedIn treat the new window as a blocked popup. @@ -134,18 +178,58 @@ export function SocialMilestoneDialog() { else window.location.assign(targetUrl); showToast(network === 'linkedin' ? copied - ? (milestone.language === 'de' ? 'LinkedIn-Text kopiert. Jetzt nur noch einfügen.' : 'LinkedIn text copied. Paste it into the composer.') - : (milestone.language === 'de' ? 'LinkedIn ist geöffnet. Bitte nutze „Text kopieren“ im QR-Master-Fenster.' : 'LinkedIn is open. Please use “Copy text” in the QR Master window.') + ? (german ? 'LinkedIn-Text kopiert. Jetzt nur noch einfügen.' : 'LinkedIn text copied. Paste it into the composer.') + : (german ? 'LinkedIn ist geöffnet. Bitte nutze „Text kopieren“ im QR-Master-Fenster.' : 'LinkedIn is open. Please use “Copy text” in the QR Master window.') : 'X share composer opened.', copied ? 'success' : 'error'); } catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } finally { setSaving(null); } }; + // Instagram has no composer a website can prefill: there is no intent URL, + // and the story deep links need a native pasteboard the browser cannot + // reach. What is left is the system share sheet on a phone, and a download + // plus the caption in the clipboard everywhere else. + const shareInstagram = async () => { + if (!milestone) return; + setSaving('instagram'); + try { + const caption = compose('instagram'); + const { imageUrl } = await prepareSelfShare(); + const response = await fetch(imageUrl); + if (!response.ok) throw new Error(german ? 'Das Meilenstein-Bild konnte nicht geladen werden.' : 'Could not load the milestone image'); + const blob = await response.blob(); + const file = new File([blob], 'qr-master-milestone.png', { type: blob.type || 'image/png' }); + const copied = await copyShareText(caption).then(() => true).catch(() => false); + if (navigator.canShare?.({ files: [file] })) { + try { + await navigator.share({ files: [file], text: caption }); + return; + } catch (error) { + // Sheet dismissed on purpose - do not push a download nobody asked for. + if (error instanceof Error && error.name === 'AbortError') return; + } + } + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = file.name; + link.click(); + URL.revokeObjectURL(objectUrl); + showToast(copied + ? (german ? 'Bild geladen, Text kopiert. Beides in Instagram einfügen.' : 'Image downloaded, caption copied. Add both in Instagram.') + : (german ? 'Bild geladen. Bitte „Nur Text kopieren“ für die Bildunterschrift nutzen.' : 'Image downloaded. Use “Copy text only” for the caption.'), + copied ? 'success' : 'error'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); + } finally { + setSaving(null); + } + }; const copyLinkedInText = async () => { setSaving('copy'); try { const { text } = await prepareSelfShare(); await copyShareText(text); - showToast(milestone?.language === 'de' ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success'); + showToast(german ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success'); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not copy the LinkedIn text', 'error'); } finally { setSaving(null); } @@ -163,43 +247,85 @@ export function SocialMilestoneDialog() { try { await update(action); setMilestone(null); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); } }; const optOut = () => { - const message = milestone?.language === 'de' + const message = german ? 'Meilenstein-Hinweise dauerhaft ausblenden? Du kannst sie später in den Einstellungen wieder aktivieren.' : 'Turn off milestone prompts? You can enable them again later in Settings.'; if (window.confirm(message)) void dismiss('opt_out'); }; + const toggleChannel = (channel: Channel) => { + setSelected(current => current.includes(channel) ? current.filter(entry => entry !== channel) : [...current, channel]); + }; if (!milestone) return null; const card = milestone.card; const count = card.totalUniqueScans || milestone.threshold; - const status = brand?.brandStatus || milestone.brandStatus || 'pending'; const promptStatus = brand?.promptStatus || milestone.promptStatus; - const canApprove = ['pending', 'failed', 'revoked'].includes(status); + const brandChannels = milestone.channels.filter(option => option.available); + // A channel that is already published or in flight cannot be re-approved. + const canApprove = selected.length > 0 && !selected.some(channel => ['processing', 'posted'].includes(postFor(channel)?.status || '')); return !open && setMilestone(null)} containerClassName="max-w-[960px]">
{copy.heading}{milestone.qrTitle} {copy.subtitle}
- +
QR MASTERVerified scan milestone
-
UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}
{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
-
{card.trend ? :
{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
- {card.trend &&
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
} +
UNIQUE SCANS
{count.toLocaleString(german ? 'de-DE' : 'en-US')}
{german ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}
{(card.totalScans || count).toLocaleString(german ? 'de-DE' : 'en-US')}
+
{card.trend ? :
{german ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
+ {card.trend &&
{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
}
{card.qrTitle}
-

{copy.consent}

{preview}
-
{withName && setXHandle(event.target.value)} disabled={saving !== null} maxLength={16} pattern="@?[A-Za-z0-9_]{1,15}" 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" />}
-
{copy.self}

{milestone.language === 'de' ? 'LinkedIn öffnet den Editor und kopiert den fertigen Text automatisch.' : 'LinkedIn opens the composer and copies the finished text automatically.'}

- {status !== 'pending' &&
{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}{brand?.brandPostUrl && View}
} +
+

{copy.consent}

+
+ {brandChannels.map(option => ( + + ))} +
+ {previews.length === 0 + ?

{german ? 'Kein Kanal ausgewählt – QR Master veröffentlicht nichts.' : 'No channel selected – QR Master publishes nothing.'}

+ : previews.map(preview => ( +
+
{CHANNEL_LABELS[preview.channel]}
+
{preview.text}
+
+ ))} +
+
+ + {withName && selected.map(channel => ( + setHandles(current => ({ ...current, [channel]: event.target.value }))} + disabled={saving !== null} + maxLength={channel === 'instagram' ? 31 : 16} + placeholder={channel === 'instagram' ? '@your.instagram' : '@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" + /> + ))} +
+
{copy.self}

{german ? 'LinkedIn öffnet den Editor und kopiert den fertigen Text automatisch. Instagram lässt sich nicht vorbefüllen: am Handy öffnet das Teilen-Menü, sonst wird das Bild geladen und der Text kopiert.' : 'LinkedIn opens the composer and copies the finished text automatically. Instagram cannot be prefilled: on a phone the share sheet opens, otherwise the image is downloaded and the caption copied.'}

+ {posts.filter(post => post.status !== 'revoked').map(post => ( +
+ {CHANNEL_LABELS[post.channel as Channel] || post.channel}: {post.status === 'posted' ? copy.posted : post.status === 'failed' ? `${copy.failed}${post.error ? ` – ${post.error}` : ''}` : copy.queued} + {post.postUrl && View} +
+ ))}
-
{promptStatus === 'shown' && }
+
{promptStatus === 'shown' && }
; } diff --git a/src/lib/social-milestone-image.tsx b/src/lib/social-milestone-image.tsx index b5da71f..28cac26 100644 --- a/src/lib/social-milestone-image.tsx +++ b/src/lib/social-milestone-image.tsx @@ -16,7 +16,26 @@ export type SocialMilestoneImageCard = { trend?: Trend | null; }; -function chart(card: SocialMilestoneImageCard, german: boolean) { +/** + * Link previews are 1.91:1, Instagram wants 1:1 or 4:5 and rejects anything + * outside that window. Same card, three canvases - never a cropped variant, + * because the numbers must stay legible. + */ +export type SocialMilestoneImageFormat = 'landscape' | 'square' | 'portrait'; + +const FORMATS: Record = { + landscape: { width: 1200, height: 630, stacked: false, chart: { width: 650, height: 285 } }, + square: { width: 1080, height: 1080, stacked: true, chart: { width: 956, height: 470 } }, + portrait: { width: 1080, height: 1350, stacked: true, chart: { width: 956, height: 720 } }, +}; + +export function socialMilestoneImageFormat(value?: string | null): SocialMilestoneImageFormat { + return value === 'square' || value === 'portrait' ? value : 'landscape'; +} + +function chart(card: SocialMilestoneImageCard, german: boolean, box: { width: number; height: number }) { const target = Math.max(1, Number(card.totalUniqueScans || card.milestoneThreshold || 1)); const trend = card.trend; const rawPoints = Array.isArray(trend?.points) ? trend.points : []; @@ -30,9 +49,10 @@ function chart(card: SocialMilestoneImageCard, german: boolean) { const first = timestamps.length ? Math.min(...timestamps) : 0; const last = Math.max(timestamps.length ? Math.max(...timestamps) : first, first + 1); const plotLeft = 72; - const plotRight = 620; + const plotRight = box.width - 30; const plotTop = 12; - const plotBottom = 218; + // Leaves room for the two date labels and the caption below the plot. + const plotBottom = box.height - 67; const points = rawPoints.map(point => { const time = new Date(point.at).getTime(); const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft); @@ -44,20 +64,20 @@ function chart(card: SocialMilestoneImageCard, german: boolean) { // Satori cannot render SVG nodes in the deployed Node runtime. SVG // draws geometry only; the aligned labels are ordinary positioned text. - return
+ return
{ticks.map(tick => { const y = plotBottom - tick / ceiling * (plotBottom - plotTop); const reached = tick === target; - return
-
{number.format(tick)}
-
+ return
+
{number.format(tick)}
+
; })} - + {points && } -
+
{trend.startLabel || (german ? 'Erstellt' : 'Created')} {trend.endLabel || (german ? 'Erreicht' : 'Reached')}
@@ -65,7 +85,12 @@ function chart(card: SocialMilestoneImageCard, german: boolean) {
; } -export function createSocialMilestoneImage(card: SocialMilestoneImageCard, german: boolean) { +export function createSocialMilestoneImage( + card: SocialMilestoneImageCard, + german: boolean, + format: SocialMilestoneImageFormat = 'landscape', +) { + const canvas = FORMATS[format]; const unique = Math.max(0, Number(card.totalUniqueScans || card.milestoneThreshold || 0)); const total = Math.max(unique, Number(card.totalScans || unique)); const locale = german ? 'de-DE' : 'en-US'; @@ -79,17 +104,17 @@ export function createSocialMilestoneImage(card: SocialMilestoneImageCard, germa QR MASTER {german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'}
-
-
+
+
UNIQUE SCANS {uniqueText} {total.toLocaleString(locale)} {german ? 'Scans insgesamt' : 'total scans'}
- {chart(card, german)} + {chart(card, german, canvas.chart)}
{card.qrTitle || (german ? 'QR-Code' : 'QR code')}
, - { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } }, + { width: canvas.width, height: canvas.height, headers: { 'Cache-Control': 'no-store' } }, ); } diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts index a50b5da..2026166 100644 --- a/src/lib/social-milestones-server.ts +++ b/src/lib/social-milestones-server.ts @@ -9,6 +9,7 @@ function excludedEmails() { /** Creates any newly crossed milestones. Safe to call repeatedly. */ export async function detectSocialMilestones(qrId?: string) { const excluded = excludedEmails(); + const thresholds = getSocialMilestoneThresholds(); const candidates = await db.qRScan.groupBy({ by: ['qrId'], where: { @@ -18,11 +19,40 @@ export async function detectSocialMilestones(qrId?: string) { }, _count: { _all: true }, }); - const records = candidates.flatMap(({ qrId: candidateQrId, _count }) => - getSocialMilestoneThresholds() - .filter(threshold => _count._all >= threshold) - .map(threshold => ({ qrId: candidateQrId, kind: milestoneKind(threshold) })) - ); + const crossedByQr = new Map(); + candidates.forEach(({ qrId: candidateQrId, _count }) => { + const crossed = thresholds.filter(threshold => _count._all >= threshold); + if (crossed.length) crossedByQr.set(candidateQrId, crossed); + }); + if (!crossedByQr.size) return 0; + + // Card snapshots read a QR code's complete scan history, so the milestones + // that already exist are filtered out before any of that work begins. This + // runs after every unique scan; without the check, each scan past the + // threshold would re-read every scan row only to hit `skipDuplicates`. + const known = await db.socialMilestone.findMany({ + where: { qrId: { in: Array.from(crossedByQr.keys()) } }, + select: { qrId: true, kind: true }, + }); + const knownByQr = new Map>(); + known.forEach(milestone => { + const kinds = knownByQr.get(milestone.qrId) || new Set(); + kinds.add(milestone.kind); + knownByQr.set(milestone.qrId, kinds); + }); + + const records: Array<{ qrId: string; kind: string }> = []; + crossedByQr.forEach((crossed, candidateQrId) => { + const seen = knownByQr.get(candidateQrId); + // A QR code seen for the first time may already be past several + // thresholds. Announce the highest one only: the post quotes the current + // scan count rather than the threshold, so the lower ones would produce a + // second prompt and a second brand post with the very same number in it. + const pending = seen + ? crossed.filter(threshold => !seen.has(milestoneKind(threshold))) + : crossed.slice(-1); + pending.forEach(threshold => records.push({ qrId: candidateQrId, kind: milestoneKind(threshold) })); + }); if (!records.length) return 0; const qrs = await db.qRCode.findMany({ diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts index d0dcb02..7b1c8f4 100644 --- a/src/lib/social-milestones.ts +++ b/src/lib/social-milestones.ts @@ -20,6 +20,18 @@ export function getSocialMilestoneThresholds(): number[] { return thresholds.length ? thresholds : [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS]; } +/** + * Channels the consent dialog may offer. A channel is only offered where a + * publisher is actually configured - asking for consent for a post that nobody + * can publish would be dishonest. Server-side read; the dialog receives the + * resulting list in its payload. + */ +export function getEnabledSocialChannels(): SocialChannel[] { + const configured = (process.env.SOCIAL_MILESTONE_CHANNELS || 'x') + .split(',').map(value => value.trim().toLowerCase()).filter(isSocialChannel); + return configured.length ? Array.from(new Set(configured)) : ['x']; +} + const useCaseLabels: Record = { menu_pdf: 'menu QR code', marketing_campaign: 'campaign QR code', @@ -141,3 +153,60 @@ export function normalizeXHandle(value: string): string | null { const handle = value.trim().replace(/^@/, ''); return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null; } + +export function normalizeInstagramHandle(value: string): string | null { + const handle = value.trim().replace(/^@/, ''); + return /^[A-Za-z0-9._]{1,30}$/.test(handle) ? handle : null; +} + +/** + * Publishing channels of the QR Master brand accounts. + * + * Consent is bound to a channel: agreeing to a post on X says nothing about + * Instagram. Every channel therefore carries its own approval, its own text and + * its own handle. + */ +export const SOCIAL_CHANNELS = ['x', 'instagram'] as const; +export type SocialChannel = typeof SOCIAL_CHANNELS[number]; + +export function isSocialChannel(value: unknown): value is SocialChannel { + return typeof value === 'string' && (SOCIAL_CHANNELS as readonly string[]).includes(value); +} + +export function normalizeChannelHandle(channel: SocialChannel, value: string): string | null { + return channel === 'instagram' ? normalizeInstagramHandle(value) : normalizeXHandle(value); +} + +function instagramHashtags(locale: SocialLocale): string { + return locale === 'de' + ? '#qrcode #qrcodes #marketing #kleinunternehmen #analytics #digitalisierung' + : '#qrcode #qrcodes #qrcodemarketing #smallbusiness #marketing #analytics'; +} + +/** + * The post split into the parts the consent dialog recombines while the + * customer types a handle. Server and client must never build this text + * differently - what stands in the preview is what gets published. + */ +export function milestonePostParts(input: { + channel: SocialChannel; + primaryUseCase: string | null; + totalUniqueScans: number; + locale: SocialLocale; + qrTitle: string; + shareUrl: string; +}) { + return { + head: buildMilestonePostForQr(input.primaryUseCase, input.totalUniqueScans, null, input.locale, input.qrTitle), + // A link in an Instagram caption is not clickable, so the share URL would + // be dead weight there. Hashtags do the reach work instead. + tail: input.channel === 'instagram' ? `\n\n${instagramHashtags(input.locale)}` : `\n\n${input.shareUrl}`, + mentionWord: input.locale === 'de' ? 'Glückwunsch' : 'Congratulations', + }; +} + +export function buildChannelPost(input: Parameters[0] & { handle: string | null }): string { + const { head, tail, mentionWord } = milestonePostParts(input); + const mention = input.handle ? `\n\n${mentionWord} @${input.handle.replace(/^@/, '')}.` : ''; + return `${head}${mention}${tail}`; +} From b278d275bb5260fb59885e568fda998b1c5eb262 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Mon, 17 Aug 2026 11:19:41 +0200 Subject: [PATCH 19/19] Polish social milestone chart curves --- .../dashboard/SocialMilestoneDialog.tsx | 14 +++--- src/lib/rounded-chart-path.ts | 50 +++++++++++++++++++ src/lib/social-milestone-image.tsx | 14 +++--- 3 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 src/lib/rounded-chart-path.ts diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 31b8337..6922506 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/Button'; import { useCsrf } from '@/hooks/useCsrf'; import { useTranslation } from '@/hooks/useTranslation'; import { showToast } from '@/components/ui/Toast'; +import { roundedChartPath } from '@/lib/rounded-chart-path'; type Channel = 'x' | 'instagram'; type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null }; @@ -24,20 +25,21 @@ function Trend({ trend, locale }: { trend: NonNullable; locale: ' : Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4); const first = new Date(trend.points[0].at).getTime(); const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1); - const points = trend.points.map(point => { + const chartPoints = trend.points.map(point => { const x = 60 + ((new Date(point.at).getTime() - first) / (last - first)) * 410; const y = 142 - (point.total / ceiling) * 126; - return `${x},${y}`; - }).join(' '); - const targetY = 142 - (trend.target / ceiling) * 126; + return { x, y }; + }); + const path = roundedChartPath(chartPoints, 18); + const endPoint = chartPoints[chartPoints.length - 1] || { x: 470, y: 142 }; const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); return {ticks.map(tick => { const y = 142 - (tick / ceiling) * 126; return {number.format(tick)}; })} - - + + {trend.startLabel} {trend.endLabel} ; diff --git a/src/lib/rounded-chart-path.ts b/src/lib/rounded-chart-path.ts new file mode 100644 index 0000000..ae50e76 --- /dev/null +++ b/src/lib/rounded-chart-path.ts @@ -0,0 +1,50 @@ +export type ChartPoint = { x: number; y: number }; + +function distance(a: ChartPoint, b: ChartPoint) { + return Math.hypot(b.x - a.x, b.y - a.y); +} + +function pointTowards(from: ChartPoint, to: ChartPoint, amount: number): ChartPoint { + const length = distance(from, to); + if (length === 0) return from; + + return { + x: from.x + ((to.x - from.x) / length) * amount, + y: from.y + ((to.y - from.y) / length) * amount, + }; +} + +function coordinate(value: number) { + return Number(value.toFixed(2)); +} + +/** + * Turns the factual scan points into one continuous SVG path while rounding + * only the visual corners. Source values and timestamps stay untouched; the + * path merely eases into and out of each factual turning point. + */ +export function roundedChartPath(points: ChartPoint[], cornerRadius: number): string { + if (points.length === 0) return ''; + if (points.length === 1) return `M ${coordinate(points[0].x)} ${coordinate(points[0].y)}`; + + let path = `M ${coordinate(points[0].x)} ${coordinate(points[0].y)}`; + + for (let index = 1; index < points.length - 1; index += 1) { + const previous = points[index - 1]; + const current = points[index]; + const next = points[index + 1]; + const radius = Math.min( + cornerRadius, + distance(previous, current) / 2, + distance(current, next) / 2, + ); + const before = pointTowards(current, previous, radius); + const after = pointTowards(current, next, radius); + + path += ` L ${coordinate(before.x)} ${coordinate(before.y)}`; + path += ` Q ${coordinate(current.x)} ${coordinate(current.y)} ${coordinate(after.x)} ${coordinate(after.y)}`; + } + + const last = points[points.length - 1]; + return `${path} L ${coordinate(last.x)} ${coordinate(last.y)}`; +} diff --git a/src/lib/social-milestone-image.tsx b/src/lib/social-milestone-image.tsx index 28cac26..0bece15 100644 --- a/src/lib/social-milestone-image.tsx +++ b/src/lib/social-milestone-image.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { ImageResponse } from 'next/og'; +import { roundedChartPath } from '@/lib/rounded-chart-path'; type Trend = { points: Array<{ at: string; total: number }>; @@ -53,14 +54,15 @@ function chart(card: SocialMilestoneImageCard, german: boolean, box: { width: nu const plotTop = 12; // Leaves room for the two date labels and the caption below the plot. const plotBottom = box.height - 67; - const points = rawPoints.map(point => { + const chartPoints = rawPoints.map(point => { const time = new Date(point.at).getTime(); const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft); const y = plotBottom - Math.min(Math.max(Number(point.total) || 0, 0), ceiling) / ceiling * (plotBottom - plotTop); - return `${x},${y}`; - }).join(' '); + return { x, y }; + }); + const path = roundedChartPath(chartPoints, 30); const number = new Intl.NumberFormat(german ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); - const endPoint = points.split(' ').at(-1)?.split(',').map(Number) || [plotRight, plotBottom]; + const endPoint = chartPoints[chartPoints.length - 1] || { x: plotRight, y: plotBottom }; // Satori cannot render SVG nodes in the deployed Node runtime. SVG // draws geometry only; the aligned labels are ordinary positioned text. @@ -74,8 +76,8 @@ function chart(card: SocialMilestoneImageCard, german: boolean, box: { width: nu
; })} - - {points && } + + {path && }
{trend.startLabel || (german ? 'Erstellt' : 'Created')}