Add consented social milestone posting

This commit is contained in:
2026-08-14 09:03:10 +02:00
parent 14c429ff30
commit f7d82aa5bd
14 changed files with 613 additions and 3 deletions

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
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;
}
// This endpoint is intentionally a queue, not a social-media client. The
// external X worker fetches an approved payload and marks it complete only
// after its own post succeeded. The app never receives X credentials.
export async function GET(request: NextRequest) {
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const dryRun = request.nextUrl.searchParams.get('dryRun') === 'true';
const now = Date.now();
const dayAgo = new Date(now - 24 * 60 * 60 * 1000);
const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000);
const postedToday = await db.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } });
if (postedToday > 0) return NextResponse.json({ milestone: null, reason: 'daily_limit' });
const milestone = await db.socialMilestone.findFirst({
where: { status: 'approved', respondedAt: { lte: approvalNotBefore } },
orderBy: { respondedAt: 'asc' },
include: { user: { select: { id: true } }, qr: { select: { id: true, status: true } } },
});
// Relations are required by the schema. This guard makes the intended
// revalidation explicit if retention policies are changed later.
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 });
// A claimed item also occupies the daily slot. This prevents two workers
// from each claiming a different milestone before either one posts.
const claimed = await db.$transaction(async (tx) => {
await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)');
const occupied = await tx.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } });
if (occupied) return 0;
const result = await tx.socialMilestone.updateMany({
where: { id: milestone.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: milestone.id, text: milestone.consentText, card: milestone.cardData } });
}
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' } | null;
if (!body?.id || !['posted', 'failed'].includes(body.result || '')) return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
const updated = await db.socialMilestone.updateMany({
where: { id: body.id, status: 'processing' },
data: { status: body.result!, postedAt: body.result === 'posted' ? new Date() : null },
});
if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 });
return NextResponse.json({ ok: true });
}