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,147 +1,147 @@
import React from 'react';
import Link from 'next/link';
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
export const metadata = {
title: 'Terms of Service | QR Master',
description: 'Read the QR Master Terms of Service to understand the rules, rights, and responsibilities that apply when you use our QR code platform.',
openGraph: {
title: 'Terms of Service | QR Master',
description: 'Read the QR Master Terms of Service to understand the rules, rights, and responsibilities that apply when you use our QR code platform.',
url: 'https://www.qrmaster.net/terms',
type: 'website',
images: ['/og-image.png'],
},
};
export default function TermsPage() {
return (
<div className="min-h-screen bg-white py-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
<div className="mb-8">
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
Back to Home
</Link>
</div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">Terms of Service</h1>
<p className="text-gray-600 mb-8">Last updated: July 2026</p>
<div className="prose prose-lg max-w-none">
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Acceptance of Terms</h2>
<p className="text-gray-700 mb-4">
Welcome to QR Master ("we," "our," or "us"). By accessing or using our website and services, you agree to be
bound by these Terms of Service. If you do not agree to these terms, please do not use our services.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Description of Service</h2>
<p className="text-gray-700 mb-4">
QR Master provides a platform for creating and managing static and dynamic QR codes, including scan
analytics, customization tools, and bulk creation features. We may add, change, or remove features at any
time.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. Accounts</h2>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li>You are responsible for maintaining the confidentiality of your account credentials and for all activity under your account.</li>
<li>You must be at least 16 years old to use our services.</li>
<li>You must provide accurate account information and keep it up to date.</li>
<li>You may delete your account at any time from your account settings.</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. Subscriptions &amp; Payments</h2>
<p className="text-gray-700 mb-4">
QR Master offers FREE, PRO, and BUSINESS subscription plans. Paid subscriptions are billed through Stripe
and renew automatically until cancelled. You can manage or cancel your subscription at any time through
the customer billing portal in your account settings.
</p>
<p className="text-gray-700 mb-4">
Fees are non-refundable except where required by applicable law. Downgrading or cancelling a plan may
affect the availability of dynamic QR codes and other paid features.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Acceptable Use</h2>
<p className="text-gray-700 mb-4">You agree not to use QR Master to:</p>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li>Create or distribute QR codes linking to illegal, fraudulent, or harmful content</li>
<li>Spam, phish, or mislead scanners about the destination of a QR code</li>
<li>Attempt to circumvent rate limits, security measures, or plan restrictions</li>
<li>Interfere with or disrupt the integrity or performance of our services</li>
<li>Reverse engineer or resell access to our services without authorization</li>
</ul>
<p className="text-gray-700 mb-4">
We reserve the right to disable QR codes or accounts that violate this policy.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Intellectual Property</h2>
<p className="text-gray-700 mb-4">
QR Master and its original content, features, and functionality are owned by us and are protected by
copyright, trademark, and other intellectual property laws. You retain all rights to the content and
destinations you associate with your own QR codes.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">7. Termination</h2>
<p className="text-gray-700 mb-4">
We may suspend or terminate your access to QR Master at any time if you violate these terms or misuse our
services. You may stop using our services and delete your account at any time.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">8. Disclaimers &amp; Limitation of Liability</h2>
<p className="text-gray-700 mb-4">
Our services are provided "as is" and "as available" without warranties of any kind. We do not control
and are not responsible for the content that a QR code links to once you set its destination. To the
maximum extent permitted by law, QR Master shall not be liable for any indirect, incidental, or
consequential damages arising from your use of the services.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">9. Changes to These Terms</h2>
<p className="text-gray-700 mb-4">
We may update these Terms of Service from time to time. We will update the "Last updated" date above when
changes are made. Continued use of our services after changes take effect constitutes acceptance of the
revised terms.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">10. Contact Us</h2>
<p className="text-gray-700 mb-4">
If you have questions about these Terms of Service, please contact us:
</p>
<div className="bg-gray-50 p-6 rounded-lg">
<p className="text-gray-700 mb-2">
<strong>Email:</strong>{' '}
<ObfuscatedMailto email="support@qrmaster.net" className="text-primary-600 hover:text-primary-700" />
</p>
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p>
</div>
</section>
</div>
<div className="mt-12 pt-8 border-t border-gray-200">
<p className="text-gray-600 text-center">
<Link href="/" className="text-primary-600 hover:text-primary-700">
Back to Home
</Link>
</p>
</div>
</div>
</div>
);
}
import React from 'react';
import Link from 'next/link';
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
export const metadata = {
title: 'Terms of Service | QR Master',
description: 'Read the QR Master Terms of Service to understand the rules, rights, and responsibilities that apply when you use our QR code platform.',
openGraph: {
title: 'Terms of Service | QR Master',
description: 'Read the QR Master Terms of Service to understand the rules, rights, and responsibilities that apply when you use our QR code platform.',
url: 'https://www.qrmaster.net/terms',
type: 'website',
images: ['/og-image.png'],
},
};
export default function TermsPage() {
return (
<div className="min-h-screen bg-white py-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
<div className="mb-8">
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
Back to Home
</Link>
</div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">Terms of Service</h1>
<p className="text-gray-600 mb-8">Last updated: July 2026</p>
<div className="prose prose-lg max-w-none">
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Acceptance of Terms</h2>
<p className="text-gray-700 mb-4">
Welcome to QR Master ("we," "our," or "us"). By accessing or using our website and services, you agree to be
bound by these Terms of Service. If you do not agree to these terms, please do not use our services.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Description of Service</h2>
<p className="text-gray-700 mb-4">
QR Master provides a platform for creating and managing static and dynamic QR codes, including scan
analytics, customization tools, and bulk creation features. We may add, change, or remove features at any
time.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. Accounts</h2>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li>You are responsible for maintaining the confidentiality of your account credentials and for all activity under your account.</li>
<li>You must be at least 16 years old to use our services.</li>
<li>You must provide accurate account information and keep it up to date.</li>
<li>You may delete your account at any time from your account settings.</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. Subscriptions &amp; Payments</h2>
<p className="text-gray-700 mb-4">
QR Master offers FREE, PRO, and BUSINESS subscription plans. Paid subscriptions are billed through Stripe
and renew automatically until cancelled. You can manage or cancel your subscription at any time through
the customer billing portal in your account settings.
</p>
<p className="text-gray-700 mb-4">
Fees are non-refundable except where required by applicable law. Downgrading or cancelling a plan may
affect the availability of dynamic QR codes and other paid features.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Acceptable Use</h2>
<p className="text-gray-700 mb-4">You agree not to use QR Master to:</p>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li>Create or distribute QR codes linking to illegal, fraudulent, or harmful content</li>
<li>Spam, phish, or mislead scanners about the destination of a QR code</li>
<li>Attempt to circumvent rate limits, security measures, or plan restrictions</li>
<li>Interfere with or disrupt the integrity or performance of our services</li>
<li>Reverse engineer or resell access to our services without authorization</li>
</ul>
<p className="text-gray-700 mb-4">
We reserve the right to disable QR codes or accounts that violate this policy.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Intellectual Property</h2>
<p className="text-gray-700 mb-4">
QR Master and its original content, features, and functionality are owned by us and are protected by
copyright, trademark, and other intellectual property laws. You retain all rights to the content and
destinations you associate with your own QR codes.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">7. Termination</h2>
<p className="text-gray-700 mb-4">
We may suspend or terminate your access to QR Master at any time if you violate these terms or misuse our
services. You may stop using our services and delete your account at any time.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">8. Disclaimers &amp; Limitation of Liability</h2>
<p className="text-gray-700 mb-4">
Our services are provided "as is" and "as available" without warranties of any kind. We do not control
and are not responsible for the content that a QR code links to once you set its destination. To the
maximum extent permitted by law, QR Master shall not be liable for any indirect, incidental, or
consequential damages arising from your use of the services.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">9. Changes to These Terms</h2>
<p className="text-gray-700 mb-4">
We may update these Terms of Service from time to time. We will update the "Last updated" date above when
changes are made. Continued use of our services after changes take effect constitutes acceptance of the
revised terms.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">10. Contact Us</h2>
<p className="text-gray-700 mb-4">
If you have questions about these Terms of Service, please contact us:
</p>
<div className="bg-gray-50 p-6 rounded-lg">
<p className="text-gray-700 mb-2">
<strong>Email:</strong>{' '}
<ObfuscatedMailto email="support@qrmaster.net" className="text-primary-600 hover:text-primary-700" />
</p>
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p>
</div>
</section>
</div>
<div className="mt-12 pt-8 border-t border-gray-200">
<p className="text-gray-600 text-center">
<Link href="/" className="text-primary-600 hover:text-primary-700">
Back to Home
</Link>
</p>
</div>
</div>
</div>
);
}

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 });
}
}