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 { buildChannelPost, getEnabledSocialChannels, isSocialChannel, milestoneThreshold, normalizeChannelHandle, SocialChannel, socialLocale, } from '@/lib/social-milestones'; import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server'; type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke' | 'retry'; type Body = { action?: Action; withName?: boolean; channels?: string[]; handles?: Record; channel?: string; language?: string; }; async function ownedMilestone(id: string, userId: string) { return db.socialMilestone.findFirst({ where: { id, userId }, include: { user: { select: { primaryUseCase: true } }, qr: { select: { id: true, title: true, createdAt: true } }, posts: { select: { channel: true, status: true, postUrl: true, error: true } }, }, }); } type ClientMilestone = { status: string; selfSharedAt: Date | null; posts: Array<{ channel: string; status: string; postUrl: string | null; error: string | null }> }; function clientState(milestone: ClientMilestone) { return { promptStatus: milestone.status, selfSharedAt: milestone.selfSharedAt?.toISOString() || null, posts: milestone.posts.map(post => ({ channel: post.channel, status: post.status, postUrl: post.postUrl, error: post.error })), }; } async function stateOf(milestoneId: string) { const milestone = await db.socialMilestone.findUnique({ where: { id: milestoneId }, select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } }, }); return milestone ? clientState(milestone) : 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 Body | null; if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke', 'retry'].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); // Revoke and retry act on a single channel: consent for X is not consent for // Instagram, and withdrawing one must not touch the other. if (body.action === 'revoke' || body.action === 'retry') { if (!isSocialChannel(body.channel)) return NextResponse.json({ error: 'Unknown channel' }, { status: 400 }); const post = await db.socialMilestonePost.findUnique({ where: { milestoneId_channel: { milestoneId: milestone.id, channel: body.channel } }, }); if (!post) return NextResponse.json({ error: 'Nothing was approved for this channel' }, { status: 404 }); if (body.action === 'revoke') { if (!['approved', 'failed'].includes(post.status)) return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 }); await db.socialMilestonePost.update({ where: { id: post.id }, data: { status: 'revoked', error: null, nextAttemptAt: null } }); } else { if (post.status !== 'failed') return NextResponse.json({ error: 'Only failed posts can be restarted' }, { status: 409 }); // Restarts reuse the approved text unchanged - a retry must never // publish something the customer did not read before consenting. await db.socialMilestonePost.update({ where: { id: post.id }, data: { status: 'approved', attempts: 0, nextAttemptAt: null, error: null } }); } return NextResponse.json({ ok: true, milestone: await stateOf(milestone.id) }); } 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.withName === true; const card = await ensureSocialMilestoneCard({ milestoneId: milestone.id, cardData: milestone.cardData, kind: milestone.kind, detectedAt: milestone.detectedAt, language, qr: milestone.qr, primaryUseCase: milestone.user.primaryUseCase, }); const now = new Date(); // 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 shareUrl = `${getWwwOrigin()}/s/m/${token}`; if (body.action === 'self_share') { const updated = await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'self_shared', respondedAt: milestone.respondedAt || now, selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language, }, select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } }, }); return NextResponse.json({ ok: true, shareToken: token, shareUrl, shareVersion: now.getTime(), milestone: clientState(updated) }); } const enabled = getEnabledSocialChannels(); const channels = (body.channels || []).filter(isSocialChannel).filter(channel => enabled.includes(channel)); if (!channels.length) return NextResponse.json({ error: 'Choose at least one channel' }, { status: 400 }); const handles = new Map(); for (const channel of channels) { if (!withName) { handles.set(channel, null); continue; } const handle = normalizeChannelHandle(channel, body.handles?.[channel] || ''); if (!handle) return NextResponse.json({ error: `Enter a valid ${channel === 'instagram' ? 'Instagram' : 'X'} handle` }, { status: 400 }); handles.set(channel, handle); } const locked = milestone.posts.filter(post => channels.includes(post.channel as SocialChannel) && ['processing', 'posted'].includes(post.status)); if (locked.length) return NextResponse.json({ error: 'This post is already being processed' }, { status: 409 }); const updated = await db.$transaction(async tx => { if (withName) { await tx.user.update({ where: { id: userId }, data: { ...(handles.has('x') ? { xHandle: handles.get('x') } : {}), ...(handles.has('instagram') ? { instagramHandle: handles.get('instagram') } : {}), }, }); } for (const channel of channels) { const consentText = buildChannelPost({ channel, primaryUseCase: milestone.user.primaryUseCase, totalUniqueScans: (card as { totalUniqueScans?: number }).totalUniqueScans || threshold, locale: language, qrTitle: milestone.qr.title, shareUrl, handle: handles.get(channel) || null, }); await tx.socialMilestonePost.upsert({ where: { milestoneId_channel: { milestoneId: milestone.id, channel } }, create: { milestoneId: milestone.id, channel, consentText, handle: handles.get(channel) || null, approvedAt: now, status: 'approved' }, // A re-approval after a correction or a withdrawal starts over. update: { consentText, handle: handles.get(channel) || null, status: 'approved', attempts: 0, nextAttemptAt: null, error: null }, }); } return tx.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'approved', withName, language, cardData: card, respondedAt: now, shareToken: token, publicShareApprovedAt: now, }, select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } }, }); }); return NextResponse.json({ ok: true, milestone: clientState(updated) }); }