Improve social milestone sharing flow

This commit is contained in:
2026-08-14 12:31:10 +02:00
parent e0c32542f9
commit 925540f3c6
11 changed files with 400 additions and 121 deletions

View File

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