9 Commits

Author SHA1 Message Date
925540f3c6 Improve social milestone sharing flow 2026-08-14 12:31:10 +02:00
e0c32542f9 Detect social milestones when scans arrive 2026-08-14 11:48:34 +02:00
6081b9e6ae refactor: derive email sender address dynamically from SMTP_USER 2026-08-14 13:03:12 +02:00
9d1d3a2062 SMTP_USER 2026-08-14 13:02:59 +02:00
72392e8cec info instead of timo 2026-08-14 13:02:47 +02:00
f7d82aa5bd Add consented social milestone posting 2026-08-14 09:03:21 +02:00
14c429ff30 fix 2026-08-13 10:07:57 -05:00
d2c5f2848a network 0.0.0.0 2026-08-13 08:32:11 -05:00
31cba6d883 Anpassungen 2026-08-13 06:10:01 -05:00
20 changed files with 940 additions and 29 deletions

View File

@@ -38,6 +38,17 @@ services:
# April 2026 and the schema has moved on through manual SQL since, so running them # April 2026 and the schema has moved on through manual SQL since, so running them
# against a fresh database would build a stale schema the app cannot work with. # against a fresh database would build a stale schema the app cannot work with.
# Bring the schema in with `pg_dump --schema-only` from production instead. # Bring the schema in with `pg_dump --schema-only` from production instead.
environment:
# Docker sets HOSTNAME=<container-id>, and the Next.js standalone server binds to
# that single interface. With two networks Caddy then cannot reach the container.
HOSTNAME: "0.0.0.0"
# `db` and `redis` are taken in BOTH networks - by this stack in test-internal and
# by production in qrmaster-network. Production wins the lookup every time, so the
# base file's hostnames point the staging app at the production instances. Container
# names are unique per daemon and cannot be shadowed.
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
REDIS_URL: redis://qrmaster-test-redis:6379
entrypoint: ["node", "server.js"] entrypoint: ["node", "server.js"]
build: build:
args: args:

View File

@@ -39,7 +39,7 @@ services:
- qrmaster-network - qrmaster-network
# Next.js Application # Next.js Application
web: web:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -62,6 +62,9 @@ services:
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-} AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
CRON_SECRET: ${CRON_SECRET:-}
SOCIAL_MILESTONE_THRESHOLDS: ${SOCIAL_MILESTONE_THRESHOLDS:-}
SOCIAL_MILESTONE_POST_DELAY_HOURS: ${SOCIAL_MILESTONE_POST_DELAY_HOURS:-}
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback} TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
@@ -111,8 +114,27 @@ services:
interval: 10s interval: 10s
timeout: 3s timeout: 3s
retries: 10 retries: 10
networks: networks:
- qrmaster-network - qrmaster-network
social-worker:
build:
context: ./scripts/social-worker
restart: unless-stopped
environment:
QRMASTER_API_BASE: http://web:3000
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
SOCIAL_MILESTONE_POSTING_ENABLED: ${SOCIAL_MILESTONE_POSTING_ENABLED:-false}
SOCIAL_WORKER_INTERVAL_SECONDS: ${SOCIAL_WORKER_INTERVAL_SECONDS:-10}
X_API_KEY: ${X_API_KEY:-}
X_API_SECRET: ${X_API_SECRET:-}
X_ACCESS_TOKEN: ${X_ACCESS_TOKEN:-}
X_ACCESS_TOKEN_SECRET: ${X_ACCESS_TOKEN_SECRET:-}
depends_on:
web:
condition: service_started
networks:
- qrmaster-network
# Adminer - Database Management UI (Optional) # Adminer - Database Management UI (Optional)

54
docker/init-db.sh Normal file → Executable file
View File

@@ -1,26 +1,28 @@
#!/bin/bash #!/bin/bash
set -e set -e
# This script runs when the PostgreSQL container is first created # This script runs when the PostgreSQL container is first created
# It ensures the database is properly initialized # It ensures the database is properly initialized
#
echo "🚀 Initializing QR Master database..." # 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
# Create the database if it doesn't exist (already created by POSTGRES_DB) # the init, and the container never becomes healthy.
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL # Must stay LF-only and executable - Postgres sources non-executable init
-- Enable required extensions # scripts, and CRLF breaks them on the first line.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; echo "🚀 Initializing QR Master database..."
-- Grant privileges # The database itself is already created by POSTGRES_DB
GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres; psql -v ON_ERROR_STOP=1 -v dbname="$POSTGRES_DB" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Enable required extensions
-- Set timezone CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
ALTER DATABASE qrmaster SET timezone TO 'UTC'; CREATE EXTENSION IF NOT EXISTS "pg_trgm";
EOSQL
-- Set timezone
echo "✅ Database initialization complete!" ALTER DATABASE :"dbname" SET timezone TO 'UTC';
echo "📊 Database: $POSTGRES_DB" EOSQL
echo "👤 User: $POSTGRES_USER"
echo "🌐 Ready to accept connections on port 5432" echo "✅ Database initialization complete!"
echo "📊 Database: $POSTGRES_DB"
echo "👤 User: $POSTGRES_USER"
echo "🌐 Ready to accept connections on port 5432"

View File

@@ -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":"<milestone-id>","result":"posted"}' \
https://qrmaster.net/api/internal/social-milestones
```
Do not configure this worker against `testmodul`. LinkedIn has no approved
brand-posting integration in this project. Customers can self-share: X opens a
prefilled intent; the same text is copied for a LinkedIn post.

View File

@@ -89,6 +89,12 @@ model User {
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
lifecycleLogs UserLifecycleLog[] lifecycleLogs UserLifecycleLog[]
socialMilestones SocialMilestone[]
// Social-success sharing preferences. A post is still never published
// without a per-milestone approval stored below.
xHandle String?
socialPromptOptOut Boolean @default(false)
} }
enum Plan { enum Plan {
@@ -148,11 +154,45 @@ model QRCode {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
scans QRScan[] scans QRScan[]
socialMilestones SocialMilestone[]
@@index([userId, createdAt]) @@index([userId, createdAt])
@@index([userId, type, status]) @@index([userId, type, status])
} }
model SocialMilestone {
id String @id @default(cuid())
qrId String
userId String
kind String
status String @default("detected")
detectedAt DateTime @default(now())
shownAt DateTime?
respondedAt DateTime?
claimedAt DateTime?
postedAt DateTime?
withName Boolean @default(false)
consentText String?
language String @default("en")
cardData Json?
brandStatus String @default("pending")
brandApprovedAt DateTime?
brandPostedAt DateTime?
brandPostUrl String?
brandPostError String?
selfSharedAt DateTime?
shareToken String? @unique
publicShareApprovedAt DateTime?
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([qrId, kind])
@@index([status, respondedAt])
@@index([status, claimedAt])
@@index([userId, status])
}
enum QRType { enum QRType {
STATIC STATIC
DYNAMIC DYNAMIC

View File

@@ -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"]

View File

@@ -0,0 +1,3 @@
Pillow>=10
requests>=2.31
requests-oauthlib>=2.0

View File

@@ -0,0 +1,113 @@
"""Always-on QR Master X milestone worker. The web app never receives X keys."""
import json
import os
import tempfile
import time
from pathlib import Path
import requests
from PIL import Image, ImageDraw, ImageFont
from requests_oauthlib import OAuth1Session
def required(name):
value = os.getenv(name, "").strip()
if not value:
raise RuntimeError(f"Missing {name}")
return value
def api(method, url, payload=None):
response = requests.request(method, url, json=payload, headers={"Authorization": f"Bearer {required('INTERNAL_API_SECRET')}"}, timeout=30)
response.raise_for_status()
return response.json()
def font(name, size):
return ImageFont.truetype(f"/usr/share/fonts/truetype/dejavu/{name}", size)
def render_card(card):
"""Render the consented immutable scan snapshot; never invent trend data."""
image = Image.new("RGB", (1200, 630), "#f8fafc")
draw = ImageDraw.Draw(image)
navy, blue, slate, border, green = "#061b31", "#0256ff", "#64748b", "#e2e8f0", "#059669"
regular, medium, bold, display = font("DejaVuSans.ttf", 28), font("DejaVuSans-Bold.ttf", 28), font("DejaVuSans-Bold.ttf", 42), font("DejaVuSans-Bold.ttf", 116)
draw.rounded_rectangle((45, 42, 1155, 588), radius=24, fill="#ffffff", outline=border, width=2)
draw.rounded_rectangle((82, 79, 114, 111), radius=7, fill=blue)
draw.text((130, 81), "QR MASTER", font=medium, fill=navy)
draw.rounded_rectangle((932, 77, 1118, 114), radius=8, fill="#ecfdf5")
draw.text((954, 84), "Verified milestone", font=font("DejaVuSans-Bold.ttf", 17), fill=green)
draw.text((84, 158), "TOTAL UNIQUE SCANS", font=font("DejaVuSans-Bold.ttf", 18), fill="#94a3b8")
total = int(card.get("totalUniqueScans") or card.get("threshold") or 0)
draw.text((78, 184), f"{total:,}", font=display, fill=navy)
label = "eindeutige Scans" if card.get("language") == "de" else "unique scans"
draw.text((86, 325), label, font=regular, fill=slate)
trend = card.get("trend") or None
if trend and len(trend.get("series", [])) > 1:
series = trend["series"]
left, top, width, height = 84, 390, 1030, 88
max_value = max(series) or 1
points = [(left + round(index * width / (len(series) - 1)), top + height - round(value / max_value * height)) for index, value in enumerate(series)]
for y in (top, top + height // 2, top + height):
draw.line((left, y, left + width, y), fill="#edf2f7", width=2)
draw.line(points, fill=blue, width=6, joint="curve")
for x, y in (points[0], points[-1]):
draw.ellipse((x - 7, y - 7, x + 7, y + 7), fill=blue)
draw.text((84, 495), f"Last {trend.get('periodDays', 7)} days · {trend.get('recentTotal', 0)} unique scans", font=font("DejaVuSans.ttf", 18), fill=slate)
else:
draw.rounded_rectangle((84, 398, 357, 452), radius=10, fill="#eff6ff")
copy = "Erste Dynamik" if card.get("language") == "de" else "Early momentum"
draw.text((106, 411), copy, font=font("DejaVuSans-Bold.ttf", 20), fill=blue)
draw.text((84, 495), "Trend appears once enough real scan history exists." if card.get("language") != "de" else "Der Trend erscheint mit ausreichend echten Scan-Daten.", font=font("DejaVuSans.ttf", 18), fill=slate)
draw.line((84, 532, 1116, 532), fill=border, width=2)
draw.text((84, 549), card.get("qrTitle") or card.get("title") or "QR code", font=medium, fill=navy)
path = Path(tempfile.mkstemp(suffix=".png")[1])
image.save(path, "PNG", optimize=True)
return path
def post_x(text, card):
oauth = OAuth1Session(required("X_API_KEY"), client_secret=required("X_API_SECRET"), resource_owner_key=required("X_ACCESS_TOKEN"), resource_owner_secret=required("X_ACCESS_TOKEN_SECRET"))
path = render_card(card) if card else None
try:
media_id = None
if path:
with path.open("rb") as image:
upload = oauth.post("https://upload.x.com/1.1/media/upload.json", files={"media": image}, timeout=60)
upload.raise_for_status()
media_id = upload.json()["media_id_string"]
payload = {"text": text}
if media_id:
payload["media"] = {"media_ids": [media_id]}
result = oauth.post("https://api.x.com/2/tweets", json=payload, timeout=30)
result.raise_for_status()
return result.json()
finally:
if path:
path.unlink(missing_ok=True)
def run_once():
base = required("QRMASTER_API_BASE").rstrip("/") + "/api/internal/social-milestones"
milestone = api("GET", base).get("milestone")
if not milestone:
return
try:
result = post_x(milestone["text"], milestone.get("card"))
tweet_id = result.get("data", {}).get("id")
post_url = f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None
api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url})
print(json.dumps({"posted": milestone["id"], "x": result}), flush=True)
except Exception as error:
api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]})
print(f"Milestone post failed: {error}", flush=True)
if __name__ == "__main__":
interval = max(5, int(os.getenv("SOCIAL_WORKER_INTERVAL_SECONDS", "10")))
if os.getenv("SOCIAL_MILESTONE_POSTING_ENABLED", "").lower() not in {"true", "1", "yes"}:
raise RuntimeError("Set SOCIAL_MILESTONE_POSTING_ENABLED=true to run this worker")
while True:
run_once()
time.sleep(interval)

View File

@@ -0,0 +1,43 @@
-- Success-sharing milestones. Run once against the target database before
-- deploying the application version that uses this feature.
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "xHandle" TEXT;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "socialPromptOptOut" BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS "SocialMilestone" (
"id" TEXT PRIMARY KEY,
"qrId" TEXT NOT NULL REFERENCES "QRCode"("id") ON DELETE CASCADE,
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"kind" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'detected',
"detectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"shownAt" TIMESTAMP(3),
"respondedAt" TIMESTAMP(3),
"postedAt" TIMESTAMP(3),
"withName" BOOLEAN NOT NULL DEFAULT false,
"consentText" TEXT,
CONSTRAINT "SocialMilestone_qr_kind_key" UNIQUE ("qrId", "kind")
);
CREATE INDEX IF NOT EXISTS "SocialMilestone_status_respondedAt_idx"
ON "SocialMilestone" ("status", "respondedAt");
CREATE INDEX IF NOT EXISTS "SocialMilestone_userId_status_idx"
ON "SocialMilestone" ("userId", "status");
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "claimedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "language" TEXT NOT NULL DEFAULT 'en';
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "cardData" JSONB;
CREATE INDEX IF NOT EXISTS "SocialMilestone_status_claimedAt_idx"
ON "SocialMilestone" ("status", "claimedAt");
-- Version 2: independent brand and self-share state plus a consent-gated
-- unguessable URL for Open Graph previews. Execute manually once.
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandStatus" TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandApprovedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostUrl" TEXT;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostError" TEXT;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "selfSharedAt" TIMESTAMP(3);
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "shareToken" TEXT;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "publicShareApprovedAt" TIMESTAMP(3);
CREATE UNIQUE INDEX IF NOT EXISTS "SocialMilestone_shareToken_key"
ON "SocialMilestone" ("shareToken") WHERE "shareToken" IS NOT NULL;

View File

@@ -16,6 +16,7 @@ import { QrCode } from 'lucide-react';
import { trackEvent, identifyUser } from '@/components/PostHogProvider'; import { trackEvent, identifyUser } from '@/components/PostHogProvider';
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans'; import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist'; import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
import { SocialMilestoneDialog } from '@/components/dashboard/SocialMilestoneDialog';
interface QRCodeData { interface QRCodeData {
id: string; id: string;
@@ -322,6 +323,7 @@ export default function DashboardPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SocialMilestoneDialog />
{/* Header with Plan Badge */} {/* Header with Plan Badge */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -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(<div style={{ height: '100%', width: '100%', display: 'flex', background: '#f8fafc', padding: 46, color: '#061b31' }}><div style={{ display: 'flex', flexDirection: 'column', width: '100%', border: '2px solid #e2e8f0', borderRadius: 24, background: 'white', padding: 44 }}><div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 23, fontWeight: 700 }}><span>QR MASTER</span><span style={{ color: '#059669', background: '#ecfdf5', padding: '8px 14px', borderRadius: 8 }}>Verified milestone</span></div><div style={{ display: 'flex', flexDirection: 'column', marginTop: 58 }}><span style={{ fontSize: 20, color: '#94a3b8', fontWeight: 700 }}>TOTAL UNIQUE SCANS</span><span style={{ fontSize: 116, fontWeight: 700, letterSpacing: -5 }}>{(card?.totalUniqueScans || 0).toLocaleString(german ? 'de-DE' : 'en-US')}</span><span style={{ fontSize: 29, color: '#64748b' }}>{german ? 'eindeutige Scans' : 'unique scans'}</span></div><div style={{ display: 'flex', marginTop: 'auto', paddingTop: 28, borderTop: '2px solid #e2e8f0', justifyContent: 'space-between', fontSize: 25 }}><span>{card?.qrTitle || 'QR code'}</span><span style={{ color: '#0256ff' }}>{card?.trend ? `${card.trend.recentTotal} in ${card.trend.periodDays} days` : (german ? 'Erste Dynamik' : 'Early momentum')}</span></div></div></div>, { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } });
}

View File

@@ -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<Metadata> {
const share = await getShare(params.token);
if (!share) return { robots: { index: false, follow: false } };
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
const count = card?.totalUniqueScans || 0;
const title = share.language === 'de'
? `${count.toLocaleString('de-DE')} eindeutige QR-Scans erreicht`
: `${count.toLocaleString('en-US')} unique QR scans reached`;
const url = `${getWwwOrigin()}/s/m/${params.token}`;
return {
title,
description: card?.qrTitle || 'A verified QR Master scan milestone.',
robots: { index: false, follow: false },
openGraph: { type: 'website', title, description: card?.qrTitle, url, images: [`${url}/og`] },
twitter: { card: 'summary_large_image', title, description: card?.qrTitle, images: [`${url}/og`] },
};
}
export default async function SocialMilestoneSharePage({ params }: Props) {
const share = await getShare(params.token);
if (!share) notFound();
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
return <main className="min-h-screen bg-slate-50 px-6 py-20 text-center text-[#061b31]"><div className="mx-auto max-w-xl rounded-xl border border-slate-200 bg-white p-10 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.35)]"><p className="text-xs font-semibold tracking-[0.14em] text-[#0256ff]">QR MASTER · VERIFIED MILESTONE</p><h1 className="mt-5 text-5xl font-semibold tracking-[-0.05em] tabular-nums">{(card?.totalUniqueScans || 0).toLocaleString(share.language === 'de' ? 'de-DE' : 'en-US')}</h1><p className="mt-2 text-slate-500">{share.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</p><p className="mt-8 text-lg font-medium">{card?.qrTitle}</p></div></main>;
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSocialMilestones } from '@/lib/social-milestones-server';
import { getSocialMilestoneThresholds } from '@/lib/social-milestones';
export const dynamic = 'force-dynamic';
function isAuthorized(request: NextRequest) {
const secret = process.env.CRON_SECRET;
return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`;
}
// Detection only: this route never contacts customers or an external network.
export async function GET(request: NextRequest) {
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const detected = await detectSocialMilestones();
return NextResponse.json({ ok: true, detected, thresholds: getSocialMilestoneThresholds() });
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
function isAuthorized(request: NextRequest) {
const secret = process.env.INTERNAL_API_SECRET;
return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`;
}
function approvalDelayHours() {
const configured = Number(process.env.SOCIAL_MILESTONE_POST_DELAY_HOURS);
return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 0;
}
// This endpoint is intentionally a queue, not a social-media client. The
// external X worker fetches an approved payload and marks it complete only
// after its own post succeeded. The app never receives X credentials.
export async function GET(request: NextRequest) {
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const dryRun = request.nextUrl.searchParams.get('dryRun') === 'true';
const now = Date.now();
const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000);
const milestone = await db.socialMilestone.findFirst({
where: { brandStatus: 'approved', brandApprovedAt: { lte: approvalNotBefore } },
orderBy: { brandApprovedAt: 'asc' },
include: { user: { select: { id: true } }, qr: { select: { id: true, status: true } } },
});
// Relations are required by the schema. This guard makes the intended
// revalidation explicit if retention policies are changed later.
if (!milestone || !milestone.user || !milestone.qr || milestone.qr.status !== 'ACTIVE') {
return NextResponse.json({ milestone: null });
}
if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText }, dryRun: true });
const claimed = await db.$transaction(async (tx) => {
await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)');
const result = await tx.socialMilestone.updateMany({
where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() },
});
return result.count;
});
if (!claimed) return NextResponse.json({ milestone: null, reason: 'claimed' });
return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, card: milestone.cardData } });
}
export async function PATCH(request: NextRequest) {
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json().catch(() => null) as { id?: string; result?: 'posted' | 'failed'; postUrl?: string; error?: string } | null;
if (!body?.id || !['posted', 'failed'].includes(body.result || '')) return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
const updated = await db.socialMilestone.updateMany({
where: { id: body.id, brandStatus: 'processing' },
data: {
brandStatus: body.result!,
brandPostedAt: body.result === 'posted' ? new Date() : null,
brandPostUrl: body.result === 'posted' ? body.postUrl || null : null,
brandPostError: body.result === 'failed' ? (body.error || 'The post could not be published.') : null,
},
});
if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,110 @@
import { randomUUID } from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { getSessionUserId } from '@/lib/session';
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke';
async function ownedMilestone(id: string, userId: string) {
return db.socialMilestone.findFirst({
where: { id, userId },
include: { user: { select: { primaryUseCase: true } }, qr: { select: { title: true } } },
});
}
function clientState(milestone: { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) {
return {
brandStatus: milestone.brandStatus,
brandPostUrl: milestone.brandPostUrl,
brandPostError: milestone.brandPostError,
selfSharedAt: milestone.selfSharedAt?.toISOString() || null,
};
}
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
const userId = getSessionUserId();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const milestone = await ownedMilestone(params.id, userId);
if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ milestone: clientState(milestone) });
}
export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
const csrf = csrfProtection(request);
if (!csrf.valid) return NextResponse.json({ error: csrf.error }, { status: 403 });
const userId = getSessionUserId();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json().catch(() => null) as { action?: Action; withName?: boolean; xHandle?: string; language?: string } | null;
if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke'].includes(body.action || '')) {
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
const milestone = await ownedMilestone(params.id, userId);
if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const threshold = milestoneThreshold(milestone.kind);
if (!threshold) return NextResponse.json({ error: 'Invalid milestone' }, { status: 400 });
const isDismissible = ['detected', 'shown'].includes(milestone.status);
if (body.action === 'revoke') {
if (!['approved', 'failed'].includes(milestone.brandStatus)) return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 });
const updated = await db.socialMilestone.update({ where: { id: milestone.id }, data: { brandStatus: 'revoked', brandPostError: null } });
return NextResponse.json({ ok: true, milestone: clientState(updated) });
}
if (body.action === 'decline' || body.action === 'opt_out') {
if (!isDismissible) return NextResponse.json({ error: 'This milestone has already been dismissed' }, { status: 409 });
const now = new Date();
await db.$transaction([
db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'declined', respondedAt: now } }),
...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []),
]);
return NextResponse.json({ ok: true });
}
const language = socialLocale(body.language);
const withName = body.action === 'approve_brand' && body.withName === true;
const xHandle = withName ? normalizeXHandle(body.xHandle || '') : null;
if (withName && !xHandle) return NextResponse.json({ error: 'Enter a valid X handle' }, { status: 400 });
const card = milestone.cardData || buildMilestoneCardSnapshot({
primaryUseCase: milestone.user.primaryUseCase,
qrTitle: milestone.qr.title,
totalUniqueScans: threshold,
milestoneThreshold: threshold,
reachedAt: milestone.detectedAt,
trend: null,
locale: language,
});
const now = new Date();
if (body.action === 'self_share') {
const token = milestone.shareToken || randomUUID().replace(/-/g, '');
const updated = await db.socialMilestone.update({
where: { id: milestone.id },
data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
});
return NextResponse.json({ ok: true, shareToken: token, milestone: clientState(updated) });
}
if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) {
return NextResponse.json({ error: 'This brand post is already being processed' }, { status: 409 });
}
const consentText = buildMilestonePostForQr(
milestone.user.primaryUseCase,
(card as { totalUniqueScans?: number }).totalUniqueScans || threshold,
xHandle,
language,
milestone.qr.title,
);
const updated = await db.$transaction(async tx => {
if (withName) await tx.user.update({ where: { id: userId }, data: { xHandle } });
return tx.socialMilestone.update({
where: { id: milestone.id },
data: {
brandStatus: 'approved', brandApprovedAt: now, brandPostError: null,
withName, consentText, language, cardData: card, respondedAt: now,
},
});
});
return NextResponse.json({ ok: true, consentText, milestone: clientState(updated) });
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSessionUserId } from '@/lib/session';
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
export const dynamic = 'force-dynamic';
// Returns at most one item. A missing response is treated as no consent, never as approval.
export async function GET(request: NextRequest) {
const userId = getSessionUserId();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const user = await db.user.findUnique({
where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, primaryUseCase: true },
});
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
const milestone = await db.socialMilestone.findFirst({
where: { userId, status: { in: ['detected', 'shown'] } },
orderBy: { detectedAt: 'asc' },
include: { qr: { select: { title: true } } },
});
if (!milestone) return NextResponse.json({ milestone: null });
if (milestone.status === 'detected') {
await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'shown', shownAt: new Date() } });
}
const threshold = milestoneThreshold(milestone.kind);
if (!threshold) return NextResponse.json({ milestone: null });
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
return NextResponse.json({
milestone: {
id: milestone.id, qrTitle: milestone.qr.title, threshold,
defaultXHandle: user.xHandle,
brandStatus: milestone.brandStatus,
brandPostUrl: milestone.brandPostUrl,
brandPostError: milestone.brandPostError,
language: locale,
preview: buildMilestonePostForQr(
user.primaryUseCase,
((milestone.cardData as { totalUniqueScans?: number } | null)?.totalUniqueScans || threshold),
null,
locale,
milestone.qr.title,
),
card: milestone.cardData || buildMilestoneCardSnapshot({
primaryUseCase: user.primaryUseCase,
qrTitle: milestone.qr.title,
totalUniqueScans: threshold,
milestoneThreshold: threshold,
reachedAt: milestone.detectedAt,
trend: null,
locale,
}),
},
});
}

View File

@@ -5,6 +5,7 @@ import { getWwwOrigin } from '@/lib/hosts';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { hashIP } from '@/lib/hash'; import { hashIP } from '@/lib/hash';
import { triggerLifecycleScoring } from '@/lib/revops-server'; import { triggerLifecycleScoring } from '@/lib/revops-server';
import { detectSocialMilestones } from '@/lib/social-milestones-server';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -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({ const activatedUsers = await db.user.updateMany({
where: { where: {
id: userId, id: userId,

View File

@@ -0,0 +1,118 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Check, ExternalLink, LineChart, Linkedin, Send, Sparkles, X } from 'lucide-react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { useCsrf } from '@/hooks/useCsrf';
import { useTranslation } from '@/hooks/useTranslation';
import { showToast } from '@/components/ui/Toast';
type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalUniqueScans: number; milestoneThreshold: number; trend: { periodDays: number; series: number[]; recentTotal: number } | null };
type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; language: 'en' | 'de'; card: Card; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null };
type BrandState = { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
function Trend({ series }: { series: number[] }) {
const max = Math.max(...series, 1);
const points = series.map((value, index) => `${(index / Math.max(series.length - 1, 1)) * 100},${92 - (value / max) * 70}`).join(' ');
return <svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-20 w-full overflow-visible" aria-label="Real scan trend"><polyline points={points} fill="none" stroke="currentColor" strokeWidth="3" vectorEffect="non-scaling-stroke" /></svg>;
}
export function SocialMilestoneDialog() {
const { fetchWithCsrf } = useCsrf();
const { locale } = useTranslation();
const [milestone, setMilestone] = useState<Milestone | null>(null);
const [withName, setWithName] = useState(false);
const [xHandle, setXHandle] = useState('');
const [saving, setSaving] = useState<'brand' | 'self' | null>(null);
const [brand, setBrand] = useState<BrandState | null>(null);
useEffect(() => {
fetch(`/api/social-milestones?locale=${locale}`).then(async response => {
if (response.ok) {
const next = (await response.json()).milestone as Milestone | null;
setMilestone(next);
if (next) setBrand({ brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null });
}
}).catch(() => undefined);
}, [locale]);
useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]);
useEffect(() => {
if (!milestone || !['approved', 'processing'].includes(brand?.brandStatus || '')) return;
const poll = async () => {
const response = await fetch(`/api/social-milestones/${milestone.id}`);
if (response.ok) setBrand((await response.json()).milestone);
};
const timer = window.setInterval(poll, 3000);
void poll();
return () => window.clearInterval(timer);
}, [milestone, brand?.brandStatus]);
const copy = milestone?.language === 'de'
? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf dem eigenen X-Account veröffentlichen?', name: 'Meinen X-Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Auf QR Master posten', queued: 'Wird auf X veröffentlicht …', posted: 'Auf X veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' }
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success from our X account?', name: 'Mention my X handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Publishing to X …', posted: 'Published on X', failed: 'Publishing failed' };
const preview = useMemo(() => {
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
return `${milestone.preview} By @${xHandle.trim().replace(/^@/, '')}.`;
}, [milestone, withName, xHandle]);
const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
if (!milestone) return null;
const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { method: 'PATCH', body: JSON.stringify({ action, withName, xHandle, language: milestone.language }) });
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Could not save your choice');
return result;
};
const shareSelf = async (network: 'x' | 'linkedin') => {
if (!milestone) return;
setSaving('self');
try {
const result = await update('self_share');
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`;
const text = `${preview} ${shareUrl}`;
if (network === 'x') window.open(`https://x.com/intent/post?text=${encodeURIComponent(text)}`, '_blank', 'noopener,noreferrer');
else {
await navigator.clipboard?.writeText(text);
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,noreferrer');
}
setBrand(result.milestone);
showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success');
} catch (error) { showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); }
finally { setSaving(null); }
};
const approveBrand = async () => {
setSaving('brand');
try {
const result = await update('approve_brand');
setBrand(result.milestone);
showToast(copy.queued, 'success');
} catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
finally { setSaving(null); }
};
const dismiss = async (action: 'decline' | 'opt_out') => {
try { await update(action); setMilestone(null); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
};
if (!milestone) return null;
const card = milestone.card;
const count = card.totalUniqueScans || milestone.threshold;
const status = brand?.brandStatus || milestone.brandStatus || 'pending';
return <Dialog open onOpenChange={open => !open && setMilestone(null)}>
<DialogContent className="max-w-xl overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)]">
<div className="px-7 pb-5 pt-7"><DialogHeader><div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-5 w-5" /></div><DialogTitle className="text-2xl font-semibold tracking-[-0.03em] text-[#061b31]">{copy.heading}</DialogTitle><DialogDescription className="pt-1 text-sm leading-6 text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></DialogHeader></div>
<div className="space-y-5 border-y border-slate-100 px-7 py-6">
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
<div className="flex items-center justify-between border-b border-slate-100 pb-4 text-xs"><span className="font-semibold tracking-wide text-[#061b31]">QR MASTER</span><span className="rounded bg-emerald-50 px-2 py-1 font-medium text-emerald-700"><Check className="mr-1 inline h-3 w-3" />Verified scan milestone</span></div>
<div className="mt-5 text-[11px] font-semibold tracking-[0.12em] text-slate-400">TOTAL UNIQUE SCANS</div><div className="mt-1 text-5xl font-semibold tracking-[-0.05em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div><div className="mt-1 text-sm text-slate-500">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div>
{card.trend ? <div className="mt-5 text-[#0256ff]"><Trend series={card.trend.series} /><div className="mt-2 flex items-center gap-2 text-xs text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{card.trend.recentTotal} {milestone.language === 'de' ? 'eindeutige Scans in den letzten' : 'unique scans in the last'} {card.trend.periodDays} days</div></div> : <div className="mt-5 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-xs font-medium text-blue-700"><LineChart className="h-3.5 w-3.5" />{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}</div>}
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
</section>
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 border-l-2 border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
<label className="flex cursor-pointer items-center gap-3 text-sm font-medium text-slate-700"><input type="checkbox" checked={withName} onChange={event => setWithName(event.target.checked)} disabled={status !== 'pending'} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
{withName && <input aria-label="X handle" value={xHandle} onChange={event => setXHandle(event.target.value)} disabled={status !== 'pending'} placeholder="@yourhandle" className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-[#0256ff] focus:ring-2 focus:ring-blue-100" />}
<div className="flex flex-wrap items-center gap-2"><span className="mr-1 text-xs font-medium text-slate-500">{copy.self}</span><Button variant="outline" size="sm" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />LinkedIn</Button></div>
{status !== 'pending' && <div className={`flex items-center justify-between rounded-md px-3 py-2 text-sm ${status === 'posted' ? 'bg-emerald-50 text-emerald-800' : status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}><span>{status === 'posted' ? copy.posted : status === 'failed' ? copy.failed : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
</div>
<DialogFooter className="bg-slate-50 px-7 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => dismiss('decline')} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || status !== 'pending'}><Send className="mr-1.5 h-4 w-4" />{copy.approve}</Button><button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={() => dismiss('opt_out')} disabled={saving !== null}>Do not show again</button></div></DialogFooter>
</DialogContent>
</Dialog>;
}

View File

@@ -0,0 +1,83 @@
import { db } from '@/lib/db';
import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, milestoneKind, SocialLocale } from '@/lib/social-milestones';
function excludedEmails() {
return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '')
.split(',').map(email => email.trim().toLowerCase()).filter(Boolean);
}
/** Creates any newly crossed milestones. Safe to call repeatedly. */
export async function detectSocialMilestones(qrId?: string) {
const excluded = excludedEmails();
const candidates = await db.qRScan.groupBy({
by: ['qrId'],
where: {
isUnique: true,
...(qrId ? { qrId } : {}),
qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined },
},
_count: { _all: true },
});
const records = candidates.flatMap(({ qrId: candidateQrId, _count }) =>
getSocialMilestoneThresholds()
.filter(threshold => _count._all >= threshold)
.map(threshold => ({ qrId: candidateQrId, kind: milestoneKind(threshold) }))
);
if (!records.length) return 0;
const qrs = await db.qRCode.findMany({
where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } },
select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } },
});
const cardByQr = new Map<string, Awaited<ReturnType<typeof createCardSnapshot>>>();
await Promise.all(qrs.map(async qr => cardByQr.set(qr.id, await createCardSnapshot(qr))));
const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId]));
const created = await db.socialMilestone.createMany({
data: records.filter(record => userIdByQr.has(record.qrId)).map(record => ({
...record,
userId: userIdByQr.get(record.qrId)!,
cardData: cardByQr.get(record.qrId),
})),
skipDuplicates: true,
});
return created.count;
}
async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) {
const now = new Date();
const sevenDaysAgo = new Date(now);
sevenDaysAgo.setUTCHours(0, 0, 0, 0);
sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 6);
const scans = await db.qRScan.findMany({
where: { qrId: qr.id, isUnique: true },
select: { ts: true },
orderBy: { ts: 'asc' },
});
const daily = new Map<string, number>();
for (const scan of scans) {
if (scan.ts >= sevenDaysAgo) {
const key = scan.ts.toISOString().slice(0, 10);
daily.set(key, (daily.get(key) || 0) + 1);
}
}
const series = Array.from({ length: 7 }, (_, index) => {
const date = new Date(sevenDaysAgo);
date.setUTCDate(date.getUTCDate() + index);
return daily.get(date.toISOString().slice(0, 10)) || 0;
});
const activeDays = series.filter(Boolean).length;
const trend = activeDays >= 2 && scans.length >= 5
? { periodDays: 7, series, recentTotal: series.reduce((total, value) => total + value, 0) }
: null;
// The snapshot is made at detection time and never silently changes after consent.
return buildMilestoneCardSnapshot({
primaryUseCase: qr.user.primaryUseCase,
qrTitle: qr.title,
totalUniqueScans: scans.length,
milestoneThreshold: scans.length,
reachedAt: now,
trend,
locale: 'en' as SocialLocale,
});
}

View File

@@ -0,0 +1,112 @@
export const DEFAULT_SOCIAL_MILESTONE_THRESHOLDS = [1000, 10000] as const;
export type SocialMilestoneKind = `unique_scans_${number}`;
/**
* Staging can set SOCIAL_MILESTONE_THRESHOLDS=1 (or e.g. 1,2) so the complete
* flow is testable without fabricating thousands of scans. Production keeps
* the conservative defaults unless its environment explicitly changes them.
*/
export function getSocialMilestoneThresholds(): number[] {
const configured = process.env.SOCIAL_MILESTONE_THRESHOLDS;
if (!configured) return [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS];
const thresholds = Array.from(new Set(
configured.split(',')
.map(value => Number(value.trim()))
.filter(value => Number.isInteger(value) && value > 0 && value <= 1_000_000)
)).sort((a, b) => a - b);
return thresholds.length ? thresholds : [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS];
}
const useCaseLabels: Record<string, string> = {
menu_pdf: 'menu QR code',
marketing_campaign: 'campaign QR code',
vcard: 'digital business-card QR code',
event: 'event QR code',
feedback: 'feedback QR code',
};
export function milestoneKind(threshold: number): SocialMilestoneKind {
return `unique_scans_${threshold}` as SocialMilestoneKind;
}
export function milestoneThreshold(kind: string): number | null {
const result = /^unique_scans_(\d+)$/.exec(kind);
return result ? Number(result[1]) : null;
}
export type SocialLocale = 'en' | 'de';
export type SocialMilestoneCard = {
version: 'milestone-card-v2';
language: SocialLocale;
qrTitle: string;
label: string;
title: string;
totalUniqueScans: number;
milestoneThreshold: number;
reachedAt: string;
trend: { periodDays: number; series: number[]; recentTotal: number } | null;
};
export function socialLocale(value?: string | null): SocialLocale {
return value === 'de' ? 'de' : 'en';
}
export function usageLabel(primaryUseCase: string | null, locale: SocialLocale = 'en'): string {
if (locale === 'de') {
const german: Record<string, string> = { menu_pdf: 'Speisekarten-QR-Code', marketing_campaign: 'Kampagnen-QR-Code', vcard: 'Visitenkarten-QR-Code', event: 'Event-QR-Code', feedback: 'Feedback-QR-Code' };
return (primaryUseCase && german[primaryUseCase]) || 'QR-Code';
}
return (primaryUseCase && useCaseLabels[primaryUseCase]) || 'QR code';
}
export function buildMilestonePost(primaryUseCase: string | null, threshold: number, xHandle?: string | null, locale: SocialLocale = 'en'): string {
const count = threshold.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
const base = locale === 'de'
? `Ein ${usageLabel(primaryUseCase, locale)} hat gerade ${count} eindeutige Scans erreicht. 🎉`
: `A ${usageLabel(primaryUseCase, locale)} just reached ${count} unique scans. 🎉`;
return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base;
}
export function buildMilestoneCard(primaryUseCase: string | null, threshold: number, locale: SocialLocale) {
return { version: 'milestone-card-v1', language: locale, threshold, label: usageLabel(primaryUseCase, locale), title: locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone' };
}
export function buildMilestonePostForQr(primaryUseCase: string | null, totalUniqueScans: number, xHandle: string | null | undefined, locale: SocialLocale, qrTitle: string): string {
const count = totalUniqueScans.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
const subject = qrTitle.trim() || usageLabel(primaryUseCase, locale);
const base = locale === 'de'
? `${subject}“ hat ${count} eindeutige Scans erreicht.`
: `${subject}” reached ${count} unique scans.`;
return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base;
}
export function buildMilestoneCardSnapshot(input: {
primaryUseCase: string | null;
qrTitle: string;
totalUniqueScans: number;
milestoneThreshold: number;
reachedAt: Date;
trend: { periodDays: number; series: number[]; recentTotal: number } | null;
locale: SocialLocale;
}): SocialMilestoneCard {
return {
version: 'milestone-card-v2',
language: input.locale,
qrTitle: input.qrTitle,
label: usageLabel(input.primaryUseCase, input.locale),
title: input.locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone',
totalUniqueScans: input.totalUniqueScans,
milestoneThreshold: input.milestoneThreshold,
reachedAt: input.reachedAt.toISOString(),
trend: input.trend,
};
}
export function normalizeXHandle(value: string): string | null {
const handle = value.trim().replace(/^@/, '');
return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null;
}