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

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