470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { cookies } from 'next/headers';
|
|
import {
|
|
getGoalLabel,
|
|
getLifecycleStageLabel,
|
|
getRoleLabel,
|
|
getSourceLabel,
|
|
getTeamSizeLabel,
|
|
getUseCaseLabel,
|
|
} from '@/lib/revops';
|
|
import { db } from '@/lib/db';
|
|
import { getMetricSnapshot, getUpgradeCandidateBadges } from '@/lib/revops-server';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
type HydratedUser = {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
emailDomain: string | null;
|
|
plan: string;
|
|
lifecycleStage: string;
|
|
fitScore: number;
|
|
intentScore: number;
|
|
leadScore: number;
|
|
signupSource: string | null;
|
|
signupSourceSelfReported: string | null;
|
|
signupCampaign: string | null;
|
|
signupLandingPath: string | null;
|
|
primaryUseCase: string | null;
|
|
primaryGoal: string | null;
|
|
jobRole: string | null;
|
|
companyName: string | null;
|
|
companyWebsite: string | null;
|
|
teamSizeBucket: string | null;
|
|
createdAt: string;
|
|
firstQrCreatedAt: string | null;
|
|
activationAt: string | null;
|
|
firstDynamicQrAt: string | null;
|
|
qrCount: number;
|
|
dynamicQrCount: number;
|
|
scanCount: number;
|
|
contentTypeCount: number;
|
|
upgradeBadges: string[];
|
|
};
|
|
|
|
function hasAdminSession() {
|
|
const adminCookie = cookies().get('newsletter-admin');
|
|
return adminCookie?.value === 'authenticated';
|
|
}
|
|
|
|
function toIso(value: Date | null) {
|
|
return value ? value.toISOString() : null;
|
|
}
|
|
|
|
function safeDate(value: string | null) {
|
|
if (!value) return null;
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
}
|
|
|
|
function applyUserFilters(users: HydratedUser[], request: NextRequest) {
|
|
const stage = request.nextUrl.searchParams.get('stage');
|
|
const source = request.nextUrl.searchParams.get('source');
|
|
const campaign = request.nextUrl.searchParams.get('campaign');
|
|
const landingPath = request.nextUrl.searchParams.get('landingPath');
|
|
const useCase = request.nextUrl.searchParams.get('useCase');
|
|
const goal = request.nextUrl.searchParams.get('goal');
|
|
const role = request.nextUrl.searchParams.get('role');
|
|
const teamSize = request.nextUrl.searchParams.get('teamSize');
|
|
const plan = request.nextUrl.searchParams.get('plan');
|
|
const search = request.nextUrl.searchParams.get('search')?.toLowerCase().trim();
|
|
const from = safeDate(request.nextUrl.searchParams.get('from'));
|
|
const to = safeDate(request.nextUrl.searchParams.get('to'));
|
|
|
|
return users.filter((user) => {
|
|
const createdAt = new Date(user.createdAt);
|
|
const matchesSearch = !search || [
|
|
user.name,
|
|
user.email,
|
|
user.companyName,
|
|
user.emailDomain,
|
|
].filter(Boolean).some((value) => value!.toLowerCase().includes(search));
|
|
|
|
return (
|
|
(!stage || user.lifecycleStage === stage) &&
|
|
(!source || user.signupSource === source) &&
|
|
(!campaign || user.signupCampaign === campaign) &&
|
|
(!landingPath || user.signupLandingPath === landingPath) &&
|
|
(!useCase || user.primaryUseCase === useCase) &&
|
|
(!goal || user.primaryGoal === goal) &&
|
|
(!role || user.jobRole === role) &&
|
|
(!teamSize || user.teamSizeBucket === teamSize) &&
|
|
(!plan || user.plan === plan) &&
|
|
(!from || createdAt >= from) &&
|
|
(!to || createdAt <= to) &&
|
|
matchesSearch
|
|
);
|
|
});
|
|
}
|
|
|
|
function sortUsers(users: HydratedUser[], sort: string) {
|
|
const sorted = [...users];
|
|
|
|
sorted.sort((a, b) => {
|
|
switch (sort) {
|
|
case 'createdAt_asc':
|
|
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
case 'activationAt_desc':
|
|
return new Date(b.activationAt || 0).getTime() - new Date(a.activationAt || 0).getTime();
|
|
case 'leadScore_asc':
|
|
return a.leadScore - b.leadScore;
|
|
case 'fitScore_desc':
|
|
return b.fitScore - a.fitScore;
|
|
case 'intentScore_desc':
|
|
return b.intentScore - a.intentScore;
|
|
case 'leadScore_desc':
|
|
default:
|
|
return b.leadScore - a.leadScore || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
}
|
|
});
|
|
|
|
return sorted;
|
|
}
|
|
|
|
function buildGroupedRows(users: HydratedUser[], key: keyof HydratedUser) {
|
|
const rows = new Map<string, {
|
|
key: string;
|
|
signups: number;
|
|
firstQr: number;
|
|
activated: number;
|
|
hot: number;
|
|
upgradeCandidates: number;
|
|
paid: number;
|
|
}>();
|
|
|
|
users.forEach((user) => {
|
|
const rawValue = (user[key] as string | null) || 'unknown';
|
|
const row = rows.get(rawValue) || {
|
|
key: rawValue,
|
|
signups: 0,
|
|
firstQr: 0,
|
|
activated: 0,
|
|
hot: 0,
|
|
upgradeCandidates: 0,
|
|
paid: 0,
|
|
};
|
|
|
|
row.signups += 1;
|
|
if (user.firstQrCreatedAt) row.firstQr += 1;
|
|
if (user.activationAt) row.activated += 1;
|
|
if (user.lifecycleStage === 'hot') row.hot += 1;
|
|
if (user.lifecycleStage === 'upgrade_candidate') row.upgradeCandidates += 1;
|
|
if (user.lifecycleStage === 'paid') row.paid += 1;
|
|
|
|
rows.set(rawValue, row);
|
|
});
|
|
|
|
return Array.from(rows.values()).sort((a, b) => b.signups - a.signups);
|
|
}
|
|
|
|
function buildFunnel(users: HydratedUser[]) {
|
|
return {
|
|
signup: users.length,
|
|
sourceConfirmed: users.filter((user) => Boolean(user.signupSourceSelfReported)).length,
|
|
useCaseSelected: users.filter((user) => Boolean(user.primaryUseCase)).length,
|
|
goalSelected: users.filter((user) => Boolean(user.primaryGoal)).length,
|
|
profileCaptured: users.filter((user) => Boolean(user.jobRole && user.teamSizeBucket)).length,
|
|
firstQrCreated: users.filter((user) => Boolean(user.firstQrCreatedAt)).length,
|
|
firstDynamicQrCreated: users.filter((user) => Boolean(user.firstDynamicQrAt)).length,
|
|
activated: users.filter((user) => Boolean(user.activationAt)).length,
|
|
};
|
|
}
|
|
|
|
function buildLifecycleSummary(users: HydratedUser[]) {
|
|
return {
|
|
cold: users.filter((user) => user.lifecycleStage === 'cold').length,
|
|
activated: users.filter((user) => user.lifecycleStage === 'activated').length,
|
|
warm: users.filter((user) => user.lifecycleStage === 'warm').length,
|
|
hot: users.filter((user) => user.lifecycleStage === 'hot').length,
|
|
upgrade_candidate: users.filter((user) => user.lifecycleStage === 'upgrade_candidate').length,
|
|
paid: users.filter((user) => user.lifecycleStage === 'paid').length,
|
|
};
|
|
}
|
|
|
|
function buildCsv(rows: HydratedUser[]) {
|
|
const headers = [
|
|
'name',
|
|
'email',
|
|
'email_domain',
|
|
'plan',
|
|
'lifecycle_stage',
|
|
'fit_score',
|
|
'intent_score',
|
|
'lead_score',
|
|
'source',
|
|
'self_reported_source',
|
|
'campaign',
|
|
'landing_page',
|
|
'use_case',
|
|
'goal',
|
|
'role',
|
|
'company',
|
|
'team_size',
|
|
'created_at',
|
|
'first_qr_created_at',
|
|
'activation_at',
|
|
'qr_count',
|
|
'dynamic_qr_count',
|
|
'scan_count',
|
|
];
|
|
|
|
const escape = (value: string | number | null) => {
|
|
const normalized = value == null ? '' : String(value);
|
|
return `"${normalized.replace(/"/g, '""')}"`;
|
|
};
|
|
|
|
const lines = rows.map((row) => [
|
|
row.name,
|
|
row.email,
|
|
row.emailDomain,
|
|
row.plan,
|
|
row.lifecycleStage,
|
|
row.fitScore,
|
|
row.intentScore,
|
|
row.leadScore,
|
|
row.signupSource,
|
|
row.signupSourceSelfReported,
|
|
row.signupCampaign,
|
|
row.signupLandingPath,
|
|
row.primaryUseCase,
|
|
row.primaryGoal,
|
|
row.jobRole,
|
|
row.companyName,
|
|
row.teamSizeBucket,
|
|
row.createdAt,
|
|
row.firstQrCreatedAt,
|
|
row.activationAt,
|
|
row.qrCount,
|
|
row.dynamicQrCount,
|
|
row.scanCount,
|
|
].map(escape).join(','));
|
|
|
|
return [headers.join(','), ...lines].join('\n');
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
if (!hasAdminSession()) {
|
|
return NextResponse.json({ error: 'Unauthorized - Admin login required' }, { status: 401 });
|
|
}
|
|
|
|
const rawUsers = await db.user.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
emailDomain: true,
|
|
plan: true,
|
|
lifecycleStage: true,
|
|
fitScore: true,
|
|
intentScore: true,
|
|
leadScore: true,
|
|
signupSource: true,
|
|
signupSourceSelfReported: true,
|
|
signupCampaign: true,
|
|
signupLandingPath: true,
|
|
primaryUseCase: true,
|
|
primaryGoal: true,
|
|
jobRole: true,
|
|
companyName: true,
|
|
companyWebsite: true,
|
|
teamSizeBucket: true,
|
|
createdAt: true,
|
|
firstQrCreatedAt: true,
|
|
firstDynamicQrAt: true,
|
|
activationAt: true,
|
|
qrCodes: {
|
|
select: {
|
|
type: true,
|
|
contentType: true,
|
|
createdAt: true,
|
|
_count: {
|
|
select: {
|
|
scans: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
});
|
|
|
|
const recentBillingLogs = await db.userLifecycleLog.findMany({
|
|
where: {
|
|
reason: {
|
|
startsWith: 'subscription_',
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
take: 10,
|
|
select: {
|
|
fromStage: true,
|
|
toStage: true,
|
|
reason: true,
|
|
createdAt: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
plan: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const users: HydratedUser[] = rawUsers.map((user) => {
|
|
const metrics = getMetricSnapshot(user.qrCodes);
|
|
|
|
return {
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
emailDomain: user.emailDomain,
|
|
plan: user.plan,
|
|
lifecycleStage: user.lifecycleStage,
|
|
fitScore: user.fitScore,
|
|
intentScore: user.intentScore,
|
|
leadScore: user.leadScore,
|
|
signupSource: user.signupSource,
|
|
signupSourceSelfReported: user.signupSourceSelfReported,
|
|
signupCampaign: user.signupCampaign,
|
|
signupLandingPath: user.signupLandingPath,
|
|
primaryUseCase: user.primaryUseCase,
|
|
primaryGoal: user.primaryGoal,
|
|
jobRole: user.jobRole,
|
|
companyName: user.companyName,
|
|
companyWebsite: user.companyWebsite,
|
|
teamSizeBucket: user.teamSizeBucket,
|
|
createdAt: user.createdAt.toISOString(),
|
|
firstQrCreatedAt: toIso(user.firstQrCreatedAt),
|
|
activationAt: toIso(user.activationAt),
|
|
firstDynamicQrAt: toIso(user.firstDynamicQrAt),
|
|
qrCount: metrics.qrCount,
|
|
dynamicQrCount: metrics.dynamicQrCount,
|
|
scanCount: metrics.scanCount,
|
|
contentTypeCount: metrics.contentTypeCount,
|
|
upgradeBadges: getUpgradeCandidateBadges(user, metrics),
|
|
};
|
|
});
|
|
|
|
const filteredUsers = sortUsers(
|
|
applyUserFilters(users, request),
|
|
request.nextUrl.searchParams.get('sort') || 'leadScore_desc'
|
|
);
|
|
|
|
if (request.nextUrl.searchParams.get('format') === 'csv') {
|
|
const csv = buildCsv(filteredUsers);
|
|
return new NextResponse(csv, {
|
|
headers: {
|
|
'Content-Type': 'text/csv; charset=utf-8',
|
|
'Content-Disposition': 'attachment; filename="qrmaster-revops-export.csv"',
|
|
},
|
|
});
|
|
}
|
|
|
|
const page = Number(request.nextUrl.searchParams.get('page') || '1');
|
|
const pageSize = Number(request.nextUrl.searchParams.get('pageSize') || '25');
|
|
const total = filteredUsers.length;
|
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
const paginatedUsers = filteredUsers.slice((page - 1) * pageSize, page * pageSize);
|
|
|
|
const acquisitionBySource = buildGroupedRows(users, 'signupSource').map((row) => ({
|
|
...row,
|
|
label: getSourceLabel(row.key),
|
|
activationRate: row.signups ? Math.round((row.activated / row.signups) * 100) : 0,
|
|
}));
|
|
const acquisitionByCampaign = buildGroupedRows(users, 'signupCampaign');
|
|
const acquisitionByLandingPath = buildGroupedRows(users, 'signupLandingPath');
|
|
const funnel = buildFunnel(users);
|
|
const lifecycleSummary = buildLifecycleSummary(users);
|
|
|
|
const mismatchCount = users.filter(
|
|
(user) =>
|
|
user.signupSource &&
|
|
user.signupSourceSelfReported &&
|
|
user.signupSource !== user.signupSourceSelfReported
|
|
).length;
|
|
|
|
const upgradeCandidates = users
|
|
.filter((user) => user.plan === 'FREE' && user.lifecycleStage === 'upgrade_candidate')
|
|
.sort((a, b) => b.leadScore - a.leadScore)
|
|
.slice(0, 25);
|
|
|
|
const recentBillingActivity = recentBillingLogs.map((log) => ({
|
|
userId: log.user.id,
|
|
name: log.user.name,
|
|
email: log.user.email,
|
|
plan: log.user.plan,
|
|
reason: log.reason,
|
|
fromStage: log.fromStage,
|
|
toStage: log.toStage,
|
|
createdAt: log.createdAt.toISOString(),
|
|
}));
|
|
|
|
const filterOptions = {
|
|
stages: ['cold', 'activated', 'warm', 'hot', 'upgrade_candidate', 'paid'],
|
|
sources: Array.from(new Set(users.map((user) => user.signupSource).filter((value): value is string => Boolean(value)))),
|
|
campaigns: Array.from(new Set(users.map((user) => user.signupCampaign).filter((value): value is string => Boolean(value)))),
|
|
landingPaths: Array.from(new Set(users.map((user) => user.signupLandingPath).filter((value): value is string => Boolean(value)))),
|
|
useCases: Array.from(new Set(users.map((user) => user.primaryUseCase).filter((value): value is string => Boolean(value)))),
|
|
goals: Array.from(new Set(users.map((user) => user.primaryGoal).filter((value): value is string => Boolean(value)))),
|
|
roles: Array.from(new Set(users.map((user) => user.jobRole).filter((value): value is string => Boolean(value)))),
|
|
teamSizes: Array.from(new Set(users.map((user) => user.teamSizeBucket).filter((value): value is string => Boolean(value)))),
|
|
plans: Array.from(new Set(users.map((user) => user.plan).filter((value): value is string => Boolean(value)))),
|
|
};
|
|
|
|
return NextResponse.json({
|
|
overview: {
|
|
totalUsers: users.length,
|
|
mismatchCount,
|
|
activatedUsers: funnel.activated,
|
|
paidUsers: lifecycleSummary.paid,
|
|
recentBillingEvents: recentBillingActivity.length,
|
|
},
|
|
acquisition: {
|
|
bySource: acquisitionBySource,
|
|
byCampaign: acquisitionByCampaign.slice(0, 15),
|
|
byLandingPath: acquisitionByLandingPath.slice(0, 15),
|
|
},
|
|
funnel,
|
|
funnelBreakdowns: {
|
|
bySource: acquisitionBySource.slice(0, 10),
|
|
byUseCase: buildGroupedRows(users, 'primaryUseCase').map((row) => ({ ...row, label: getUseCaseLabel(row.key) })),
|
|
byRole: buildGroupedRows(users, 'jobRole').map((row) => ({ ...row, label: getRoleLabel(row.key) })),
|
|
byTeamSize: buildGroupedRows(users, 'teamSizeBucket').map((row) => ({ ...row, label: getTeamSizeLabel(row.key) })),
|
|
},
|
|
lifecycleSummary,
|
|
recentBillingActivity,
|
|
campaignSourceQuality: acquisitionBySource,
|
|
upgradeCandidates,
|
|
filterOptions,
|
|
segments: {
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages,
|
|
rows: paginatedUsers.map((user) => ({
|
|
...user,
|
|
lifecycleStageLabel: getLifecycleStageLabel(user.lifecycleStage),
|
|
signupSourceLabel: getSourceLabel(user.signupSource),
|
|
signupSourceSelfReportedLabel: getSourceLabel(user.signupSourceSelfReported),
|
|
primaryUseCaseLabel: getUseCaseLabel(user.primaryUseCase),
|
|
primaryGoalLabel: getGoalLabel(user.primaryGoal),
|
|
jobRoleLabel: getRoleLabel(user.jobRole),
|
|
teamSizeLabel: getTeamSizeLabel(user.teamSizeBucket),
|
|
})),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching RevOps dashboard data:', error);
|
|
return NextResponse.json({ error: 'Failed to fetch RevOps dashboard data' }, { status: 500 });
|
|
}
|
|
}
|