TikTok api
This commit is contained in:
@@ -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 & 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 & 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
92
src/app/(main)/api/tiktok/callback/route.ts
Normal file
92
src/app/(main)/api/tiktok/callback/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
41
src/app/(main)/api/tiktok/connect/route.ts
Normal file
41
src/app/(main)/api/tiktok/connect/route.ts
Normal 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;
|
||||
}
|
||||
41
src/app/(main)/api/tiktok/token/route.ts
Normal file
41
src/app/(main)/api/tiktok/token/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user