Copy overhaul + qr designs
This commit is contained in:
@@ -97,14 +97,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
triggerLifecycleScoring(user.id, 'signup');
|
||||
|
||||
// Send welcome email (fire-and-forget — never block signup)
|
||||
// Send welcome email (fire-and-forget - never block signup)
|
||||
try {
|
||||
await sendWelcomeEmail(user.email, user.name ?? 'there');
|
||||
} catch (emailError) {
|
||||
console.error('Welcome email failed:', emailError);
|
||||
}
|
||||
|
||||
// Meta Conversions API — CompleteRegistration event
|
||||
// Meta Conversions API - CompleteRegistration event
|
||||
sendConversionEvent({
|
||||
eventName: 'CompleteRegistration',
|
||||
userData: {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { sendActivationNudgeEmail, sendUpgradeNudgeEmail, sendThirtyDayNudgeEmail } from '@/lib/email';
|
||||
import {
|
||||
sendActivationNudgeEmail,
|
||||
sendUpgradeNudgeEmail,
|
||||
sendThirtyDayNudgeEmail,
|
||||
sendFirstScanEmail,
|
||||
} from '@/lib/email';
|
||||
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
|
||||
|
||||
// Protect with a shared secret — set CRON_SECRET in Vercel env vars
|
||||
// Protect with a shared secret - set CRON_SECRET in Vercel env vars
|
||||
function isAuthorized(request: NextRequest): boolean {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
@@ -17,14 +23,17 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const now = new Date();
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
let activationSent = 0;
|
||||
let upgradeSent = 0;
|
||||
let limitSent = 0;
|
||||
let firstScanSent = 0;
|
||||
let thirtyDaySent = 0;
|
||||
|
||||
// Day-3: signed up > 3 days ago, never created a QR code, hasn't received this email yet
|
||||
// ── Day 3: signed up, never created anything ─────────────────────────────
|
||||
// Unchanged. This one is legitimately time-based: the absence of activity is
|
||||
// the trigger, and absence only becomes meaningful after some time passes.
|
||||
const activationCandidates = await db.user.findMany({
|
||||
where: {
|
||||
createdAt: { lt: threeDaysAgo },
|
||||
@@ -50,34 +59,83 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Day-7: signed up > 7 days ago, has ≥1 QR code, still FREE, hasn't received this email yet
|
||||
const upgradeCandidates = await db.user.findMany({
|
||||
// ── Limit reached: behaviour, not calendar ───────────────────────────────
|
||||
// This replaces the old day-7 upgrade nudge, which fired at day 7 regardless
|
||||
// of usage. Someone with a single code was getting a mail about a ceiling
|
||||
// they had not come near - an upgrade pitch to a person with no pain, sent
|
||||
// under the founder's own name. Now it only goes to people actually blocked.
|
||||
const freeUsers = await db.user.findMany({
|
||||
where: {
|
||||
createdAt: { lt: sevenDaysAgo },
|
||||
upgradeNudgeSentAt: null,
|
||||
plan: 'FREE',
|
||||
limitReachedNudgeSentAt: null,
|
||||
},
|
||||
include: {
|
||||
_count: { select: { qrCodes: true } },
|
||||
},
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
|
||||
for (const user of upgradeCandidates) {
|
||||
if (user._count.qrCodes > 0 && user.email) {
|
||||
try {
|
||||
await sendUpgradeNudgeEmail(user.email, user.name ?? 'there', user._count.qrCodes);
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { upgradeNudgeSentAt: now },
|
||||
});
|
||||
upgradeSent++;
|
||||
} catch (err) {
|
||||
console.error(`Upgrade nudge failed for ${user.email}:`, err);
|
||||
}
|
||||
for (const user of freeUsers) {
|
||||
if (!user.email) continue;
|
||||
const activeDynamic = await db.qRCode.count({
|
||||
where: { userId: user.id, type: 'DYNAMIC', status: 'ACTIVE' },
|
||||
});
|
||||
|
||||
if (activeDynamic < DYNAMIC_QR_LIMITS.FREE) continue;
|
||||
|
||||
try {
|
||||
await sendUpgradeNudgeEmail(user.email, user.name ?? 'there', activeDynamic);
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { limitReachedNudgeSentAt: now },
|
||||
});
|
||||
limitSent++;
|
||||
} catch (err) {
|
||||
console.error(`Limit nudge failed for ${user.email}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Day-30: signed up > 30 days ago, has ≥1 QR code, still FREE, hasn't received this email yet
|
||||
// ── First scan: the only trigger that is not a date ──────────────────────
|
||||
// Fires the day after the first scan, so the event is still recent enough to
|
||||
// be an occasion rather than a fact from the archive. The 7-day floor stops
|
||||
// this from firing for historic users whose first scan was months ago.
|
||||
const firstScanCandidates = await db.user.findMany({
|
||||
where: {
|
||||
firstScanAt: { not: null, lte: oneDayAgo },
|
||||
firstScanNudgeSentAt: null,
|
||||
},
|
||||
select: { id: true, email: true, name: true, firstScanAt: true },
|
||||
});
|
||||
|
||||
for (const user of firstScanCandidates) {
|
||||
if (!user.email || !user.firstScanAt) continue;
|
||||
|
||||
const scan = await db.qRScan.findFirst({
|
||||
where: { qr: { userId: user.id } },
|
||||
orderBy: { ts: 'asc' },
|
||||
select: { ts: true, device: true, country: true, qr: { select: { title: true } } },
|
||||
});
|
||||
|
||||
if (!scan) continue;
|
||||
|
||||
try {
|
||||
await sendFirstScanEmail(user.email, user.name ?? 'there', {
|
||||
qrTitle: scan.qr?.title ?? 'your QR code',
|
||||
device: scan.device,
|
||||
country: scan.country,
|
||||
ts: scan.ts,
|
||||
});
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { firstScanNudgeSentAt: now },
|
||||
});
|
||||
firstScanSent++;
|
||||
} catch (err) {
|
||||
console.error(`First scan mail failed for ${user.email}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Day 30: built around the user's own numbers ─────────────────────────
|
||||
// The old version argued from branding and cited a pattern among Pro users
|
||||
// that was never sourced. This one argues from the scan count the user
|
||||
// actually produced, which needs no testimonial to be believable.
|
||||
const thirtyDayCandidates = await db.user.findMany({
|
||||
where: {
|
||||
createdAt: { lt: thirtyDaysAgo },
|
||||
@@ -90,24 +148,41 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
for (const user of thirtyDayCandidates) {
|
||||
if (user._count.qrCodes > 0 && user.email) {
|
||||
try {
|
||||
await sendThirtyDayNudgeEmail(user.email, user.name ?? 'there', user._count.qrCodes);
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { thirtyDayNudgeSentAt: now },
|
||||
});
|
||||
thirtyDaySent++;
|
||||
} catch (err) {
|
||||
console.error(`30-day nudge failed for ${user.email}:`, err);
|
||||
}
|
||||
if (user._count.qrCodes === 0 || !user.email) continue;
|
||||
|
||||
const scanCount = await db.qRScan.count({
|
||||
where: {
|
||||
qr: { userId: user.id },
|
||||
ts: { gte: thirtyDaysAgo },
|
||||
},
|
||||
});
|
||||
|
||||
// No scans means the pitch has no evidence behind it. Staying quiet is
|
||||
// better than sending "your codes were scanned 0 times this month".
|
||||
if (scanCount === 0) continue;
|
||||
|
||||
try {
|
||||
await sendThirtyDayNudgeEmail(
|
||||
user.email,
|
||||
user.name ?? 'there',
|
||||
user._count.qrCodes,
|
||||
scanCount
|
||||
);
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { thirtyDayNudgeSentAt: now },
|
||||
});
|
||||
thirtyDaySent++;
|
||||
} catch (err) {
|
||||
console.error(`30-day nudge failed for ${user.email}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
activationNudgesSent: activationSent,
|
||||
upgradeNudgesSent: upgradeSent,
|
||||
limitNudgesSent: limitSent,
|
||||
firstScanEmailsSent: firstScanSent,
|
||||
thirtyDayNudgesSent: thirtyDaySent,
|
||||
});
|
||||
}
|
||||
|
||||
129
src/app/(main)/api/design-presets/route.ts
Normal file
129
src/app/(main)/api/design-presets/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Saved QR design presets.
|
||||
*
|
||||
* Business-only. The value here is repeatability, not novelty: an agency running
|
||||
* several clients needs client A to look identical across every code, including
|
||||
* the 500 that came out of one spreadsheet upload.
|
||||
*/
|
||||
|
||||
const MAX_PRESETS = 50;
|
||||
|
||||
const presetSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(60),
|
||||
style: z.record(z.any()),
|
||||
});
|
||||
|
||||
function isAllowed(plan: string | undefined): boolean {
|
||||
return plan === 'BUSINESS' || plan === 'ENTERPRISE';
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const userId = getSessionUserId();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const presets = await db.qRDesignPreset.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(presets);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = getSessionUserId();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { plan: true },
|
||||
});
|
||||
|
||||
if (!isAllowed(user?.plan)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Upgrade required',
|
||||
message: 'Saved design presets are part of the Business plan.',
|
||||
plan: user?.plan ?? 'FREE',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = presetSchema.parse(await request.json());
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: err.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
|
||||
}
|
||||
|
||||
const count = await db.qRDesignPreset.count({ where: { userId } });
|
||||
const existing = await db.qRDesignPreset.findFirst({
|
||||
where: { userId, name: data.name },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing && count >= MAX_PRESETS) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Preset limit reached',
|
||||
message: `You can keep up to ${MAX_PRESETS} presets. Delete one to save another.`,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Same name overwrites rather than creating a near-duplicate. Someone saving
|
||||
// "Client A" twice means "update it", not "keep both".
|
||||
const preset = await db.qRDesignPreset.upsert({
|
||||
where: { userId_name: { userId, name: data.name } },
|
||||
create: { userId, name: data.name, style: data.style },
|
||||
update: { style: data.style },
|
||||
});
|
||||
|
||||
return NextResponse.json(preset, { status: existing ? 200 : 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = getSessionUserId();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = new URL(request.url).searchParams.get('id');
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = await db.qRDesignPreset.deleteMany({ where: { id, userId } });
|
||||
if (deleted.count === 0) {
|
||||
return NextResponse.json({ error: 'Preset not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { db } from '@/lib/db';
|
||||
import { z } from 'zod';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
|
||||
|
||||
const updateQRSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
@@ -103,6 +104,39 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Reactivating a paused code consumes a slot again. Without this check,
|
||||
// pause -> create a new one -> unpause would quietly put the user over
|
||||
// their plan limit.
|
||||
if (
|
||||
data.status === 'ACTIVE' &&
|
||||
existing.status === 'PAUSED' &&
|
||||
existing.type === 'DYNAMIC'
|
||||
) {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { plan: true },
|
||||
});
|
||||
const limit =
|
||||
DYNAMIC_QR_LIMITS[(user?.plan ?? 'FREE') as keyof typeof DYNAMIC_QR_LIMITS] ??
|
||||
DYNAMIC_QR_LIMITS.FREE;
|
||||
const activeCount = await db.qRCode.count({
|
||||
where: { userId, type: 'DYNAMIC', status: 'ACTIVE' },
|
||||
});
|
||||
|
||||
if (activeCount >= limit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Limit reached',
|
||||
message: `You have ${activeCount} of ${limit} dynamic QR codes active. Pause another one first, or upgrade to reactivate this code.`,
|
||||
currentCount: activeCount,
|
||||
limit,
|
||||
plan: user?.plan ?? 'FREE',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Static QR codes cannot be edited
|
||||
if (existing.type === 'STATIC' && data.content) {
|
||||
return NextResponse.json(
|
||||
@@ -119,6 +153,7 @@ export async function PATCH(
|
||||
...(data.content && { content: data.content }),
|
||||
...(data.tags && { tags: data.tags }),
|
||||
...(data.style && { style: data.style }),
|
||||
...(data.status && { status: data.status }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const qrCodes = await db.qRCode.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
@@ -30,11 +32,26 @@ export async function GET(request: NextRequest) {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Recent activity per code. Used by the upgrade modal so someone deciding
|
||||
// which code to pause can see which one is actually dead, rather than
|
||||
// guessing from a lifetime total that says nothing about right now.
|
||||
const recentScans = await db.qRScan.groupBy({
|
||||
by: ['qrId'],
|
||||
where: {
|
||||
ts: { gte: thirtyDaysAgo },
|
||||
qr: { userId },
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const recentByQr = new Map(recentScans.map(r => [r.qrId, r._count._all]));
|
||||
|
||||
// Transform the data
|
||||
const transformed = qrCodes.map(qr => ({
|
||||
...qr,
|
||||
scans: qr._count.scans,
|
||||
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
|
||||
scans30d: recentByQr.get(qr.id) ?? 0,
|
||||
_count: undefined,
|
||||
}));
|
||||
|
||||
@@ -113,10 +130,13 @@ export async function POST(request: NextRequest) {
|
||||
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
|
||||
if (!isStatic) {
|
||||
// Count existing dynamic QR codes
|
||||
// Only ACTIVE codes consume a slot. Pausing a code frees one, which is what
|
||||
// the pricing page has always promised ("3 active dynamic QR codes").
|
||||
const dynamicQRCount = await db.qRCode.count({
|
||||
where: {
|
||||
userId,
|
||||
type: 'DYNAMIC',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,16 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Get plan and billing interval from request
|
||||
const { plan, billingInterval = 'month' } = await request.json();
|
||||
const { plan, billingInterval = 'month', returnPath } = await request.json();
|
||||
|
||||
// Where to send the user after checkout. Used by the in-app upgrade modal so
|
||||
// people land back on the thing they were building instead of the dashboard.
|
||||
const safeReturnPath =
|
||||
typeof returnPath === 'string' &&
|
||||
returnPath.startsWith('/') &&
|
||||
!returnPath.startsWith('//')
|
||||
? returnPath
|
||||
: null;
|
||||
|
||||
if (!plan || !['PRO', 'BUSINESS'].includes(plan)) {
|
||||
return NextResponse.json(
|
||||
@@ -114,8 +123,12 @@ export async function POST(request: NextRequest) {
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${appUrl}/pricing?canceled=true`,
|
||||
success_url: safeReturnPath
|
||||
? `${appUrl}${safeReturnPath}${safeReturnPath.includes('?') ? '&' : '?'}success=true&session_id={CHECKOUT_SESSION_ID}`
|
||||
: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: safeReturnPath
|
||||
? `${appUrl}${safeReturnPath}${safeReturnPath.includes('?') ? '&' : '?'}canceled=true`
|
||||
: `${appUrl}/pricing?canceled=true`,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
plan,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { headers } from 'next/headers';
|
||||
import { getPlanFromStripePriceId, stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import Stripe from 'stripe';
|
||||
import { sendConversionEvent } from '@/lib/metaConversions';
|
||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { headers } from 'next/headers';
|
||||
import { getPlanFromStripePriceId, stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import Stripe from 'stripe';
|
||||
import { sendConversionEvent } from '@/lib/metaConversions';
|
||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.text();
|
||||
@@ -51,21 +51,21 @@ 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_created');
|
||||
},
|
||||
});
|
||||
|
||||
// Meta CAPI — Purchase event
|
||||
await scoreUserLifecycle(updatedUser.id, 'subscription_created');
|
||||
|
||||
// Meta CAPI - Purchase event
|
||||
const amountCents = session.amount_total ?? 0;
|
||||
sendConversionEvent({
|
||||
eventName: 'Purchase',
|
||||
@@ -95,47 +95,47 @@ 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,
|
||||
},
|
||||
data: {
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: getPlanFromStripePriceId(subscription.items.data[0].price.id) ?? undefined,
|
||||
},
|
||||
});
|
||||
const updated = await db.user.findUnique({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (updated?.id) {
|
||||
await scoreUserLifecycle(
|
||||
updated.id,
|
||||
subscription.cancel_at_period_end ? 'subscription_canceled_at_period_end' : 'subscription_updated'
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: getPlanFromStripePriceId(subscription.items.data[0].price.id) ?? undefined,
|
||||
},
|
||||
});
|
||||
const updated = await db.user.findUnique({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (updated?.id) {
|
||||
await scoreUserLifecycle(
|
||||
updated.id,
|
||||
subscription.cancel_at_period_end ? 'subscription_canceled_at_period_end' : 'subscription_updated'
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
const updatedUser = 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',
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(updatedUser.id, 'subscription_deleted');
|
||||
break;
|
||||
}
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(updatedUser.id, 'subscription_deleted');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { tiktokApi } from '@/lib/tiktok';
|
||||
|
||||
// Live read-only stats from the Display API. Requires the user.info.stats and
|
||||
// video.list scopes — accounts connected before the scope change must
|
||||
// video.list scopes - accounts connected before the scope change must
|
||||
// re-authorize via /api/tiktok/connect.
|
||||
|
||||
const USER_FIELDS = 'display_name,follower_count,following_count,likes_count,video_count';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TIKTOK_BRAND, 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
|
||||
// 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 TikTok status' },
|
||||
|
||||
@@ -135,7 +135,7 @@ export async function POST(request: NextRequest) {
|
||||
media_type: 'PHOTO',
|
||||
// MEDIA_UPLOAD = draft in the creator's TikTok inbox (posting
|
||||
// policy is upload/draft only) and only needs the video.upload
|
||||
// scope — QRMaster has no video.publish.
|
||||
// scope - QRMaster has no video.publish.
|
||||
post_mode: 'MEDIA_UPLOAD',
|
||||
post_info: {
|
||||
...(title ? { title } : {}),
|
||||
|
||||
@@ -23,11 +23,14 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Count dynamic QR codes
|
||||
// Count dynamic QR codes. Must match the limit check in /api/qrs - only
|
||||
// ACTIVE codes consume a slot, otherwise the dashboard shows a different
|
||||
// number than the API actually enforces.
|
||||
const dynamicQRCount = await db.qRCode.count({
|
||||
where: {
|
||||
userId,
|
||||
type: 'DYNAMIC',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user