Consent is bound to the channel it was given for: approving a post on X says nothing about Instagram. Publishing state moves from the SocialMilestone row into SocialMilestonePost, one row per channel, where a missing row means no consent. The dialog asks per channel, shows the text each one will publish and keeps a separate handle for each; Instagram captions end in hashtags because a link there is not clickable. Also fixes three problems in the existing X path: - A QR code already past several thresholds produced one prompt per threshold, and since the post quotes the current scan count, every one of them would have published the same number. Only the highest threshold is announced now. - Detection ran after every unique scan and re-read the QR code's full scan history just to hit skipDuplicates. Known milestones are filtered first. - A failed post stayed failed forever because the consent dialog only opens once. The queue now retries three times on its own, spaces first attempts by SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per channel with restart and revoke. The worker no longer renders the card itself; it downloads the image the app renders at /s/m/<token>/og, which also serves the new square and portrait formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and SOCIAL_WORKER_CHANNELS both name it. Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before deploying this version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
9.0 KiB
TypeScript
191 lines
9.0 KiB
TypeScript
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<string, string>;
|
|
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<SocialChannel, string | null>();
|
|
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) });
|
|
}
|