Harden milestone sharing and X publishing
This commit is contained in:
@@ -2,15 +2,17 @@ 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 { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
|
||||
import { buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
|
||||
import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server';
|
||||
|
||||
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 } } },
|
||||
include: { user: { select: { primaryUseCase: true } }, qr: { select: { id: true, title: true, createdAt: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,49 +65,51 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
}
|
||||
|
||||
const language = socialLocale(body.language);
|
||||
const withName = body.action === 'approve_brand' && body.withName === true;
|
||||
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 = milestone.cardData || buildMilestoneCardSnapshot({
|
||||
const card = await ensureSocialMilestoneCard({
|
||||
milestoneId: milestone.id,
|
||||
cardData: milestone.cardData,
|
||||
kind: milestone.kind,
|
||||
detectedAt: milestone.detectedAt,
|
||||
language,
|
||||
qr: milestone.qr,
|
||||
primaryUseCase: milestone.user.primaryUseCase,
|
||||
qrTitle: milestone.qr.title,
|
||||
totalScans: threshold,
|
||||
totalUniqueScans: threshold,
|
||||
milestoneThreshold: threshold,
|
||||
reachedAt: milestone.detectedAt,
|
||||
trend: null,
|
||||
locale: language,
|
||||
});
|
||||
const now = new Date();
|
||||
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 token = milestone.shareToken || randomBytes(9).toString('base64url');
|
||||
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, shareVersion: now.getTime(), milestone: clientState(updated) });
|
||||
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 consentText = buildMilestonePostForQr(
|
||||
const postText = buildMilestonePostForQr(
|
||||
milestone.user.primaryUseCase,
|
||||
(card as { totalUniqueScans?: number }).totalUniqueScans || threshold,
|
||||
xHandle,
|
||||
language,
|
||||
milestone.qr.title,
|
||||
);
|
||||
const consentText = `${postText}\n\n${shareUrl}`;
|
||||
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,
|
||||
brandStatus: 'approved', brandApprovedAt: milestone.brandApprovedAt || now, brandPostError: null,
|
||||
status: 'approved', withName, consentText, language, cardData: card, respondedAt: now,
|
||||
shareToken: token, publicShareApprovedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
|
||||
import { buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
|
||||
import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -16,9 +19,15 @@ export async function GET(request: NextRequest) {
|
||||
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
||||
|
||||
const milestone = await db.socialMilestone.findFirst({
|
||||
where: { userId, status: { in: ['detected', 'shown'] } },
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
{ status: { in: ['detected', 'shown'] } },
|
||||
{ status: 'approved', brandStatus: { in: ['approved', 'processing', 'failed'] } },
|
||||
],
|
||||
},
|
||||
orderBy: { detectedAt: 'asc' },
|
||||
include: { qr: { select: { id: true, title: true } } },
|
||||
include: { qr: { select: { id: true, title: true, createdAt: true } } },
|
||||
});
|
||||
if (!milestone) return NextResponse.json({ milestone: null });
|
||||
|
||||
@@ -28,20 +37,22 @@ export async function GET(request: NextRequest) {
|
||||
const threshold = milestoneThreshold(milestone.kind);
|
||||
if (!threshold) return NextResponse.json({ milestone: null });
|
||||
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
|
||||
const allScanCount = await db.qRScan.count({ where: { qrId: milestone.qr.id } });
|
||||
const storedCard = milestone.cardData as Record<string, unknown> | null;
|
||||
const card = storedCard
|
||||
? { ...storedCard, totalScans: typeof storedCard.totalScans === 'number' ? storedCard.totalScans : allScanCount }
|
||||
: buildMilestoneCardSnapshot({
|
||||
primaryUseCase: user.primaryUseCase,
|
||||
qrTitle: milestone.qr.title,
|
||||
totalScans: allScanCount,
|
||||
totalUniqueScans: threshold,
|
||||
milestoneThreshold: threshold,
|
||||
reachedAt: milestone.detectedAt,
|
||||
trend: null,
|
||||
locale,
|
||||
});
|
||||
const shareToken = milestone.shareToken || randomBytes(9).toString('base64url');
|
||||
if (!milestone.shareToken) {
|
||||
await db.socialMilestone.update({ where: { id: milestone.id }, data: { shareToken } });
|
||||
}
|
||||
const shareUrl = `${getWwwOrigin()}/s/m/${shareToken}`;
|
||||
const card = await ensureSocialMilestoneCard({
|
||||
milestoneId: milestone.id,
|
||||
cardData: milestone.cardData,
|
||||
kind: milestone.kind,
|
||||
detectedAt: milestone.detectedAt,
|
||||
language: locale,
|
||||
qr: milestone.qr,
|
||||
primaryUseCase: user.primaryUseCase,
|
||||
refresh: ['detected', 'shown'].includes(milestone.status),
|
||||
snapshotAt: new Date(),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
milestone: {
|
||||
@@ -51,9 +62,10 @@ export async function GET(request: NextRequest) {
|
||||
brandPostUrl: milestone.brandPostUrl,
|
||||
brandPostError: milestone.brandPostError,
|
||||
language: locale,
|
||||
shareUrl,
|
||||
preview: buildMilestonePostForQr(
|
||||
user.primaryUseCase,
|
||||
((milestone.cardData as { totalUniqueScans?: number } | null)?.totalUniqueScans || threshold),
|
||||
card.totalUniqueScans || threshold,
|
||||
null,
|
||||
locale,
|
||||
milestone.qr.title,
|
||||
|
||||
Reference in New Issue
Block a user