Harden milestone sharing and X publishing

This commit is contained in:
2026-08-14 18:19:55 +02:00
parent d8f7202bf6
commit 4ec70ed30f
8 changed files with 191 additions and 63 deletions

View File

@@ -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)

View File

@@ -18,8 +18,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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.`;

View File

@@ -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) {

View File

@@ -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,
},
});
});

View File

@@ -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<string, unknown> | 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,

View File

@@ -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<Card['trend']>; locale: 'en' | 'de' }) {
@@ -44,7 +44,7 @@ export function SocialMilestoneDialog() {
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 [saving, setSaving] = useState<'brand' | 'self' | 'copy' | null>(null);
const [brand, setBrand] = useState<BrandState | null>(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() {
<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 whitespace-pre-line border-l 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={!canApprove} 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={!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" />}
<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>
<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={saving !== null} 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={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" />}
<div className="space-y-2"><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={copyLinkedInText} disabled={saving !== null}><Copy className="mr-1.5 h-3.5 w-3.5" />{milestone.language === 'de' ? 'Text kopieren' : 'Copy text'}</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><p className="text-[11px] leading-4 text-slate-500">{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.'}</p></div>
{status !== 'pending' && <div className={`flex items-start justify-between gap-3 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}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
</div>
<DialogFooter className="shrink-0 bg-slate-50 px-6 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 || !canApprove}><Send className="mr-1.5 h-4 w-4" />{status === 'approved' || status === 'processing' ? copy.queued : status === 'failed' ? (milestone.language === 'de' ? 'Erneut versuchen' : 'Retry post') : 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={optOut} disabled={saving !== null}>{milestone.language === 'de' ? 'Nicht mehr anzeigen' : 'Do not show again'}</button></div></DialogFooter>
<DialogFooter className="shrink-0 bg-slate-50 px-6 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => status === 'pending' ? dismiss('decline') : setMilestone(null)} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || !canApprove}><Send className="mr-1.5 h-4 w-4" />{status === 'approved' || status === 'processing' ? copy.queued : status === 'failed' ? (milestone.language === 'de' ? 'Erneut versuchen' : 'Retry post') : copy.approve}</Button>{status === 'pending' && <button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={optOut} disabled={saving !== null}>{milestone.language === 'de' ? 'Nicht mehr anzeigen' : 'Do not show again'}</button>}</div></DialogFooter>
</DialogContent>
</Dialog>;
}

View File

@@ -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<string, Awaited<ReturnType<typeof createCardSnapshot>>>();
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<SocialMilestoneCard> {
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;
}

View File

@@ -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<SocialMilestoneCard>;
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';
}