Publish milestones per channel and add Instagram
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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -14,54 +15,109 @@ function approvalDelayHours() {
|
||||
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 X worker fetches an approved payload and marks it complete only
|
||||
// after its own post succeeded. The app never receives X credentials.
|
||||
// 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. 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.' },
|
||||
});
|
||||
|
||||
// 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 milestone = await db.socialMilestone.findFirst({
|
||||
where: { brandStatus: 'approved', brandApprovedAt: { lte: approvalNotBefore } },
|
||||
orderBy: { brandApprovedAt: 'asc' },
|
||||
include: { user: { select: { id: true } }, qr: { select: { id: true, status: true } } },
|
||||
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 } } },
|
||||
});
|
||||
// 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 (!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 = milestone.shareToken ? `${getWwwOrigin()}/s/m/${milestone.shareToken}` : null;
|
||||
if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, shareUrl }, dryRun: true });
|
||||
|
||||
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<Array<{ acquired: boolean }>>`
|
||||
SELECT pg_try_advisory_xact_lock(920241) AS acquired
|
||||
SELECT pg_try_advisory_xact_lock(${claimLockId(channel)}) AS acquired
|
||||
`;
|
||||
if (!lock?.acquired) return 0;
|
||||
const result = await tx.socialMilestone.updateMany({
|
||||
where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() },
|
||||
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: milestone.id,
|
||||
text: milestone.consentText,
|
||||
card: milestone.cardData,
|
||||
shareToken: milestone.shareToken,
|
||||
id: post.id,
|
||||
milestoneId: post.milestone.id,
|
||||
channel,
|
||||
text: post.consentText,
|
||||
shareToken: post.milestone.shareToken,
|
||||
shareUrl,
|
||||
approvedAt: milestone.brandApprovedAt?.toISOString() || null,
|
||||
// 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(),
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -69,15 +125,37 @@ 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 updated = await db.socialMilestone.updateMany({
|
||||
where: { id: body.id, brandStatus: 'processing' },
|
||||
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: {
|
||||
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,
|
||||
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: 'Milestone is no longer available' }, { status: 409 });
|
||||
return NextResponse.json({ ok: true });
|
||||
if (!updated.count) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 });
|
||||
return NextResponse.json({ ok: true, attempts, retryScheduled: retry });
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const adminKey = process.env.TIKTOK_ADMIN_KEY;
|
||||
const adminKey = process.env.SOCIAL_ASSET_ADMIN_KEY || process.env.TIKTOK_ADMIN_KEY;
|
||||
const provided =
|
||||
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
|
||||
if (!adminKey || provided !== adminKey) {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { db } from '@/lib/db';
|
||||
// are served from qrmaster.net via GET /api/social-assets/[id].
|
||||
|
||||
const isAdminRequest = (request: NextRequest) => {
|
||||
const adminKey = process.env.TIKTOK_ADMIN_KEY;
|
||||
// Asset hosting is not a TikTok feature - Instagram needs it too. The old
|
||||
// TikTok key stays valid so existing deployments keep working.
|
||||
const adminKey = process.env.SOCIAL_ASSET_ADMIN_KEY || process.env.TIKTOK_ADMIN_KEY;
|
||||
if (!adminKey) return false;
|
||||
const provided =
|
||||
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
|
||||
|
||||
@@ -4,28 +4,51 @@ import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
|
||||
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';
|
||||
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 } } },
|
||||
include: {
|
||||
user: { select: { primaryUseCase: true } },
|
||||
qr: { select: { id: true, title: true, createdAt: true } },
|
||||
posts: { select: { channel: true, status: true, postUrl: true, error: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clientState(milestone: { status: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) {
|
||||
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,
|
||||
brandStatus: milestone.brandStatus,
|
||||
brandPostUrl: milestone.brandPostUrl,
|
||||
brandPostError: milestone.brandPostError,
|
||||
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 });
|
||||
@@ -40,8 +63,8 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
const userId = getSessionUserId();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null) as { action?: Action; withName?: boolean; xHandle?: string; language?: string } | null;
|
||||
if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke'].includes(body.action || '')) {
|
||||
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);
|
||||
@@ -50,11 +73,26 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
if (!threshold) return NextResponse.json({ error: 'Invalid milestone' }, { status: 400 });
|
||||
const isDismissible = ['detected', 'shown'].includes(milestone.status);
|
||||
|
||||
if (body.action === 'revoke') {
|
||||
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) });
|
||||
// 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();
|
||||
@@ -67,8 +105,6 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
|
||||
const language = socialLocale(body.language);
|
||||
const withName = 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 card = await ensureSocialMilestoneCard({
|
||||
milestoneId: milestone.id,
|
||||
cardData: milestone.cardData,
|
||||
@@ -79,43 +115,76 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
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') {
|
||||
// 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 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) });
|
||||
}
|
||||
|
||||
if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) {
|
||||
return NextResponse.json({ error: 'This brand post is already being processed' }, { status: 409 });
|
||||
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 postText = buildMilestonePostForQr(
|
||||
milestone.user.primaryUseCase,
|
||||
(card as { totalUniqueScans?: number }).totalUniqueScans || threshold,
|
||||
xHandle,
|
||||
language,
|
||||
milestone.qr.title,
|
||||
);
|
||||
const consentText = `${postText}\n\n${shareUrl}`;
|
||||
|
||||
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: { xHandle } });
|
||||
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: {
|
||||
brandStatus: 'approved', brandApprovedAt: milestone.brandApprovedAt || now, brandPostError: null,
|
||||
status: 'approved', withName, consentText, language, cardData: card, respondedAt: now,
|
||||
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, consentText, milestone: clientState(updated) });
|
||||
return NextResponse.json({ ok: true, milestone: clientState(updated) });
|
||||
}
|
||||
|
||||
51
src/app/(main)/api/social-milestones/history/route.ts
Normal file
51
src/app/(main)/api/social-milestones/history/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { milestoneThreshold } from '@/lib/social-milestones';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// The consent dialog opens once per milestone. Everything that happened before
|
||||
// - a declined prompt, a queued post, a post that ran out of attempts - is only
|
||||
// visible here, which is also the only place a failed post can be restarted.
|
||||
export async function GET() {
|
||||
const userId = getSessionUserId();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const milestones = await db.socialMilestone.findMany({
|
||||
where: { userId },
|
||||
orderBy: { detectedAt: 'desc' },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true, kind: true, status: true, detectedAt: true, cardData: true,
|
||||
selfSharedAt: true, shareToken: true, publicShareApprovedAt: true,
|
||||
qr: { select: { title: true } },
|
||||
posts: { select: { channel: true, status: true, postUrl: true, error: true, postedAt: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
milestones: milestones.map(milestone => {
|
||||
const card = milestone.cardData as { totalUniqueScans?: number } | null;
|
||||
return {
|
||||
id: milestone.id,
|
||||
qrTitle: milestone.qr.title,
|
||||
uniqueScans: card?.totalUniqueScans || milestoneThreshold(milestone.kind) || 0,
|
||||
detectedAt: milestone.detectedAt.toISOString(),
|
||||
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,
|
||||
postedAt: post.postedAt?.toISOString() || null,
|
||||
})),
|
||||
shareUrl: milestone.shareToken && milestone.publicShareApprovedAt
|
||||
? `${getWwwOrigin()}/s/m/${milestone.shareToken}`
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -37,16 +37,19 @@ export async function PATCH(request: NextRequest) {
|
||||
});
|
||||
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,
|
||||
},
|
||||
})] : []),
|
||||
// Dropping the per-channel approvals is what makes the reset complete:
|
||||
// no row means no consent, which is exactly the pre-prompt state.
|
||||
...(latest ? [
|
||||
db.socialMilestonePost.deleteMany({ where: { milestoneId: latest.id } }),
|
||||
db.socialMilestone.update({
|
||||
where: { id: latest.id },
|
||||
data: {
|
||||
status: 'detected', shownAt: null, respondedAt: null,
|
||||
consentText: null, withName: false,
|
||||
selfSharedAt: null, publicShareApprovedAt: null, shareToken: null,
|
||||
},
|
||||
}),
|
||||
] : []),
|
||||
]);
|
||||
return NextResponse.json({ ok: true, promptsEnabled: true, resetMilestone: Boolean(latest) });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
|
||||
import { getEnabledSocialChannels, milestonePostParts, milestoneThreshold, SOCIAL_CHANNELS, socialLocale } from '@/lib/social-milestones';
|
||||
import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, primaryUseCase: true },
|
||||
where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, instagramHandle: true, primaryUseCase: true },
|
||||
});
|
||||
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
||||
|
||||
@@ -49,23 +49,34 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
if (!delivered.count) return NextResponse.json({ milestone: null });
|
||||
|
||||
// One entry per channel: its own text, its own handle. The dialog recombines
|
||||
// head + mention + tail while the customer types, so what is on screen is
|
||||
// exactly what the server will store as the consent text. `available` marks
|
||||
// the channels a publisher is configured for - the others are still listed
|
||||
// because sharing them yourself works without any publisher.
|
||||
const enabled = getEnabledSocialChannels();
|
||||
const channels = SOCIAL_CHANNELS.map(channel => ({
|
||||
channel,
|
||||
available: enabled.includes(channel),
|
||||
defaultHandle: (channel === 'instagram' ? user.instagramHandle : user.xHandle) || '',
|
||||
...milestonePostParts({
|
||||
channel,
|
||||
primaryUseCase: user.primaryUseCase,
|
||||
totalUniqueScans: card.totalUniqueScans || threshold,
|
||||
locale,
|
||||
qrTitle: milestone.qr.title,
|
||||
shareUrl,
|
||||
}),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
milestone: {
|
||||
id: milestone.id, qrTitle: milestone.qr.title, threshold,
|
||||
defaultXHandle: user.xHandle,
|
||||
brandStatus: milestone.brandStatus,
|
||||
promptStatus: 'shown',
|
||||
brandPostUrl: milestone.brandPostUrl,
|
||||
brandPostError: milestone.brandPostError,
|
||||
language: locale,
|
||||
shareUrl,
|
||||
preview: buildMilestonePostForQr(
|
||||
user.primaryUseCase,
|
||||
card.totalUniqueScans || threshold,
|
||||
null,
|
||||
locale,
|
||||
milestone.qr.title,
|
||||
),
|
||||
channels,
|
||||
posts: [],
|
||||
card,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user