Add internal RevOps export API
This commit is contained in:
@@ -53,6 +53,7 @@ services:
|
|||||||
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050}
|
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}
|
IP_SALT: ${IP_SALT:-your-salt-change-in-production}
|
||||||
ENABLE_DEMO: ${ENABLE_DEMO:-false}
|
ENABLE_DEMO: ${ENABLE_DEMO:-false}
|
||||||
NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true}
|
NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true}
|
||||||
|
|||||||
131
docs/automations/qrmaster-internal-revops-export.md
Normal file
131
docs/automations/qrmaster-internal-revops-export.md
Normal file
@@ -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 <INTERNAL_API_SECRET>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
463
src/app/(main)/api/internal/revops-export/route.ts
Normal file
463
src/app/(main)/api/internal/revops-export/route.ts
Normal file
@@ -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<string, number>();
|
||||||
|
const devices = new Map<string, number>();
|
||||||
|
const os = new Map<string, number>();
|
||||||
|
const referrers = new Map<string, number>();
|
||||||
|
const utmSources = new Map<string, number>();
|
||||||
|
const utmCampaigns = new Map<string, number>();
|
||||||
|
const scansByDay = new Map<string, number>();
|
||||||
|
|
||||||
|
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<string, number>, 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<string, number>) =>
|
||||||
|
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<string, number>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user