Improve social milestone sharing flow
This commit is contained in:
@@ -20,14 +20,10 @@ 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' },
|
||||
where: { brandStatus: 'approved', brandApprovedAt: { lte: approvalNotBefore } },
|
||||
orderBy: { brandApprovedAt: 'asc' },
|
||||
include: { user: { select: { id: true } }, qr: { select: { id: true, status: true } } },
|
||||
});
|
||||
// Relations are required by the schema. This guard makes the intended
|
||||
@@ -37,14 +33,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
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() },
|
||||
where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() },
|
||||
});
|
||||
return result.count;
|
||||
});
|
||||
@@ -54,11 +46,16 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
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;
|
||||
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 updated = await db.socialMilestone.updateMany({
|
||||
where: { id: body.id, status: 'processing' },
|
||||
data: { status: body.result!, postedAt: body.result === 'posted' ? new Date() : null },
|
||||
where: { id: body.id, brandStatus: 'processing' },
|
||||
data: {
|
||||
brandStatus: body.result!,
|
||||
brandPostedAt: body.result === 'posted' ? new Date() : null,
|
||||
brandPostUrl: body.result === 'posted' ? body.postUrl || null : null,
|
||||
brandPostError: body.result === 'failed' ? (body.error || 'The post could not be published.') : null,
|
||||
},
|
||||
});
|
||||
if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 });
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { buildMilestoneCard, buildMilestonePost, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
|
||||
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -33,9 +33,26 @@ export async function GET(request: NextRequest) {
|
||||
milestone: {
|
||||
id: milestone.id, qrTitle: milestone.qr.title, threshold,
|
||||
defaultXHandle: user.xHandle,
|
||||
brandStatus: milestone.brandStatus,
|
||||
brandPostUrl: milestone.brandPostUrl,
|
||||
brandPostError: milestone.brandPostError,
|
||||
language: locale,
|
||||
preview: buildMilestonePost(user.primaryUseCase, threshold, null, locale),
|
||||
card: buildMilestoneCard(user.primaryUseCase, threshold, locale),
|
||||
preview: buildMilestonePostForQr(
|
||||
user.primaryUseCase,
|
||||
((milestone.cardData as { totalUniqueScans?: number } | null)?.totalUniqueScans || threshold),
|
||||
null,
|
||||
locale,
|
||||
milestone.qr.title,
|
||||
),
|
||||
card: milestone.cardData || buildMilestoneCardSnapshot({
|
||||
primaryUseCase: user.primaryUseCase,
|
||||
qrTitle: milestone.qr.title,
|
||||
totalUniqueScans: threshold,
|
||||
milestoneThreshold: threshold,
|
||||
reachedAt: milestone.detectedAt,
|
||||
trend: null,
|
||||
locale,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user