From e0c32542f99f88f86838589eef11024c18d34c26 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 11:47:28 +0200 Subject: [PATCH] Detect social milestones when scans arrive --- .../api/cron/social-milestones/route.ts | 44 ++----------------- src/app/(main)/r/[slug]/route.ts | 7 +++ src/lib/social-milestones-server.ts | 38 ++++++++++++++++ 3 files changed, 49 insertions(+), 40 deletions(-) create mode 100644 src/lib/social-milestones-server.ts diff --git a/src/app/(main)/api/cron/social-milestones/route.ts b/src/app/(main)/api/cron/social-milestones/route.ts index a546a9d..e553149 100644 --- a/src/app/(main)/api/cron/social-milestones/route.ts +++ b/src/app/(main)/api/cron/social-milestones/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/lib/db'; -import { getSocialMilestoneThresholds, milestoneKind } from '@/lib/social-milestones'; +import { detectSocialMilestones } from '@/lib/social-milestones-server'; +import { getSocialMilestoneThresholds } from '@/lib/social-milestones'; export const dynamic = 'force-dynamic'; @@ -9,45 +9,9 @@ function isAuthorized(request: NextRequest) { 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 }); + const detected = await detectSocialMilestones(); + return NextResponse.json({ ok: true, detected, thresholds: getSocialMilestoneThresholds() }); } diff --git a/src/app/(main)/r/[slug]/route.ts b/src/app/(main)/r/[slug]/route.ts index d6ddc54..cd364ac 100644 --- a/src/app/(main)/r/[slug]/route.ts +++ b/src/app/(main)/r/[slug]/route.ts @@ -5,6 +5,7 @@ import { getWwwOrigin } from '@/lib/hosts'; import { db } from '@/lib/db'; import { hashIP } from '@/lib/hash'; import { triggerLifecycleScoring } from '@/lib/revops-server'; +import { detectSocialMilestones } from '@/lib/social-milestones-server'; export async function GET( request: NextRequest, @@ -260,6 +261,12 @@ async function trackScan(qrId: string, userId: string, request: NextRequest) { }, }); + // The customer sees a newly crossed milestone on their next dashboard + // visit; no separate cron invocation is required after a real scan. + if (isUnique) { + await detectSocialMilestones(qrId); + } + const activatedUsers = await db.user.updateMany({ where: { id: userId, diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts new file mode 100644 index 0000000..f3dc76e --- /dev/null +++ b/src/lib/social-milestones-server.ts @@ -0,0 +1,38 @@ +import { db } from '@/lib/db'; +import { getSocialMilestoneThresholds, milestoneKind } from '@/lib/social-milestones'; + +function excludedEmails() { + return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '') + .split(',').map(email => email.trim().toLowerCase()).filter(Boolean); +} + +/** Creates any newly crossed milestones. Safe to call repeatedly. */ +export async function detectSocialMilestones(qrId?: string) { + const excluded = excludedEmails(); + const candidates = await db.qRScan.groupBy({ + by: ['qrId'], + where: { + isUnique: true, + ...(qrId ? { qrId } : {}), + qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined }, + }, + _count: { _all: true }, + }); + const records = candidates.flatMap(({ qrId: candidateQrId, _count }) => + getSocialMilestoneThresholds() + .filter(threshold => _count._all >= threshold) + .map(threshold => ({ qrId: candidateQrId, kind: milestoneKind(threshold) })) + ); + if (!records.length) return 0; + + 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])); + const created = await db.socialMilestone.createMany({ + data: records.filter(record => userIdByQr.has(record.qrId)).map(record => ({ ...record, userId: userIdByQr.get(record.qrId)! })), + skipDuplicates: true, + }); + return created.count; +}