|
| 1 | +import { eq, max } from 'drizzle-orm'; |
| 2 | +import { NextRequest, NextResponse } from 'next/server'; |
| 3 | + |
| 4 | +import { db } from '@/db'; |
| 5 | +import { categories, categoryTranslations } from '@/db/schema/categories'; |
| 6 | +import { |
| 7 | + AdminApiDisabledError, |
| 8 | + AdminForbiddenError, |
| 9 | + AdminUnauthorizedError, |
| 10 | + requireAdminApi, |
| 11 | +} from '@/lib/auth/admin'; |
| 12 | +import { logError } from '@/lib/logging'; |
| 13 | +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; |
| 14 | +import { guardBrowserSameOrigin } from '@/lib/security/origin'; |
| 15 | +import { createCategorySchema } from '@/lib/validation/admin-quiz'; |
| 16 | + |
| 17 | +export const runtime = 'nodejs'; |
| 18 | + |
| 19 | +const LOCALES = ['en', 'uk', 'pl'] as const; |
| 20 | + |
| 21 | +function noStoreJson(body: unknown, init?: { status?: number }) { |
| 22 | + const res = NextResponse.json(body, { status: init?.status ?? 200 }); |
| 23 | + res.headers.set('Cache-Control', 'no-store'); |
| 24 | + return res; |
| 25 | +} |
| 26 | + |
| 27 | +export async function POST(request: NextRequest): Promise<NextResponse> { |
| 28 | + const blocked = guardBrowserSameOrigin(request); |
| 29 | + if (blocked) { |
| 30 | + blocked.headers.set('Cache-Control', 'no-store'); |
| 31 | + return blocked; |
| 32 | + } |
| 33 | + |
| 34 | + try { |
| 35 | + await requireAdminApi(request); |
| 36 | + |
| 37 | + const csrfResult = requireAdminCsrf(request, 'admin:category:create'); |
| 38 | + if (csrfResult) { |
| 39 | + csrfResult.headers.set('Cache-Control', 'no-store'); |
| 40 | + return csrfResult; |
| 41 | + } |
| 42 | + |
| 43 | + let rawBody: unknown; |
| 44 | + try { |
| 45 | + rawBody = await request.json(); |
| 46 | + } catch { |
| 47 | + return noStoreJson({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 }); |
| 48 | + } |
| 49 | + |
| 50 | + const parsed = createCategorySchema.safeParse(rawBody); |
| 51 | + if (!parsed.success) { |
| 52 | + return noStoreJson( |
| 53 | + { error: 'Invalid payload', code: 'INVALID_PAYLOAD', details: parsed.error.format() }, |
| 54 | + { status: 400 } |
| 55 | + ); |
| 56 | + } |
| 57 | + |
| 58 | + const { slug, translations } = parsed.data; |
| 59 | + |
| 60 | + // Auto displayOrder |
| 61 | + const [maxRow] = await db |
| 62 | + .select({ maxOrder: max(categories.displayOrder) }) |
| 63 | + .from(categories); |
| 64 | + |
| 65 | + const displayOrder = (maxRow?.maxOrder ?? 0) + 1; |
| 66 | + |
| 67 | + // Insert category (onConflictDoNothing handles duplicate slug race) |
| 68 | + const rows = await db |
| 69 | + .insert(categories) |
| 70 | + .values({ slug, displayOrder }) |
| 71 | + .onConflictDoNothing({ target: categories.slug }) |
| 72 | + .returning({ id: categories.id }); |
| 73 | + |
| 74 | + if (rows.length === 0) { |
| 75 | + return noStoreJson( |
| 76 | + { error: 'Category with this slug already exists', code: 'DUPLICATE_SLUG' }, |
| 77 | + { status: 409 } |
| 78 | + ); |
| 79 | + } |
| 80 | + |
| 81 | + const category = rows[0]; |
| 82 | + |
| 83 | + // Insert translations (cleanup orphan category on failure) |
| 84 | + try { |
| 85 | + await db.insert(categoryTranslations).values( |
| 86 | + LOCALES.map(locale => ({ |
| 87 | + categoryId: category.id, |
| 88 | + locale, |
| 89 | + title: translations[locale].title, |
| 90 | + })) |
| 91 | + ); |
| 92 | + } catch (translationError) { |
| 93 | + await db.delete(categories).where(eq(categories.id, category.id)); |
| 94 | + throw translationError; |
| 95 | + } |
| 96 | + |
| 97 | + return noStoreJson({ |
| 98 | + success: true, |
| 99 | + category: { id: category.id, slug, title: translations.en.title }, |
| 100 | + }); |
| 101 | + } catch (error) { |
| 102 | + if (error instanceof AdminApiDisabledError) { |
| 103 | + return noStoreJson({ code: error.code }, { status: 403 }); |
| 104 | + } |
| 105 | + if (error instanceof AdminUnauthorizedError) { |
| 106 | + return noStoreJson({ code: error.code }, { status: 401 }); |
| 107 | + } |
| 108 | + if (error instanceof AdminForbiddenError) { |
| 109 | + return noStoreJson({ code: error.code }, { status: 403 }); |
| 110 | + } |
| 111 | + |
| 112 | + logError('admin_category_create_failed', error, { |
| 113 | + route: request.nextUrl.pathname, |
| 114 | + method: request.method, |
| 115 | + }); |
| 116 | + |
| 117 | + return noStoreJson({ error: 'Internal error', code: 'INTERNAL_ERROR' }, { status: 500 }); |
| 118 | + } |
| 119 | +} |
0 commit comments