From eea88f2fb4b342235bed6ad4bc04cb4946dd57b5 Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Tue, 30 Jun 2026 20:11:13 +0200 Subject: [PATCH] Add internal RevOps export API --- docker-compose.yml | 1 + .../qrmaster-internal-revops-export.md | 131 +++++ .../api/internal/revops-export/route.ts | 463 ++++++++++++++++++ 3 files changed, 595 insertions(+) create mode 100644 docs/automations/qrmaster-internal-revops-export.md create mode 100644 src/app/(main)/api/internal/revops-export/route.ts diff --git a/docker-compose.yml b/docker-compose.yml index 902a150..28f0440 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,7 @@ services: NEXTAUTH_URL: ${NEXTAUTH_URL} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050} + INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} IP_SALT: ${IP_SALT:-your-salt-change-in-production} ENABLE_DEMO: ${ENABLE_DEMO:-false} NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true} diff --git a/docs/automations/qrmaster-internal-revops-export.md b/docs/automations/qrmaster-internal-revops-export.md new file mode 100644 index 0000000..eef54d2 --- /dev/null +++ b/docs/automations/qrmaster-internal-revops-export.md @@ -0,0 +1,131 @@ +# QR Master Internal RevOps Export API + +This endpoint exposes read-only QR Master admin/product data for trusted automations such as Hermes cron jobs. + +## Endpoint + +```http +GET /api/internal/revops-export +Authorization: Bearer +``` + +The endpoint accepts `INTERNAL_API_SECRET` first, with `CRON_SECRET` as a fallback for compatibility. + +## Purpose + +QR Master only exports structured data. It does **not** perform AI analysis, web research, email drafting, or lead outreach. + +Hermes is responsible for: + +- calling this API on a schedule, +- researching public websites/domains with its tools, +- deciding which leads are worth manual outreach, +- drafting personalized emails, +- sending Timo an internal briefing, +- never auto-sending messages to QR Master users. + +## Query Parameters + +| Parameter | Example | Notes | +|---|---|---| +| `range` | `24h`, `7d`, `30d`, `all` | Defaults to `24h`. | +| `from` | `2026-06-30T00:00:00Z` | Optional explicit start. Overrides `range`. | +| `to` | `2026-07-01T00:00:00Z` | Optional explicit end. Defaults to now if `from` is set. | +| `limit` | `100` | User/lead limit. Defaults to 100, max 500. | +| `allUsers` | `true` | Exports all users up to `limit`; otherwise exports users active/relevant in range. | + +## Example Calls + +Daily Hermes lead research context: + +```bash +curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ + "https://www.qrmaster.net/api/internal/revops-export?range=24h&limit=100" +``` + +Weekly progress context: + +```bash +curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ + "https://www.qrmaster.net/api/internal/revops-export?range=7d&limit=200" +``` + +Local development: + +```bash +curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ + "http://localhost:3050/api/internal/revops-export?range=24h" +``` + +## Response Shape + +Top-level fields: + +```json +{ + "ok": true, + "generatedAt": "2026-07-01T18:00:00.000Z", + "endpoint": "/api/internal/revops-export", + "range": { + "preset": "24h", + "from": "2026-06-30T18:00:00.000Z", + "to": "2026-07-01T18:00:00.000Z" + }, + "overview": {}, + "breakdowns": {}, + "users": [], + "leads": [], + "newsletterSubscriptions": [] +} +``` + +Each exported user includes: + +- identity: `id`, `name`, `email`, `emailDomain`, `createdAt`, `updatedAt` +- plan/subscription flags +- attribution fields +- onboarding fields +- lifecycle scores and timestamps +- aggregate stats +- QR codes, content, public path, and scan summaries +- integrations +- recent lifecycle logs + +## Safety Rules for Hermes Jobs + +Hermes should treat this API as internal source data and follow these rules: + +1. Do not send outreach emails directly to leads. +2. Send only an internal briefing/draft pack to Timo. +3. Use public web research only when it improves personalization. +4. Do not tell leads they were researched. +5. Avoid creepy language; frame personalization around their QR Master activity and likely workflow. +6. Never expose API secrets or raw sensitive internals in the briefing. +7. Prefer business-use signals: QR content type, destination, scans, onboarding use case, domain, company website, landing path. + +## Suggested Hermes Cron Jobs + +### Daily QR Master Lead Research + +Schedule: daily, evening. + +Prompt should: + +1. call `/api/internal/revops-export?range=24h`, +2. identify promising leads, +3. research public company/domain signals, +4. draft personalized outreach emails from Timo, +5. send Timo an internal email briefing, +6. never send emails to leads. + +### Weekly QR Master Progress Review + +Schedule: Sunday evening. + +Prompt should: + +1. call `/api/internal/revops-export?range=7d`, +2. summarize users, QR creation, scans, activations, paid/upgrades, +3. identify bottlenecks, +4. suggest next-week goals, +5. email Timo the review. diff --git a/src/app/(main)/api/internal/revops-export/route.ts b/src/app/(main)/api/internal/revops-export/route.ts new file mode 100644 index 0000000..3f66b88 --- /dev/null +++ b/src/app/(main)/api/internal/revops-export/route.ts @@ -0,0 +1,463 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getEmailDomain } from '@/lib/revops'; +import { getMetricSnapshot, getUpgradeCandidateBadges } from '@/lib/revops-server'; + +export const dynamic = 'force-dynamic'; + +type RangePreset = '24h' | '7d' | '30d' | 'all'; + +const DEFAULT_LIMIT = 100; +const MAX_LIMIT = 500; + +function isAuthorized(request: NextRequest): boolean { + const authHeader = request.headers.get('authorization'); + const token = process.env.INTERNAL_API_SECRET || process.env.CRON_SECRET; + + if (!token) return false; + return authHeader === `Bearer ${token}`; +} + +function parsePositiveInt(value: string | null, fallback: number, max: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(Math.floor(parsed), max); +} + +function parseDate(value: string | null): Date | null { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function resolveDateRange(request: NextRequest) { + const now = new Date(); + const explicitFrom = parseDate(request.nextUrl.searchParams.get('from')); + const explicitTo = parseDate(request.nextUrl.searchParams.get('to')); + const range = (request.nextUrl.searchParams.get('range') || '24h') as RangePreset; + + if (explicitFrom || explicitTo) { + return { + from: explicitFrom, + to: explicitTo ?? now, + preset: 'custom', + }; + } + + if (range === 'all') { + return { from: null, to: now, preset: range }; + } + + const hours = range === '7d' ? 24 * 7 : range === '30d' ? 24 * 30 : 24; + return { + from: new Date(now.getTime() - hours * 60 * 60 * 1000), + to: now, + preset: range, + }; +} + +function withinRange(date: Date | null | undefined, from: Date | null, to: Date) { + if (!date) return false; + return (!from || date >= from) && date <= to; +} + +function jsonDate(value: Date | null | undefined) { + return value ? value.toISOString() : null; +} + +function safeJson(value: unknown) { + return value ?? null; +} + +function summarizeScans(scans: Array<{ + ts: Date; + isUnique: boolean; + country: string | null; + device: string | null; + os: string | null; + referrer: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; +}>) { + const countries = new Map(); + const devices = new Map(); + const os = new Map(); + const referrers = new Map(); + const utmSources = new Map(); + const utmCampaigns = new Map(); + const scansByDay = new Map(); + + for (const scan of scans) { + const day = scan.ts.toISOString().slice(0, 10); + scansByDay.set(day, (scansByDay.get(day) || 0) + 1); + + const increment = (map: Map, key?: string | null) => { + if (!key) return; + map.set(key, (map.get(key) || 0) + 1); + }; + + increment(countries, scan.country); + increment(devices, scan.device); + increment(os, scan.os); + increment(referrers, scan.referrer); + increment(utmSources, scan.utmSource); + increment(utmCampaigns, scan.utmCampaign); + } + + const top = (map: Map) => + Array.from(map.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([key, count]) => ({ key, count })); + + return { + total: scans.length, + unique: scans.filter((scan) => scan.isUnique).length, + firstScanAt: jsonDate(scans[0]?.ts), + lastScanAt: jsonDate(scans[scans.length - 1]?.ts), + byDay: Array.from(scansByDay.entries()).map(([date, count]) => ({ date, count })), + topCountries: top(countries), + topDevices: top(devices), + topOperatingSystems: top(os), + topReferrers: top(referrers), + topUtmSources: top(utmSources), + topUtmCampaigns: top(utmCampaigns), + }; +} + +export async function GET(request: NextRequest) { + try { + if (!isAuthorized(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { from, to, preset } = resolveDateRange(request); + const limit = parsePositiveInt(request.nextUrl.searchParams.get('limit'), DEFAULT_LIMIT, MAX_LIMIT); + const includeAllUsers = request.nextUrl.searchParams.get('allUsers') === 'true' || preset === 'all'; + + const users = await db.user.findMany({ + where: includeAllUsers || !from ? undefined : { + OR: [ + { createdAt: { gte: from, lte: to } }, + { updatedAt: { gte: from, lte: to } }, + { firstQrCreatedAt: { gte: from, lte: to } }, + { firstDynamicQrAt: { gte: from, lte: to } }, + { firstStaticQrAt: { gte: from, lte: to } }, + { firstScanAt: { gte: from, lte: to } }, + { activationAt: { gte: from, lte: to } }, + { lastQualifiedAt: { gte: from, lte: to } }, + { lastScoredAt: { gte: from, lte: to } }, + { qrCodes: { some: { createdAt: { gte: from, lte: to } } } }, + { qrCodes: { some: { scans: { some: { ts: { gte: from, lte: to } } } } } }, + ], + }, + orderBy: [ + { leadScore: 'desc' }, + { createdAt: 'desc' }, + ], + take: limit, + select: { + id: true, + email: true, + name: true, + image: true, + emailVerified: true, + createdAt: true, + updatedAt: true, + plan: true, + stripeCustomerId: true, + stripeSubscriptionId: true, + stripePriceId: true, + stripeCurrentPeriodEnd: true, + signupSource: true, + signupSourceSelfReported: true, + signupMedium: true, + signupCampaign: true, + signupContent: true, + signupTerm: true, + signupReferrer: true, + signupLandingPath: true, + signupFirstSeenAt: true, + emailDomain: true, + primaryUseCase: true, + primaryGoal: true, + jobRole: true, + companyName: true, + companyWebsite: true, + teamSizeBucket: true, + onboardingStartedAt: true, + sourceConfirmedAt: true, + useCaseSelectedAt: true, + goalSelectedAt: true, + profileCompletedAt: true, + firstQrCreatedAt: true, + firstDynamicQrAt: true, + firstStaticQrAt: true, + firstScanAt: true, + activationAt: true, + onboardingCompletedAt: true, + fitScore: true, + intentScore: true, + leadScore: true, + lifecycleStage: true, + lastQualifiedAt: true, + lastScoredAt: true, + qrCodes: { + orderBy: { createdAt: 'desc' }, + take: 50, + select: { + id: true, + title: true, + type: true, + contentType: true, + content: true, + tags: true, + status: true, + style: true, + slug: true, + createdAt: true, + updatedAt: true, + scans: { + orderBy: { ts: 'asc' }, + take: 500, + select: { + id: true, + ts: true, + device: true, + os: true, + country: true, + referrer: true, + utmSource: true, + utmMedium: true, + utmCampaign: true, + isUnique: true, + }, + }, + _count: { + select: { scans: true }, + }, + }, + }, + lifecycleLogs: { + orderBy: { createdAt: 'desc' }, + take: 10, + select: { + fromStage: true, + toStage: true, + fitScore: true, + intentScore: true, + leadScore: true, + reason: true, + createdAt: true, + }, + }, + integrations: { + select: { + provider: true, + status: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + }); + + const leads = await db.lead.findMany({ + where: from ? { createdAt: { gte: from, lte: to } } : undefined, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + email: true, + source: true, + reprintCost: true, + updatesPerYear: true, + annualSavings: true, + createdAt: true, + updatedAt: true, + }, + }); + + const newsletterSubscriptions = await db.newsletterSubscription.findMany({ + where: from ? { createdAt: { gte: from, lte: to } } : undefined, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + email: true, + source: true, + status: true, + createdAt: true, + updatedAt: true, + }, + }); + + const exportedUsers = users.map((user) => { + const metrics = getMetricSnapshot(user.qrCodes); + const allScans = user.qrCodes.flatMap((qr) => qr.scans).sort((a, b) => a.ts.getTime() - b.ts.getTime()); + const rangeScans = allScans.filter((scan) => withinRange(scan.ts, from, to)); + + return { + id: user.id, + name: user.name, + email: user.email, + emailDomain: user.emailDomain || getEmailDomain(user.email), + image: user.image, + emailVerified: jsonDate(user.emailVerified), + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + plan: user.plan, + stripe: { + hasCustomer: Boolean(user.stripeCustomerId), + hasSubscription: Boolean(user.stripeSubscriptionId), + priceId: user.stripePriceId, + currentPeriodEnd: jsonDate(user.stripeCurrentPeriodEnd), + }, + attribution: { + signupSource: user.signupSource, + signupSourceSelfReported: user.signupSourceSelfReported, + signupMedium: user.signupMedium, + signupCampaign: user.signupCampaign, + signupContent: user.signupContent, + signupTerm: user.signupTerm, + signupReferrer: user.signupReferrer, + signupLandingPath: user.signupLandingPath, + signupFirstSeenAt: jsonDate(user.signupFirstSeenAt), + }, + onboarding: { + primaryUseCase: user.primaryUseCase, + primaryGoal: user.primaryGoal, + jobRole: user.jobRole, + companyName: user.companyName, + companyWebsite: user.companyWebsite, + teamSizeBucket: user.teamSizeBucket, + onboardingStartedAt: jsonDate(user.onboardingStartedAt), + sourceConfirmedAt: jsonDate(user.sourceConfirmedAt), + useCaseSelectedAt: jsonDate(user.useCaseSelectedAt), + goalSelectedAt: jsonDate(user.goalSelectedAt), + profileCompletedAt: jsonDate(user.profileCompletedAt), + onboardingCompletedAt: jsonDate(user.onboardingCompletedAt), + }, + lifecycle: { + fitScore: user.fitScore, + intentScore: user.intentScore, + leadScore: user.leadScore, + lifecycleStage: user.lifecycleStage, + firstQrCreatedAt: jsonDate(user.firstQrCreatedAt), + firstDynamicQrAt: jsonDate(user.firstDynamicQrAt), + firstStaticQrAt: jsonDate(user.firstStaticQrAt), + firstScanAt: jsonDate(user.firstScanAt), + activationAt: jsonDate(user.activationAt), + lastQualifiedAt: jsonDate(user.lastQualifiedAt), + lastScoredAt: jsonDate(user.lastScoredAt), + upgradeBadges: getUpgradeCandidateBadges(user, metrics), + logs: user.lifecycleLogs.map((log) => ({ + ...log, + createdAt: log.createdAt.toISOString(), + })), + }, + stats: { + qrCount: metrics.qrCount, + dynamicQrCount: metrics.dynamicQrCount, + staticQrCount: metrics.qrCount - metrics.dynamicQrCount, + contentTypeCount: metrics.contentTypeCount, + businessishTypeCount: metrics.businessishTypeCount, + scanCount: metrics.scanCount, + scansInRange: rangeScans.length, + uniqueScansInRange: rangeScans.filter((scan) => scan.isUnique).length, + }, + scans: summarizeScans(allScans), + scansInRange: summarizeScans(rangeScans), + qrCodes: user.qrCodes.map((qr) => { + const qrScansInRange = qr.scans.filter((scan) => withinRange(scan.ts, from, to)); + return { + id: qr.id, + title: qr.title, + type: qr.type, + contentType: qr.contentType, + content: safeJson(qr.content), + tags: qr.tags, + status: qr.status, + style: safeJson(qr.style), + slug: qr.slug, + publicPath: `/r/${qr.slug}`, + createdAt: qr.createdAt.toISOString(), + updatedAt: qr.updatedAt.toISOString(), + scanCount: qr._count.scans, + scansInRange: qrScansInRange.length, + firstScanAt: jsonDate(qr.scans[0]?.ts), + lastScanAt: jsonDate(qr.scans[qr.scans.length - 1]?.ts), + scanSummary: summarizeScans(qr.scans), + scanSummaryInRange: summarizeScans(qrScansInRange), + }; + }), + integrations: user.integrations.map((integration) => ({ + ...integration, + createdAt: integration.createdAt.toISOString(), + updatedAt: integration.updatedAt.toISOString(), + })), + }; + }); + + const allQrs = exportedUsers.flatMap((user) => user.qrCodes.map((qr) => ({ userId: user.id, ...qr }))); + const qrsInRange = allQrs.filter((qr) => withinRange(new Date(qr.createdAt), from, to)); + const scansInRange = exportedUsers.reduce((sum, user) => sum + user.stats.scansInRange, 0); + const uniqueScansInRange = exportedUsers.reduce((sum, user) => sum + user.stats.uniqueScansInRange, 0); + + const contentTypeCounts = new Map(); + for (const qr of allQrs) { + contentTypeCounts.set(qr.contentType, (contentTypeCounts.get(qr.contentType) || 0) + 1); + } + + return NextResponse.json({ + ok: true, + generatedAt: new Date().toISOString(), + endpoint: '/api/internal/revops-export', + range: { + preset, + from: jsonDate(from), + to: to.toISOString(), + }, + limits: { + requestedUserLimit: limit, + maxUserLimit: MAX_LIMIT, + qrLimitPerUser: 50, + scanLimitPerQr: 500, + }, + overview: { + exportedUsers: exportedUsers.length, + newUsersInRange: exportedUsers.filter((user) => withinRange(new Date(user.createdAt), from, to)).length, + activatedUsersInRange: exportedUsers.filter((user) => withinRange(user.lifecycle.activationAt ? new Date(user.lifecycle.activationAt) : null, from, to)).length, + paidUsers: exportedUsers.filter((user) => user.plan === 'PRO' || user.plan === 'BUSINESS').length, + hotUsers: exportedUsers.filter((user) => user.lifecycle.lifecycleStage === 'hot').length, + upgradeCandidates: exportedUsers.filter((user) => user.lifecycle.lifecycleStage === 'upgrade_candidate').length, + qrCodesTotal: allQrs.length, + qrCodesCreatedInRange: qrsInRange.length, + scansInRange, + uniqueScansInRange, + reprintCalculatorLeadsInRange: leads.length, + newsletterSubscriptionsInRange: newsletterSubscriptions.length, + }, + breakdowns: { + qrContentTypes: Array.from(contentTypeCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([contentType, count]) => ({ contentType, count })), + }, + users: exportedUsers, + leads: leads.map((lead) => ({ + ...lead, + createdAt: lead.createdAt.toISOString(), + updatedAt: lead.updatedAt.toISOString(), + })), + newsletterSubscriptions: newsletterSubscriptions.map((subscription) => ({ + ...subscription, + createdAt: subscription.createdAt.toISOString(), + updatedAt: subscription.updatedAt.toISOString(), + })), + }); + } catch (error) { + console.error('Error exporting internal RevOps data:', error); + return NextResponse.json({ error: 'Failed to export RevOps data' }, { status: 500 }); + } +}