From 4ec70ed30f90ad3128c78d8bedf95cc4b3f5a632 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 18:19:55 +0200 Subject: [PATCH] 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'; }