Add consented social milestone posting
This commit is contained in:
53
src/app/(main)/api/cron/social-milestones/route.ts
Normal file
53
src/app/(main)/api/cron/social-milestones/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSocialMilestoneThresholds, milestoneKind } from '@/lib/social-milestones';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function isAuthorized(request: NextRequest) {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`;
|
||||
}
|
||||
|
||||
function excludedEmails() {
|
||||
return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '')
|
||||
.split(',').map(email => email.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Detection only: this route never contacts customers or an external network.
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const excluded = excludedEmails();
|
||||
const thresholds = getSocialMilestoneThresholds();
|
||||
const candidates = await db.qRScan.groupBy({
|
||||
by: ['qrId'],
|
||||
where: {
|
||||
isUnique: true,
|
||||
qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined },
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const records = candidates.flatMap(({ qrId, _count }) =>
|
||||
thresholds
|
||||
.filter(threshold => _count._all >= threshold)
|
||||
.map(threshold => ({ qrId, kind: milestoneKind(threshold) }))
|
||||
);
|
||||
|
||||
if (records.length) {
|
||||
const qrs = await db.qRCode.findMany({
|
||||
where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId]));
|
||||
await db.socialMilestone.createMany({
|
||||
data: records
|
||||
.filter(record => userIdByQr.has(record.qrId))
|
||||
.map(record => ({ ...record, userId: userIdByQr.get(record.qrId)! })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, detected: records.length, thresholds });
|
||||
}
|
||||
65
src/app/(main)/api/internal/social-milestones/route.ts
Normal file
65
src/app/(main)/api/internal/social-milestones/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function isAuthorized(request: NextRequest) {
|
||||
const secret = process.env.INTERNAL_API_SECRET;
|
||||
return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`;
|
||||
}
|
||||
|
||||
function approvalDelayHours() {
|
||||
const configured = Number(process.env.SOCIAL_MILESTONE_POST_DELAY_HOURS);
|
||||
return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 0;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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' },
|
||||
include: { user: { select: { id: true } }, qr: { select: { id: true, status: 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 (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() },
|
||||
});
|
||||
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 } });
|
||||
}
|
||||
|
||||
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;
|
||||
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 },
|
||||
});
|
||||
if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
48
src/app/(main)/api/social-milestones/[id]/route.ts
Normal file
48
src/app/(main)/api/social-milestones/[id]/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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';
|
||||
|
||||
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke';
|
||||
|
||||
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 { action?: Action; withName?: boolean; xHandle?: string; language?: string } | null;
|
||||
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 } } },
|
||||
});
|
||||
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 });
|
||||
|
||||
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() } });
|
||||
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 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 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 });
|
||||
}
|
||||
41
src/app/(main)/api/social-milestones/route.ts
Normal file
41
src/app/(main)/api/social-milestones/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Returns at most one item. A missing response is treated as no consent, never as approval.
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = getSessionUserId();
|
||||
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 },
|
||||
});
|
||||
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
||||
|
||||
const milestone = await db.socialMilestone.findFirst({
|
||||
where: { userId, status: { in: ['detected', 'shown'] } },
|
||||
orderBy: { detectedAt: 'asc' },
|
||||
include: { qr: { select: { title: true } } },
|
||||
});
|
||||
if (!milestone) return NextResponse.json({ milestone: null });
|
||||
|
||||
if (milestone.status === 'detected') {
|
||||
await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'shown', shownAt: new Date() } });
|
||||
}
|
||||
const threshold = milestoneThreshold(milestone.kind);
|
||||
if (!threshold) return NextResponse.json({ milestone: null });
|
||||
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
|
||||
|
||||
return NextResponse.json({
|
||||
milestone: {
|
||||
id: milestone.id, qrTitle: milestone.qr.title, threshold,
|
||||
defaultXHandle: user.xHandle,
|
||||
language: locale,
|
||||
preview: buildMilestonePost(user.primaryUseCase, threshold, null, locale),
|
||||
card: buildMilestoneCard(user.primaryUseCase, threshold, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user