Fix milestone sharing previews and publisher recovery

This commit is contained in:
2026-08-14 14:33:29 +02:00
parent e7581e488d
commit d8f7202bf6
10 changed files with 265 additions and 43 deletions

View File

@@ -20,6 +20,12 @@ 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();
// A worker can be interrupted after claiming a row. Surface that state as a
// retryable failure instead of leaving the dashboard in "processing" forever.
await db.socialMilestone.updateMany({
where: { brandStatus: 'processing', claimedAt: { lt: new Date(now - 5 * 60 * 1000) } },
data: { brandStatus: 'failed', brandPostError: 'The publisher was interrupted before it confirmed the post. Please retry.' },
});
const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000);
const milestone = await db.socialMilestone.findFirst({
where: { brandStatus: 'approved', brandApprovedAt: { lte: approvalNotBefore } },

View File

@@ -86,7 +86,7 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
where: { id: milestone.id },
data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
});
return NextResponse.json({ ok: true, shareToken: token, milestone: clientState(updated) });
return NextResponse.json({ ok: true, shareToken: token, shareVersion: now.getTime(), milestone: clientState(updated) });
}
if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) {

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import { csrfProtection } from '@/lib/csrf';
import { db } from '@/lib/db';
import { getSessionUserId } from '@/lib/session';
import { getSocialMilestoneThresholds } from '@/lib/social-milestones';
export const dynamic = 'force-dynamic';
function testResetAvailable() {
return getSocialMilestoneThresholds().some(threshold => threshold < 100);
}
export async function GET() {
const userId = getSessionUserId();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const user = await db.user.findUnique({ where: { id: userId }, select: { socialPromptOptOut: true } });
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ promptsEnabled: !user.socialPromptOptOut, testResetAvailable: testResetAvailable() });
}
export async function PATCH(request: NextRequest) {
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 { action?: 'enable' | 'disable' | 'reset_test' } | null;
if (!body?.action || !['enable', 'disable', 'reset_test'].includes(body.action)) {
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
if (body.action === 'reset_test') {
if (!testResetAvailable()) return NextResponse.json({ error: 'Test reset is not available in this environment' }, { status: 403 });
const latest = await db.socialMilestone.findFirst({
where: { userId },
orderBy: { detectedAt: 'desc' },
select: { id: true },
});
await db.$transaction([
db.user.update({ where: { id: userId }, data: { socialPromptOptOut: false } }),
...(latest ? [db.socialMilestone.update({
where: { id: latest.id },
data: {
status: 'detected', shownAt: null, respondedAt: null,
brandStatus: 'pending', brandApprovedAt: null, claimedAt: null,
brandPostedAt: null, brandPostUrl: null, brandPostError: null,
consentText: null, withName: false,
selfSharedAt: null, publicShareApprovedAt: null, shareToken: null,
},
})] : []),
]);
return NextResponse.json({ ok: true, promptsEnabled: true, resetMilestone: Boolean(latest) });
}
const promptsEnabled = body.action === 'enable';
await db.user.update({ where: { id: userId }, data: { socialPromptOptOut: !promptsEnabled } });
return NextResponse.json({ ok: true, promptsEnabled });
}