This commit is contained in:
2026-07-27 20:47:36 +02:00
parent 90dfedf098
commit ab63d4b916
9 changed files with 1199 additions and 547 deletions

View 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 });
}
}

View File

@@ -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;