import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getWwwOrigin } from '@/lib/hosts'; import { isSocialChannel, SOCIAL_CHANNELS, SocialChannel } from '@/lib/social-milestones'; export const dynamic = 'force-dynamic'; function isAuthorized(request: NextRequest) { const secret = process.env.INTERNAL_API_SECRET; return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`; } function approvalDelayHours() { const configured = Number(process.env.SOCIAL_MILESTONE_POST_DELAY_HOURS); return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 0; } /** Timeline spacing between two brand posts. Set to 0 to publish back to back. */ function minGapHours() { const configured = Number(process.env.SOCIAL_MILESTONE_MIN_GAP_HOURS); return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 24; } // Route modules may only export request handlers, so these stay local. const MAX_PUBLISH_ATTEMPTS = 3; /** One lock per channel, otherwise two publishers would block each other. */ function claimLockId(channel: SocialChannel) { return 920241 + SOCIAL_CHANNELS.indexOf(channel); } /** 5, 10, then 20 minutes. A transient outage resolves without a human. */ function retryDelayMs(attempts: number) { return Math.min(60, 5 * 2 ** Math.max(0, attempts - 1)) * 60 * 1000; } // This endpoint is intentionally a queue, not a social-media client. The // external worker fetches an approved payload and marks it complete only after // its own post succeeded. The app never receives X or Meta credentials. export async function GET(request: NextRequest) { if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const channelParam = request.nextUrl.searchParams.get('channel') || 'x'; if (!isSocialChannel(channelParam)) return NextResponse.json({ error: 'Unknown channel' }, { status: 400 }); const channel: SocialChannel = channelParam; const dryRun = request.nextUrl.searchParams.get('dryRun') === 'true'; const now = Date.now(); // A worker can be interrupted after claiming a row. Count that as a spent // attempt and re-queue it instead of leaving the dashboard in "processing" // forever. The worker reconciles against the account before it posts again, // so an interruption after a successful post cannot duplicate it. await db.$executeRaw` UPDATE "SocialMilestonePost" SET "attempts" = "attempts" + 1, "status" = CASE WHEN "attempts" + 1 < ${MAX_PUBLISH_ATTEMPTS} THEN 'approved' ELSE 'failed' END, "nextAttemptAt" = CASE WHEN "attempts" + 1 < ${MAX_PUBLISH_ATTEMPTS} THEN ${new Date(now + retryDelayMs(1))} ELSE NULL END, "error" = 'The publisher was interrupted before it confirmed the post.', "claimedAt" = NULL WHERE "channel" = ${channel} AND "status" = 'processing' AND "claimedAt" < ${new Date(now - 5 * 60 * 1000)} `; const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000); const post = await db.socialMilestonePost.findFirst({ where: { channel, status: 'approved', approvedAt: { lte: approvalNotBefore }, OR: [{ nextAttemptAt: null }, { nextAttemptAt: { lte: new Date(now) } }], // A paused or deleted QR code stops being advertised. milestone: { qr: { status: 'ACTIVE' } }, }, orderBy: { approvedAt: 'asc' }, include: { milestone: { select: { id: true, shareToken: true } } }, }); if (!post) return NextResponse.json({ milestone: null }); // Several customers can consent on the same afternoon. Spacing keeps the // brand timeline readable, per channel. A retry is exempt: nothing of it went // out yet, and delaying recovery by a full day would strand the post. if (post.attempts === 0 && minGapHours() > 0) { const previous = await db.socialMilestonePost.findFirst({ where: { channel, postedAt: { gt: new Date(now - minGapHours() * 60 * 60 * 1000) } }, orderBy: { postedAt: 'desc' }, select: { postedAt: true }, }); if (previous?.postedAt) { const nextPostAt = new Date(previous.postedAt.getTime() + minGapHours() * 60 * 60 * 1000); return NextResponse.json({ milestone: null, reason: 'spacing', nextPostAt: nextPostAt.toISOString() }); } } const shareUrl = post.milestone.shareToken ? `${getWwwOrigin()}/s/m/${post.milestone.shareToken}` : null; if (dryRun) return NextResponse.json({ milestone: { id: post.id, channel, text: post.consentText, shareUrl }, dryRun: true }); const claimed = await db.$transaction(async (tx) => { // The blocking advisory-lock function returns PostgreSQL `void`, which // Prisma cannot deserialize. The try variant returns a real boolean and // keeps the lock scoped to this transaction. const [lock] = await tx.$queryRaw>` SELECT pg_try_advisory_xact_lock(${claimLockId(channel)}) AS acquired `; if (!lock?.acquired) return 0; const result = await tx.socialMilestonePost.updateMany({ where: { id: post.id, status: 'approved' }, data: { status: 'processing', claimedAt: new Date() }, }); return result.count; }); if (!claimed) return NextResponse.json({ milestone: null, reason: 'claimed' }); return NextResponse.json({ milestone: { id: post.id, milestoneId: post.milestone.id, channel, text: post.consentText, shareToken: post.milestone.shareToken, shareUrl, // Tells the worker whether an earlier attempt may already have published // this post, so it only spends read quota when reconciling. attempts: post.attempts, approvedAt: post.approvedAt.toISOString(), } }); } export async function PATCH(request: NextRequest) { if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const body = await request.json().catch(() => null) as { id?: string; result?: 'posted' | 'failed'; postUrl?: string; error?: string } | null; if (!body?.id || !['posted', 'failed'].includes(body.result || '')) return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); const claimed = await db.socialMilestonePost.findFirst({ where: { id: body.id, status: 'processing' }, select: { id: true, attempts: true }, }); if (!claimed) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 }); if (body.result === 'posted') { const updated = await db.socialMilestonePost.updateMany({ where: { id: claimed.id, status: 'processing' }, data: { status: 'posted', postedAt: new Date(), postUrl: body.postUrl || null, error: null, nextAttemptAt: null }, }); if (!updated.count) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 }); return NextResponse.json({ ok: true }); } // Re-queue on its own until the attempts are used up. Only then does the post // rest in `failed`, where the customer can restart it manually. const attempts = claimed.attempts + 1; const retry = attempts < MAX_PUBLISH_ATTEMPTS; const updated = await db.socialMilestonePost.updateMany({ where: { id: claimed.id, status: 'processing' }, data: { status: retry ? 'approved' : 'failed', attempts, nextAttemptAt: retry ? new Date(Date.now() + retryDelayMs(attempts)) : null, postedAt: null, postUrl: null, error: body.error || 'The post could not be published.', claimedAt: null, }, }); if (!updated.count) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 }); return NextResponse.json({ ok: true, attempts, retryScheduled: retry }); }