TikTok api

This commit is contained in:
Timo Knuth
2026-07-02 13:06:50 +02:00
parent b0b70640ab
commit 0b9c8d2a8f
12 changed files with 1965 additions and 1712 deletions

View File

@@ -1,463 +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 });
}
}
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 });
}
}

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { TIKTOK_ACCOUNT_KEY, TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok';
const textResponse = (body: string, status: number) => {
const response = new NextResponse(body, {
status,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
response.cookies.delete(TIKTOK_OAUTH_STATE_COOKIE_NAME);
return response;
};
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
const savedState = request.cookies.get(TIKTOK_OAUTH_STATE_COOKIE_NAME)?.value;
if (error) {
return textResponse(`TikTok authorization failed: ${errorDescription || error}`, 400);
}
if (!code) {
return textResponse('Missing authorization code.', 400);
}
if (!state || !savedState || state !== savedState) {
return textResponse('Invalid OAuth state. Start over at /api/tiktok/connect.', 403);
}
const clientKey = process.env.TIKTOK_CLIENT_KEY;
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
if (!clientKey || !clientSecret) {
return textResponse('TikTok client credentials are not configured.', 500);
}
const redirectUri =
process.env.TIKTOK_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`;
try {
const tokenResponse = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_key: clientKey,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
}),
});
const tokens = await tokenResponse.json();
if (!tokenResponse.ok || tokens.error) {
throw new Error(tokens.error_description || tokens.error || 'TikTok token exchange failed');
}
const now = Date.now();
const accessTokenExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000);
const refreshTokenExpiresAt = tokens.refresh_expires_in
? new Date(now + Number(tokens.refresh_expires_in) * 1000)
: null;
await db.tiktokIntegration.upsert({
where: { accountKey: TIKTOK_ACCOUNT_KEY },
create: {
accountKey: TIKTOK_ACCOUNT_KEY,
openId: tokens.open_id,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
scope: tokens.scope || null,
accessTokenExpiresAt,
refreshTokenExpiresAt,
},
update: {
openId: tokens.open_id,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
scope: tokens.scope || null,
accessTokenExpiresAt,
refreshTokenExpiresAt,
},
});
return textResponse('TikTok account connected. You can close this tab.', 200);
} catch (err) {
console.error('TikTok callback error:', err);
const message = err instanceof Error ? err.message : 'Unknown error';
return textResponse(`Failed to connect TikTok account: ${message}`, 502);
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok';
const isProduction = process.env.NODE_ENV === 'production';
export async function GET(request: NextRequest) {
const adminKey = process.env.TIKTOK_ADMIN_KEY;
if (adminKey) {
const provided = request.nextUrl.searchParams.get('key');
if (provided !== adminKey) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
const clientKey = process.env.TIKTOK_CLIENT_KEY;
if (!clientKey) {
return NextResponse.json({ error: 'TIKTOK_CLIENT_KEY not configured' }, { status: 500 });
}
const redirectUri =
process.env.TIKTOK_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`;
const oauthState = crypto.randomUUID();
const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/');
authUrl.searchParams.set('client_key', clientKey);
authUrl.searchParams.set('scope', 'user.info.basic,video.publish');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('state', oauthState);
const response = NextResponse.redirect(authUrl);
response.cookies.set(TIKTOK_OAUTH_STATE_COOKIE_NAME, oauthState, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
});
return response;
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { getValidTiktokTokens } from '@/lib/tiktok';
export async function GET(request: NextRequest) {
const adminKey = process.env.TIKTOK_ADMIN_KEY;
if (!adminKey) {
// Unlike /connect, this endpoint hands out live credentials — never expose
// it without a configured key.
return NextResponse.json(
{ error: 'TIKTOK_ADMIN_KEY must be configured to expose tokens' },
{ status: 500 }
);
}
const provided =
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
if (provided !== adminKey) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const tokens = await getValidTiktokTokens();
if (!tokens) {
return NextResponse.json(
{ error: 'No TikTok account connected. Visit /api/tiktok/connect first.' },
{ status: 404 }
);
}
return NextResponse.json({
access_token: tokens.accessToken,
open_id: tokens.openId,
scope: tokens.scope,
expires_at: tokens.accessTokenExpiresAt.toISOString(),
});
} catch (err) {
console.error('TikTok token endpoint error:', err);
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 502 });
}
}