fix V2
This commit is contained in:
204
src/app/(main)/api/qrs/bulk/route.ts
Normal file
204
src/app/(main)/api/qrs/bulk/route.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { generateSlug } from '@/lib/hash';
|
||||
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';
|
||||
import { qrStyleSchema } from '@/lib/validationSchemas';
|
||||
import { buildVcard } from '@/lib/bulk-content';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* POST /api/qrs/bulk - create a whole upload in one request.
|
||||
*
|
||||
* The bulk page used to fire one POST per row against /api/qrs, which is rate
|
||||
* limited to 20 creates a minute. A 100-row upload therefore lost 80 rows to
|
||||
* 429s, and a 1000-row upload opened 1000 concurrent connections to do it.
|
||||
* One request per batch also means the dynamic quota is checked once, against
|
||||
* a live count, instead of racing itself row by row.
|
||||
*/
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const MAX_ITEMS = 1000;
|
||||
|
||||
const itemSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'TEXT']),
|
||||
content: z.record(z.any()),
|
||||
});
|
||||
|
||||
const bulkSchema = z.object({
|
||||
items: z.array(itemSchema).min(1).max(MAX_ITEMS),
|
||||
isStatic: z.boolean(),
|
||||
style: qrStyleSchema.optional(),
|
||||
});
|
||||
|
||||
/** Mirrors the switch in /api/qrs so a bulk code encodes what a single one does. */
|
||||
function buildQrContent(contentType: string, content: any): string {
|
||||
switch (contentType) {
|
||||
case 'URL':
|
||||
return content.url || 'https://example.com';
|
||||
case 'PHONE':
|
||||
return `tel:${content.phone}`;
|
||||
case 'GEO': {
|
||||
const lat = content.latitude ?? 0;
|
||||
const lon = content.longitude ?? 0;
|
||||
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
|
||||
return `geo:${lat},${lon}${label}`;
|
||||
}
|
||||
case 'VCARD':
|
||||
return buildVcard(content);
|
||||
case 'TEXT':
|
||||
return content.text || '';
|
||||
default:
|
||||
return content.url || '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = getSessionUserId();
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
|
||||
// One batch is one request, so the limit is on batches, not rows.
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_BULK_CREATE);
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many bulk uploads. Please wait a moment and try again.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { plan: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const plan = user.plan || 'FREE';
|
||||
|
||||
// Bulk creation is a Business feature. The page hides itself for other
|
||||
// plans, but the endpoint has to say so too - a hidden button is not a
|
||||
// permission check.
|
||||
if (plan !== 'BUSINESS' && (plan as string) !== 'ENTERPRISE') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Upgrade required',
|
||||
message: 'Bulk QR code creation is part of the Business plan.',
|
||||
plan,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = bulkSchema.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 { items, isStatic } = body;
|
||||
const style = body.style ?? {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cornerStyle: 'square',
|
||||
size: 200,
|
||||
};
|
||||
|
||||
// Work out how many rows the plan can actually take before writing
|
||||
// anything, so the caller gets one honest answer instead of discovering
|
||||
// the ceiling halfway through a print run.
|
||||
let allowed = items.length;
|
||||
if (!isStatic) {
|
||||
const limit =
|
||||
DYNAMIC_QR_LIMITS[plan as keyof typeof DYNAMIC_QR_LIMITS] ?? DYNAMIC_QR_LIMITS.FREE;
|
||||
const used = await db.qRCode.count({
|
||||
where: { userId, type: 'DYNAMIC', status: 'ACTIVE' },
|
||||
});
|
||||
allowed = Math.max(0, Math.min(items.length, limit - used));
|
||||
}
|
||||
|
||||
// `row` is the 1-based index into the submitted items. The caller needs it
|
||||
// to line results back up with its own rows once some of them have failed.
|
||||
const created: { row: number; title: string; slug: string; id: string }[] = [];
|
||||
const failed: { row: number; title: string; reason: string }[] = [];
|
||||
|
||||
for (let i = allowed; i < items.length; i++) {
|
||||
failed.push({
|
||||
row: i + 1,
|
||||
title: items[i].title,
|
||||
reason: 'No dynamic code slots left on your plan',
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < allowed; i++) {
|
||||
const item = items[i];
|
||||
const content = isStatic
|
||||
? { ...item.content, qrContent: buildQrContent(item.contentType, item.content) }
|
||||
: item.content;
|
||||
|
||||
// Slugs carry six random characters, so a clash across a large batch is
|
||||
// unlikely but not impossible. Retrying beats failing the whole upload.
|
||||
let saved = false;
|
||||
for (let attempt = 0; attempt < 3 && !saved; attempt++) {
|
||||
try {
|
||||
const qr = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title: item.title,
|
||||
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
||||
contentType: item.contentType,
|
||||
content,
|
||||
tags: [],
|
||||
style,
|
||||
slug: generateSlug(item.title),
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true, slug: true, title: true },
|
||||
});
|
||||
created.push({ ...qr, row: i + 1 });
|
||||
saved = true;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002' && attempt < 2) continue;
|
||||
failed.push({
|
||||
row: i + 1,
|
||||
title: item.title,
|
||||
reason: err?.code === 'P2002' ? 'Could not allocate a unique link' : 'Could not be saved',
|
||||
});
|
||||
saved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (created.length > 0) {
|
||||
triggerLifecycleScoring(userId, 'qr_created');
|
||||
}
|
||||
|
||||
return NextResponse.json({ created, failed });
|
||||
} catch (error) {
|
||||
console.error('Error creating QR codes in bulk:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
import { buildVcard } from '@/lib/bulk-content';
|
||||
|
||||
// GET /api/qrs - List user's QR codes
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -173,15 +174,10 @@ export async function POST(request: NextRequest) {
|
||||
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||
break;
|
||||
case 'VCARD':
|
||||
qrContent = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
||||
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
||||
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
||||
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
||||
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
||||
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
||||
END:VCARD`;
|
||||
// Shared with the bulk endpoint. Two copies of this template drifting
|
||||
// apart would mean the same contact encodes differently depending on
|
||||
// which route created it.
|
||||
qrContent = buildVcard(body.content);
|
||||
break;
|
||||
case 'GEO':
|
||||
const lat = body.content.latitude || 0;
|
||||
|
||||
Reference in New Issue
Block a user