revops + onboarding
This commit is contained in:
430
src/app/(main)/api/admin/revops/route.ts
Normal file
430
src/app/(main)/api/admin/revops/route.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
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 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 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,
|
||||
},
|
||||
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,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get('code');
|
||||
|
||||
// If no code, redirect to Google OAuth
|
||||
if (!code) {
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import {
|
||||
appendRedirectParam,
|
||||
GOOGLE_OAUTH_STATE_COOKIE_NAME,
|
||||
POST_AUTH_REDIRECT_COOKIE_NAME,
|
||||
sanitizeRedirectPath,
|
||||
} from '@/lib/auth-flow';
|
||||
import {
|
||||
ATTRIBUTION_COOKIE_NAME,
|
||||
getEmailDomain,
|
||||
parseAttributionCookie,
|
||||
shouldResumeOnboarding,
|
||||
} from '@/lib/revops';
|
||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state');
|
||||
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
|
||||
const savedOauthState = request.cookies.get(GOOGLE_OAUTH_STATE_COOKIE_NAME)?.value;
|
||||
const savedRedirect = sanitizeRedirectPath(request.cookies.get(POST_AUTH_REDIRECT_COOKIE_NAME)?.value);
|
||||
|
||||
// If no code, redirect to Google OAuth
|
||||
if (!code) {
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
|
||||
if (!googleClientId) {
|
||||
@@ -17,19 +35,56 @@ export async function GET(request: NextRequest) {
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||
const scope = 'openid email profile';
|
||||
|
||||
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`;
|
||||
|
||||
return NextResponse.redirect(googleAuthUrl);
|
||||
}
|
||||
|
||||
// Handle callback with code
|
||||
try {
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||
const scope = 'openid email profile';
|
||||
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||
const oauthState = crypto.randomUUID();
|
||||
|
||||
const googleAuthUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
googleAuthUrl.searchParams.set('client_id', googleClientId);
|
||||
googleAuthUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
googleAuthUrl.searchParams.set('response_type', 'code');
|
||||
googleAuthUrl.searchParams.set('scope', scope);
|
||||
googleAuthUrl.searchParams.set('state', oauthState);
|
||||
|
||||
const response = NextResponse.redirect(googleAuthUrl);
|
||||
response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
|
||||
if (redirectTarget) {
|
||||
response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
} else {
|
||||
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Handle callback with code
|
||||
try {
|
||||
if (!state || !savedOauthState || state !== savedOauthState) {
|
||||
const invalidStateResponse = NextResponse.redirect(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-state-invalid`
|
||||
);
|
||||
invalidStateResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
||||
invalidStateResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||
return invalidStateResponse;
|
||||
}
|
||||
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
if (!googleClientId || !googleClientSecret) {
|
||||
return NextResponse.json(
|
||||
@@ -50,9 +105,9 @@ export async function GET(request: NextRequest) {
|
||||
code,
|
||||
client_id: googleClientId,
|
||||
client_secret: googleClientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
@@ -82,16 +137,27 @@ export async function GET(request: NextRequest) {
|
||||
const isNewUser = !user;
|
||||
|
||||
// Create user if they don't exist
|
||||
if (!user) {
|
||||
user = await db.user.create({
|
||||
data: {
|
||||
email: userInfo.email,
|
||||
name: userInfo.name || userInfo.email.split('@')[0],
|
||||
image: userInfo.picture,
|
||||
emailVerified: new Date(), // Google already verified the email
|
||||
password: null, // OAuth users don't need a password
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
const onboardingStartedAt = new Date();
|
||||
user = await db.user.create({
|
||||
data: {
|
||||
email: userInfo.email,
|
||||
name: userInfo.name || userInfo.email.split('@')[0],
|
||||
image: userInfo.picture,
|
||||
emailVerified: new Date(), // Google already verified the email
|
||||
password: null, // OAuth users don't need a password
|
||||
onboardingStartedAt,
|
||||
emailDomain: getEmailDomain(userInfo.email),
|
||||
signupSource: firstTouch?.signupSource || null,
|
||||
signupMedium: firstTouch?.signupMedium || null,
|
||||
signupCampaign: firstTouch?.signupCampaign || null,
|
||||
signupContent: firstTouch?.signupContent || null,
|
||||
signupTerm: firstTouch?.signupTerm || null,
|
||||
signupReferrer: firstTouch?.signupReferrer || null,
|
||||
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
|
||||
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Create Account entry for the OAuth provider
|
||||
await db.account.create({
|
||||
@@ -144,22 +210,35 @@ export async function GET(request: NextRequest) {
|
||||
id_token: tokens.id_token,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set authentication cookie
|
||||
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||
|
||||
// Redirect to dashboard with tracking params
|
||||
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
|
||||
redirectUrl.searchParams.set('authMethod', 'google');
|
||||
redirectUrl.searchParams.set('isNewUser', isNewUser.toString());
|
||||
|
||||
return NextResponse.redirect(redirectUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('Google OAuth error:', error);
|
||||
return NextResponse.redirect(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
triggerLifecycleScoring(user.id, isNewUser ? 'signup' : 'subscription_changed');
|
||||
|
||||
const onboardingTarget = isNewUser || shouldResumeOnboarding(user)
|
||||
? appendRedirectParam('/onboarding', savedRedirect, {
|
||||
authMethod: 'google',
|
||||
isNewUser: isNewUser.toString(),
|
||||
})
|
||||
: (savedRedirect || appendRedirectParam('/dashboard', null, {
|
||||
authMethod: 'google',
|
||||
isNewUser: isNewUser.toString(),
|
||||
}));
|
||||
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
|
||||
|
||||
const response = NextResponse.redirect(redirectUrl.toString());
|
||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
||||
response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
||||
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Google OAuth error:', error);
|
||||
const errorResponse = NextResponse.redirect(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
|
||||
);
|
||||
errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
||||
errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
30
src/app/(main)/api/auth/logout/route.ts
Normal file
30
src/app/(main)/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
response.cookies.set('userId', '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
response.cookies.set('newsletter-admin', '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
response.cookies.set(ATTRIBUTION_COOKIE_NAME, '', {
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '@/lib/db';
|
||||
import { z } from 'zod';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { sendWelcomeEmail } from '@/lib/email';
|
||||
import { sendConversionEvent } from '@/lib/meta';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { sendWelcomeEmail } from '@/lib/email';
|
||||
import { sendConversionEvent } from '@/lib/meta';
|
||||
import {
|
||||
ATTRIBUTION_COOKIE_NAME,
|
||||
getEmailDomain,
|
||||
parseAttributionCookie,
|
||||
} from '@/lib/revops';
|
||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -67,14 +72,29 @@ export async function POST(request: NextRequest) {
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create user
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
|
||||
const onboardingStartedAt = new Date();
|
||||
|
||||
// Create user
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
onboardingStartedAt,
|
||||
emailDomain: getEmailDomain(email),
|
||||
signupSource: firstTouch?.signupSource || null,
|
||||
signupMedium: firstTouch?.signupMedium || null,
|
||||
signupCampaign: firstTouch?.signupCampaign || null,
|
||||
signupContent: firstTouch?.signupContent || null,
|
||||
signupTerm: firstTouch?.signupTerm || null,
|
||||
signupReferrer: firstTouch?.signupReferrer || null,
|
||||
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
|
||||
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
||||
},
|
||||
});
|
||||
|
||||
triggerLifecycleScoring(user.id, 'signup');
|
||||
|
||||
// Send welcome email (fire-and-forget — never block signup)
|
||||
try {
|
||||
@@ -97,20 +117,22 @@ export async function POST(request: NextRequest) {
|
||||
}).catch(console.error);
|
||||
|
||||
// Create response
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
plan: 'FREE',
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
needsOnboarding: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
|
||||
// Set cookie for auto-login after signup
|
||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
||||
|
||||
return response;
|
||||
|
||||
// Set cookie for auto-login after signup
|
||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
||||
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
@@ -125,4 +147,4 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { shouldResumeOnboarding } from '@/lib/revops';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -50,9 +51,18 @@ export async function POST(request: NextRequest) {
|
||||
const { email, password } = validation.data;
|
||||
|
||||
// Find user
|
||||
const user = await db.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
const user = await db.user.findUnique({
|
||||
where: { email },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
password: true,
|
||||
onboardingStartedAt: true,
|
||||
onboardingCompletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
@@ -74,12 +84,13 @@ export async function POST(request: NextRequest) {
|
||||
// Set cookie
|
||||
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
needsOnboarding: shouldResumeOnboarding(user),
|
||||
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
src/app/(main)/api/onboarding/route.ts
Normal file
119
src/app/(main)/api/onboarding/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { getClientIdentifier, rateLimit, RateLimits } from '@/lib/rateLimit';
|
||||
import { onboardingUpdateSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { getOnboardingState, triggerLifecycleScoring } from '@/lib/revops-server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const state = await getOnboardingState(userId);
|
||||
|
||||
if (!state) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(state);
|
||||
} catch (error) {
|
||||
console.error('Error fetching onboarding state:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch onboarding state' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validation = await validateRequest(onboardingUpdateSchema, body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const data = validation.data;
|
||||
const now = new Date();
|
||||
const existingUser = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
onboardingStartedAt: true,
|
||||
sourceConfirmedAt: true,
|
||||
useCaseSelectedAt: true,
|
||||
goalSelectedAt: true,
|
||||
profileCompletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
onboardingStartedAt: existingUser.onboardingStartedAt ?? now,
|
||||
signupSourceSelfReported: data.signupSourceSelfReported,
|
||||
primaryUseCase: data.primaryUseCase,
|
||||
primaryGoal: data.primaryGoal,
|
||||
jobRole: data.jobRole,
|
||||
companyName: data.companyName,
|
||||
companyWebsite: data.companyWebsite,
|
||||
teamSizeBucket: data.teamSizeBucket,
|
||||
sourceConfirmedAt:
|
||||
data.signupSourceSelfReported && !existingUser.sourceConfirmedAt
|
||||
? now
|
||||
: undefined,
|
||||
useCaseSelectedAt:
|
||||
data.primaryUseCase && !existingUser.useCaseSelectedAt
|
||||
? now
|
||||
: undefined,
|
||||
goalSelectedAt:
|
||||
data.primaryGoal && !existingUser.goalSelectedAt
|
||||
? now
|
||||
: undefined,
|
||||
profileCompletedAt:
|
||||
data.markProfileComplete && !existingUser.profileCompletedAt
|
||||
? now
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
triggerLifecycleScoring(userId, 'onboarding_update');
|
||||
const state = await getOnboardingState(userId);
|
||||
|
||||
return NextResponse.json({ success: true, state });
|
||||
} catch (error) {
|
||||
console.error('Error updating onboarding state:', error);
|
||||
return NextResponse.json({ error: 'Failed to update onboarding state' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { generateSlug } from '@/lib/hash';
|
||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { generateSlug } from '@/lib/hash';
|
||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
|
||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||
|
||||
// GET /api/qrs - List user's QR codes
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -47,12 +49,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Plan limits
|
||||
const PLAN_LIMITS = {
|
||||
FREE: 3,
|
||||
PRO: 50,
|
||||
BUSINESS: 500,
|
||||
ENTERPRISE: 99999,
|
||||
};
|
||||
const PLAN_LIMITS = DYNAMIC_QR_LIMITS;
|
||||
|
||||
// POST /api/qrs - Create a new QR code
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -208,9 +205,9 @@ END:VCARD`;
|
||||
const slug = generateSlug(body.title);
|
||||
|
||||
// Create QR code
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title: body.title,
|
||||
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
||||
contentType: body.contentType,
|
||||
@@ -224,10 +221,12 @@ END:VCARD`;
|
||||
},
|
||||
slug,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(qrCode);
|
||||
},
|
||||
});
|
||||
|
||||
triggerLifecycleScoring(userId, 'qr_created');
|
||||
|
||||
return NextResponse.json(qrCode);
|
||||
} catch (error) {
|
||||
console.error('Error creating QR code:', error);
|
||||
return NextResponse.json(
|
||||
@@ -235,4 +234,4 @@ END:VCARD`;
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -53,32 +54,35 @@ export async function POST(request: NextRequest) {
|
||||
// No active subscription
|
||||
if (!user.stripeSubscriptionId) {
|
||||
// Just update plan to FREE if somehow plan is not FREE but no subscription
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: 'FREE',
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
});
|
||||
await scoreUserLifecycle(userId, 'subscription_changed');
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Cancel the Stripe subscription
|
||||
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
|
||||
|
||||
// Update user plan to FREE
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: 'FREE',
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(userId, 'subscription_changed');
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error canceling subscription:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -65,13 +65,29 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create or get Stripe customer
|
||||
let customerId = user.stripeCustomerId;
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: {
|
||||
// Create or get Stripe customer
|
||||
let customerId = user.stripeCustomerId;
|
||||
|
||||
if (customerId) {
|
||||
try {
|
||||
const existingCustomer = await stripe.customers.retrieve(customerId);
|
||||
|
||||
if ('deleted' in existingCustomer && existingCustomer.deleted) {
|
||||
customerId = null;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') {
|
||||
customerId = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
@@ -79,30 +95,33 @@ export async function POST(request: NextRequest) {
|
||||
customerId = customer.id;
|
||||
|
||||
// Update user with Stripe customer ID
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { stripeCustomerId: customerId },
|
||||
});
|
||||
}
|
||||
|
||||
// Create Stripe Checkout Session
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { stripeCustomerId: customerId },
|
||||
});
|
||||
}
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
|
||||
|
||||
// Create Stripe Checkout Session
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
plan,
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${appUrl}/pricing?canceled=true`,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
plan,
|
||||
billingInterval,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||
|
||||
/**
|
||||
* Manual sync endpoint to update user subscription from Stripe
|
||||
@@ -37,17 +38,19 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (subscriptions.data.length === 0) {
|
||||
// No active subscription - set to FREE
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(user.id, 'subscription_changed');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan: 'FREE',
|
||||
message: 'No active subscription found, set to FREE plan',
|
||||
@@ -87,18 +90,20 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
// Update user in database
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: priceId,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: plan as any,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(user.id, 'subscription_changed');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan,
|
||||
subscriptionId: subscription.id,
|
||||
currentPeriodEnd,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -20,26 +21,30 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!user.stripeCustomerId) {
|
||||
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the most recent checkout session for this customer
|
||||
const checkoutSessions = await stripe.checkout.sessions.list({
|
||||
customer: user.stripeCustomerId,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (checkoutSessions.data.length === 0) {
|
||||
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const checkoutSession = checkoutSessions.data[0];
|
||||
|
||||
// Only process if payment was successful
|
||||
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
||||
const subscriptionId = typeof checkoutSession.subscription === 'string'
|
||||
? checkoutSession.subscription
|
||||
if (!user.stripeCustomerId) {
|
||||
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { sessionId } = await request.json().catch(() => ({ sessionId: null }));
|
||||
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing checkout session ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
|
||||
const sessionBelongsToUser =
|
||||
checkoutSession.metadata?.userId === user.id ||
|
||||
checkoutSession.customer === user.stripeCustomerId;
|
||||
|
||||
if (!sessionBelongsToUser) {
|
||||
return NextResponse.json({ error: 'Checkout session does not belong to user' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Only process if payment was successful
|
||||
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
||||
const subscriptionId = typeof checkoutSession.subscription === 'string'
|
||||
? checkoutSession.subscription
|
||||
: checkoutSession.subscription.id;
|
||||
|
||||
// Retrieve the full subscription object
|
||||
@@ -48,41 +53,32 @@ export async function POST(request: NextRequest) {
|
||||
// Determine plan from metadata or price ID
|
||||
const plan = checkoutSession.metadata?.plan || 'PRO';
|
||||
|
||||
// Debug log to see the subscription structure
|
||||
console.log('Full subscription object:', JSON.stringify(subscription, null, 2));
|
||||
|
||||
// Get current_period_end - Stripe returns it as a Unix timestamp
|
||||
// Try different possible field names
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
// Get current_period_end - Stripe returns it as a Unix timestamp
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
|
||||
const currentPeriodEnd = periodEndTimestamp
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
|
||||
|
||||
console.log('Subscription data:', {
|
||||
id: subscription.id,
|
||||
periodEndTimestamp,
|
||||
currentPeriodEnd,
|
||||
priceId: subscription.items?.data?.[0]?.price?.id,
|
||||
});
|
||||
|
||||
// Update user in database
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
// Update user in database
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: plan as any,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan,
|
||||
subscriptionId: subscription.id,
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(user.id, 'subscription_changed');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { headers } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import Stripe from 'stripe';
|
||||
import { sendConversionEvent } from '@/lib/meta';
|
||||
import { db } from '@/lib/db';
|
||||
import Stripe from 'stripe';
|
||||
import { sendConversionEvent } from '@/lib/meta';
|
||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.text();
|
||||
@@ -50,17 +51,19 @@ export async function POST(request: NextRequest) {
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const updatedUser = await db.user.update({
|
||||
where: {
|
||||
stripeCustomerId: session.customer as string,
|
||||
const updatedUser = await db.user.update({
|
||||
where: {
|
||||
stripeCustomerId: session.customer as string,
|
||||
},
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: (session.metadata?.plan || 'FREE') as any,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
|
||||
|
||||
// Meta CAPI — Purchase event
|
||||
const amountCents = session.amount_total ?? 0;
|
||||
@@ -92,34 +95,43 @@ export async function POST(request: NextRequest) {
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
const updated = await db.user.findUnique({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (updated?.id) {
|
||||
await scoreUserLifecycle(updated.id, 'subscription_changed');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
},
|
||||
const updatedUser = await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
|
||||
Reference in New Issue
Block a user