From 515dd3ea8506d8188c813e40a5ffc6d1ae5446cc Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sun, 22 Feb 2026 01:06:32 +0200 Subject: [PATCH 1/4] feat(admin-quiz): Admin quiz upload + publish workflow Add quiz creation via JSON upload, publish/unpublish workflow with validation, metadata editing, difficulty management, and quiz deletion. - Create quiz page with category dropdown, locale tabs, JSON upload - Status controls with confirmation modals (Mark as Ready / Revert to Draft) - Publish validation: all 3 locales complete for questions + answers - Metadata editor: title, description (per locale), time limit - Difficulty dropdown per question (beginner/medium/advanced) - Upload more questions to draft quizzes - Delete draft quizzes and individual questions - Sidebar: New Quiz link - Migration 0015: status column (draft/ready) --- .../app/[locale]/admin/quiz/[id]/page.tsx | 66 +- frontend/app/[locale]/admin/quiz/new/page.tsx | 37 + frontend/app/[locale]/admin/quiz/page.tsx | 23 +- frontend/app/api/admin/categories/route.ts | 118 + .../quiz/[id]/questions/[questionId]/route.ts | 109 +- .../api/admin/quiz/[id]/questions/route.ts | 198 + frontend/app/api/admin/quiz/[id]/route.ts | 238 ++ frontend/app/api/admin/quiz/route.ts | 196 + frontend/components/admin/AdminSidebar.tsx | 2 + .../components/admin/quiz/CreateQuizForm.tsx | 292 ++ .../admin/quiz/DeleteQuizButton.tsx | 54 + .../admin/quiz/InlineCategoryForm.tsx | 132 + .../components/admin/quiz/JsonUploadArea.tsx | 127 + .../components/admin/quiz/QuestionEditor.tsx | 99 +- .../components/admin/quiz/QuizEditorList.tsx | 6 + .../components/admin/quiz/QuizListTable.tsx | 57 +- .../admin/quiz/QuizMetadataEditor.tsx | 240 ++ .../admin/quiz/QuizStatusControls.tsx | 204 + .../admin/quiz/UploadMoreQuestions.tsx | 97 + .../db/queries/categories/admin-categories.ts | 39 + frontend/db/queries/quizzes/admin-quiz.ts | 4 + frontend/db/queries/quizzes/quiz.ts | 2 +- frontend/db/schema/quiz.ts | 1 + frontend/drizzle/0015_chunky_chimera.sql | 1 + frontend/drizzle/meta/0015_snapshot.json | 3550 +++++++++++++++++ frontend/drizzle/meta/_journal.json | 7 + frontend/lib/validation/admin-quiz.ts | 109 +- .../lib/validation/quiz-publish-validation.ts | 126 + frontend/package-lock.json | 1030 ++--- 29 files changed, 6634 insertions(+), 530 deletions(-) create mode 100644 frontend/app/[locale]/admin/quiz/new/page.tsx create mode 100644 frontend/app/api/admin/categories/route.ts create mode 100644 frontend/app/api/admin/quiz/[id]/questions/route.ts create mode 100644 frontend/app/api/admin/quiz/[id]/route.ts create mode 100644 frontend/app/api/admin/quiz/route.ts create mode 100644 frontend/components/admin/quiz/CreateQuizForm.tsx create mode 100644 frontend/components/admin/quiz/DeleteQuizButton.tsx create mode 100644 frontend/components/admin/quiz/InlineCategoryForm.tsx create mode 100644 frontend/components/admin/quiz/JsonUploadArea.tsx create mode 100644 frontend/components/admin/quiz/QuizMetadataEditor.tsx create mode 100644 frontend/components/admin/quiz/QuizStatusControls.tsx create mode 100644 frontend/components/admin/quiz/UploadMoreQuestions.tsx create mode 100644 frontend/db/queries/categories/admin-categories.ts create mode 100644 frontend/drizzle/0015_chunky_chimera.sql create mode 100644 frontend/drizzle/meta/0015_snapshot.json create mode 100644 frontend/lib/validation/quiz-publish-validation.ts diff --git a/frontend/app/[locale]/admin/quiz/[id]/page.tsx b/frontend/app/[locale]/admin/quiz/[id]/page.tsx index 57b7e3f8..216c88d2 100644 --- a/frontend/app/[locale]/admin/quiz/[id]/page.tsx +++ b/frontend/app/[locale]/admin/quiz/[id]/page.tsx @@ -2,6 +2,9 @@ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { QuizEditorList } from '@/components/admin/quiz/QuizEditorList'; +import { UploadMoreQuestions } from '@/components/admin/quiz/UploadMoreQuestions'; +import { QuizStatusControls } from '@/components/admin/quiz/QuizStatusControls'; +import { QuizMetadataEditor } from '@/components/admin/quiz/QuizMetadataEditor'; import { getAdminQuizFull } from '@/db/queries/quizzes/admin-quiz'; import { Link } from '@/i18n/routing'; import { issueCsrfToken } from '@/lib/security/csrf'; @@ -23,7 +26,17 @@ export default async function AdminQuizEditPage({ const title = quiz.translations.en?.title ?? quiz.translations.uk?.title ?? quiz.slug; + const isDraft = quiz.status === 'draft'; + const csrfToken = issueCsrfToken('admin:quiz:question:update'); + const csrfTokenDelete = isDraft + ? issueCsrfToken('admin:quiz:question:delete') + : undefined; + const csrfTokenAddQuestions = isDraft + ? issueCsrfToken('admin:quiz:questions:add') + : undefined; + const csrfTokenUpdate = issueCsrfToken('admin:quiz:update'); + return (
@@ -38,15 +51,62 @@ export default async function AdminQuizEditPage({

{title}

-

- {quiz.questions.length} questions · slug: {quiz.slug} -

+
+ + {quiz.questions.length} questions · slug: {quiz.slug} + + + {isDraft ? 'Draft' : 'Ready'} + + + {quiz.isActive ? 'Active' : 'Inactive'} + +
+
+
+ +
+
+
+ {isDraft && csrfTokenAddQuestions && ( +
+ +
+ )} +
); diff --git a/frontend/app/[locale]/admin/quiz/new/page.tsx b/frontend/app/[locale]/admin/quiz/new/page.tsx new file mode 100644 index 00000000..29208be8 --- /dev/null +++ b/frontend/app/[locale]/admin/quiz/new/page.tsx @@ -0,0 +1,37 @@ +import { Metadata } from 'next'; + +import { CreateQuizForm } from '@/components/admin/quiz/CreateQuizForm'; +import { getAdminCategoryList } from '@/db/queries/categories/admin-categories'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'New Quiz | DevLovers', +}; + +export default async function AdminQuizNewPage() { + const categories = await getAdminCategoryList(); + const csrfTokenQuiz = issueCsrfToken('admin:quiz:create'); + const csrfTokenCategory = issueCsrfToken('admin:category:create'); + + return ( +
+
+ + ← Back to quizzes + +
+ +

New Quiz

+ + +
+ ); +} diff --git a/frontend/app/[locale]/admin/quiz/page.tsx b/frontend/app/[locale]/admin/quiz/page.tsx index 83cdceec..a1949de0 100644 --- a/frontend/app/[locale]/admin/quiz/page.tsx +++ b/frontend/app/[locale]/admin/quiz/page.tsx @@ -2,6 +2,8 @@ import { Metadata } from 'next'; import { QuizListTable } from '@/components/admin/quiz/QuizListTable'; import { getAdminQuizList } from '@/db/queries/quizzes/admin-quiz'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; export const metadata: Metadata = { title: 'Quiz Admin | DevLovers', @@ -9,16 +11,27 @@ export const metadata: Metadata = { export default async function AdminQuizPage() { const quizzes = await getAdminQuizList(); + const csrfTokenDelete = issueCsrfToken('admin:quiz:delete'); return (
-

Quizzes

-

- Manage quiz content, questions, and answers -

+
+
+

Quizzes

+

+ Manage quiz content, questions, and answers +

+
+ + + New Quiz + +
- +
); diff --git a/frontend/app/api/admin/categories/route.ts b/frontend/app/api/admin/categories/route.ts new file mode 100644 index 00000000..96fe1a3a --- /dev/null +++ b/frontend/app/api/admin/categories/route.ts @@ -0,0 +1,118 @@ +import { eq, max } from 'drizzle-orm'; +import { NextRequest, NextResponse } from 'next/server'; + +import { db } from '@/db'; +import { categories, categoryTranslations } from '@/db/schema/categories'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { createCategorySchema } from '@/lib/validation/admin-quiz'; + +export const runtime = 'nodejs'; + +const LOCALES = ['en', 'uk', 'pl'] as const; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function POST(request: NextRequest): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:category:create'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 }); + } + + const parsed = createCategorySchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { error: 'Invalid payload', code: 'INVALID_PAYLOAD', details: parsed.error.format() }, + { status: 400 } + ); + } + + const { slug, translations } = parsed.data; + + // Check duplicate slug + const [existing] = await db + .select({ id: categories.id }) + .from(categories) + .where(eq(categories.slug, slug)) + .limit(1); + + if (existing) { + return noStoreJson( + { error: 'Category with this slug already exists', code: 'DUPLICATE_SLUG' }, + { status: 409 } + ); + } + + // Auto displayOrder + const [maxRow] = await db + .select({ maxOrder: max(categories.displayOrder) }) + .from(categories); + + const displayOrder = (maxRow?.maxOrder ?? 0) + 1; + + // Insert category + const [category] = await db + .insert(categories) + .values({ slug, displayOrder }) + .returning({ id: categories.id }); + + // Insert translations + await db.insert(categoryTranslations).values( + LOCALES.map(locale => ({ + categoryId: category.id, + locale, + title: translations[locale].title, + })) + ); + + return noStoreJson({ + success: true, + category: { id: category.id, slug, title: translations.en.title }, + }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson({ code: error.code }, { status: 401 }); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + + logError('admin_category_create_failed', error, { + route: request.nextUrl.pathname, + method: request.method, + }); + + return noStoreJson({ error: 'Internal error', code: 'INTERNAL_ERROR' }, { status: 500 }); + } +} diff --git a/frontend/app/api/admin/quiz/[id]/questions/[questionId]/route.ts b/frontend/app/api/admin/quiz/[id]/questions/[questionId]/route.ts index 95b8887a..f086257c 100644 --- a/frontend/app/api/admin/quiz/[id]/questions/[questionId]/route.ts +++ b/frontend/app/api/admin/quiz/[id]/questions/[questionId]/route.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm'; +import { and, count, eq, sql } from 'drizzle-orm'; import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; @@ -8,6 +8,7 @@ import { quizAnswerTranslations, quizQuestionContent, quizQuestions, + quizzes, } from '@/db/schema/quiz'; import { AdminApiDisabledError, @@ -107,6 +108,14 @@ export async function PATCH( ); } + // Update difficulty if provided + if (parsed.data.difficulty) { + await db + .update(quizQuestions) + .set({ difficulty: parsed.data.difficulty }) + .where(eq(quizQuestions.id, questionId)); + } + // Verify all submitted answer IDs belong to this question const dbAnswers = await db .select({ id: quizAnswers.id }) @@ -214,3 +223,101 @@ export async function PATCH( ); } } + +export async function DELETE( + request: NextRequest, + context: { params: Promise<{ id: string; questionId: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:quiz:question:delete'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const rawParams = await context.params; + const parsedParams = paramsSchema.safeParse(rawParams); + if (!parsedParams.success) { + return noStoreJson({ error: 'Invalid params', code: 'INVALID_PARAMS' }, { status: 400 }); + } + + const { id: quizId, questionId } = parsedParams.data; + + // Verify quiz is draft + const [quiz] = await db + .select({ id: quizzes.id, status: quizzes.status }) + .from(quizzes) + .where(eq(quizzes.id, quizId)) + .limit(1); + + if (!quiz) { + return noStoreJson({ error: 'Quiz not found', code: 'NOT_FOUND' }, { status: 404 }); + } + + if (quiz.status !== 'draft') { + return noStoreJson( + { error: 'Can only delete questions from draft quizzes', code: 'NOT_DRAFT' }, + { status: 409 } + ); + } + + // Verify question belongs to quiz + const [question] = await db + .select({ id: quizQuestions.id }) + .from(quizQuestions) + .where( + and( + eq(quizQuestions.id, questionId), + eq(quizQuestions.quizId, quizId) + ) + ) + .limit(1); + + if (!question) { + return noStoreJson({ error: 'Question not found', code: 'QUESTION_NOT_FOUND' }, { status: 404 }); + } + + // Delete question (cascades content, answers, answer translations) + await db.delete(quizQuestions).where(eq(quizQuestions.id, questionId)); + + // Update questionsCount + const [countRow] = await db + .select({ total: sql`COUNT(*)::int` }) + .from(quizQuestions) + .where(eq(quizQuestions.quizId, quizId)); + + await db + .update(quizzes) + .set({ questionsCount: countRow.total }) + .where(eq(quizzes.id, quizId)); + + await invalidateQuizCache(quizId); + + return noStoreJson({ success: true, questionsCount: countRow.total }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson({ code: error.code }, { status: 401 }); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + + logError('admin_quiz_question_delete_failed', error, { + route: request.nextUrl.pathname, + method: request.method, + }); + + return noStoreJson({ error: 'Internal error', code: 'INTERNAL_ERROR' }, { status: 500 }); + } +} diff --git a/frontend/app/api/admin/quiz/[id]/questions/route.ts b/frontend/app/api/admin/quiz/[id]/questions/route.ts new file mode 100644 index 00000000..a60cc2a8 --- /dev/null +++ b/frontend/app/api/admin/quiz/[id]/questions/route.ts @@ -0,0 +1,198 @@ +import { eq, sql } from 'drizzle-orm'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { db } from '@/db'; +import { + quizAnswers, + quizAnswerTranslations, + quizQuestionContent, + quizQuestions, + quizzes, +} from '@/db/schema/quiz'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { invalidateQuizCache } from '@/lib/quiz/quiz-answers-redis'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { addQuestionsSchema } from '@/lib/validation/admin-quiz'; + +export const runtime = 'nodejs'; + +const LOCALES = ['en', 'uk', 'pl'] as const; + +const paramsSchema = z.object({ id: z.string().uuid() }); + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +function textToExplanationBlocks(text: string) { + return [{ type: 'paragraph', children: [{ text }] }]; +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:quiz:questions:add'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const rawParams = await context.params; + const parsedParams = paramsSchema.safeParse(rawParams); + if (!parsedParams.success) { + return noStoreJson({ error: 'Invalid params', code: 'INVALID_PARAMS' }, { status: 400 }); + } + + const { id: quizId } = parsedParams.data; + + // Verify quiz exists and is draft + const [quiz] = await db + .select({ id: quizzes.id, status: quizzes.status }) + .from(quizzes) + .where(eq(quizzes.id, quizId)) + .limit(1); + + if (!quiz) { + return noStoreJson({ error: 'Quiz not found', code: 'NOT_FOUND' }, { status: 404 }); + } + + if (quiz.status !== 'draft') { + return noStoreJson( + { error: 'Can only add questions to draft quizzes. Unpublish first.', code: 'NOT_DRAFT' }, + { status: 409 } + ); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 }); + } + + const parsed = addQuestionsSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { error: 'Invalid payload', code: 'INVALID_PAYLOAD', details: parsed.error.format() }, + { status: 400 } + ); + } + + const { questions } = parsed.data; + + // Get current max displayOrder for offset + const [maxRow] = await db + .select({ maxOrder: sql`COALESCE(MAX(display_order), 0)::int` }) + .from(quizQuestions) + .where(eq(quizQuestions.quizId, quizId)); + + const orderOffset = maxRow?.maxOrder ?? 0; + + // 1. Insert questions + const insertedQuestions = await db + .insert(quizQuestions) + .values( + questions.map((q, i) => ({ + quizId, + displayOrder: orderOffset + i + 1, + difficulty: q.difficulty, + })) + ) + .returning({ id: quizQuestions.id }); + + // 2. Insert question content + await db.insert(quizQuestionContent).values( + insertedQuestions.flatMap((dbQ, i) => + LOCALES.map(locale => ({ + quizQuestionId: dbQ.id, + locale, + questionText: questions[i][locale].q, + explanation: textToExplanationBlocks(questions[i][locale].exp), + })) + ) + ); + + // 3. Insert answers + const answerValues = insertedQuestions.flatMap((dbQ, i) => + questions[i].answers.map((a, aIdx) => ({ + quizQuestionId: dbQ.id, + displayOrder: aIdx + 1, + isCorrect: a.correct, + })) + ); + + const insertedAnswers = await db + .insert(quizAnswers) + .values(answerValues) + .returning({ id: quizAnswers.id }); + + // 4. Insert answer translations + await db.insert(quizAnswerTranslations).values( + insertedAnswers.flatMap((dbA, i) => { + const qIdx = Math.floor(i / 4); + const aIdx = i % 4; + return LOCALES.map(locale => ({ + quizAnswerId: dbA.id, + locale, + answerText: questions[qIdx].answers[aIdx][locale], + })); + }) + ); + + // 5. Update questionsCount + const [countRow] = await db + .select({ count: sql`COUNT(*)::int` }) + .from(quizQuestions) + .where(eq(quizQuestions.quizId, quizId)); + + await db + .update(quizzes) + .set({ questionsCount: countRow.count }) + .where(eq(quizzes.id, quizId)); + + await invalidateQuizCache(quizId); + + return noStoreJson({ + success: true, + addedCount: questions.length, + totalCount: countRow.count, + }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson({ code: error.code }, { status: 401 }); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + + logError('admin_quiz_add_questions_failed', error, { + route: request.nextUrl.pathname, + method: request.method, + }); + + return noStoreJson({ error: 'Internal error', code: 'INTERNAL_ERROR' }, { status: 500 }); + } +} diff --git a/frontend/app/api/admin/quiz/[id]/route.ts b/frontend/app/api/admin/quiz/[id]/route.ts new file mode 100644 index 00000000..157be20a --- /dev/null +++ b/frontend/app/api/admin/quiz/[id]/route.ts @@ -0,0 +1,238 @@ +import { count, eq } from 'drizzle-orm'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { db } from '@/db'; +import { quizAttempts, quizzes, quizTranslations } from '@/db/schema/quiz'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { invalidateQuizCache } from '@/lib/quiz/quiz-answers-redis'; +import { validateQuizForPublish } from '@/lib/validation/quiz-publish-validation'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { patchQuizSchema } from '@/lib/validation/admin-quiz'; + +export const runtime = 'nodejs'; + +const paramsSchema = z.object({ id: z.string().uuid() }); + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:quiz:update'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const rawParams = await context.params; + const parsedParams = paramsSchema.safeParse(rawParams); + if (!parsedParams.success) { + return noStoreJson({ error: 'Invalid params', code: 'INVALID_PARAMS' }, { status: 400 }); + } + + const { id: quizId } = parsedParams.data; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 }); + } + + const parsed = patchQuizSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { error: 'Invalid payload', code: 'INVALID_PAYLOAD', details: parsed.error.format() }, + { status: 400 } + ); + } + + // Verify quiz exists + const [quiz] = await db + .select({ id: quizzes.id, status: quizzes.status }) + .from(quizzes) + .where(eq(quizzes.id, quizId)) + .limit(1); + + if (!quiz) { + return noStoreJson({ error: 'Quiz not found', code: 'NOT_FOUND' }, { status: 404 }); + } + + const { status, isActive } = parsed.data; + + // Publish validation: draft -> ready + if (status === 'ready' && quiz.status !== 'ready') { + const validationErrors = await validateQuizForPublish(quizId); + if (validationErrors.length > 0) { + return noStoreJson( + { error: 'Quiz is not ready for publishing', code: 'PUBLISH_VALIDATION_FAILED', details: validationErrors }, + { status: 422 } + ); + } + } + + // Build update object + const updateData: Record = {}; + if (status !== undefined) updateData.status = status; + if (isActive !== undefined) updateData.isActive = isActive; + if (parsed.data.timeLimitSeconds !== undefined) updateData.timeLimitSeconds = parsed.data.timeLimitSeconds; + + await db.update(quizzes).set(updateData).where(eq(quizzes.id, quizId)); + const { translations } = parsed.data; + if (translations) { + const locales = ['en', 'uk', 'pl'] as const; + for (const locale of locales) { + await db + .insert(quizTranslations) + .values({ + quizId, + locale, + title: translations[locale].title, + description: translations[locale].description, + }) + .onConflictDoUpdate({ + target: [quizTranslations.quizId, quizTranslations.locale], + set: { + title: translations[locale].title, + description: translations[locale].description, + }, + }); + } + } + + await invalidateQuizCache(quizId); + + return noStoreJson({ success: true, quiz: { id: quizId, status: status ?? quiz.status, isActive } }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson({ code: error.code }, { status: 401 }); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + + logError('admin_quiz_patch_failed', error, { + route: request.nextUrl.pathname, + method: request.method, + }); + + return noStoreJson({ error: 'Internal error', code: 'INTERNAL_ERROR' }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + context: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:quiz:delete'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const rawParams = await context.params; + const parsedParams = paramsSchema.safeParse(rawParams); + if (!parsedParams.success) { + return noStoreJson({ error: 'Invalid params', code: 'INVALID_PARAMS' }, { status: 400 }); + } + + const { id: quizId } = parsedParams.data; + + const [quiz] = await db + .select({ + id: quizzes.id, + status: quizzes.status, + isActive: quizzes.isActive, + }) + .from(quizzes) + .where(eq(quizzes.id, quizId)) + .limit(1); + + if (!quiz) { + return noStoreJson({ error: 'Quiz not found', code: 'NOT_FOUND' }, { status: 404 }); + } + + if (quiz.status !== 'draft') { + return noStoreJson( + { error: 'Only draft quizzes can be deleted', code: 'NOT_DRAFT' }, + { status: 409 } + ); + } + + if (quiz.isActive) { + return noStoreJson( + { error: 'Deactivate the quiz before deleting', code: 'STILL_ACTIVE' }, + { status: 409 } + ); + } + + const [{ total }] = await db + .select({ total: count() }) + .from(quizAttempts) + .where(eq(quizAttempts.quizId, quizId)); + + if (total > 0) { + return noStoreJson( + { error: `Cannot delete: ${total} attempt(s) exist`, code: 'HAS_ATTEMPTS' }, + { status: 409 } + ); + } + + await db.delete(quizzes).where(eq(quizzes.id, quizId)); + + await invalidateQuizCache(quizId); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson({ code: error.code }, { status: 401 }); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + + logError('admin_quiz_delete_failed', error, { + route: request.nextUrl.pathname, + method: request.method, + }); + + return noStoreJson({ error: 'Internal error', code: 'INTERNAL_ERROR' }, { status: 500 }); + } +} diff --git a/frontend/app/api/admin/quiz/route.ts b/frontend/app/api/admin/quiz/route.ts new file mode 100644 index 00000000..3249b931 --- /dev/null +++ b/frontend/app/api/admin/quiz/route.ts @@ -0,0 +1,196 @@ +import { eq, and } from 'drizzle-orm'; +import { NextRequest, NextResponse } from 'next/server'; + +import { db } from '@/db'; +import { getMaxQuizDisplayOrder } from '@/db/queries/categories/admin-categories'; +import { + quizAnswers, + quizAnswerTranslations, + quizQuestionContent, + quizQuestions, + quizTranslations, + quizzes, +} from '@/db/schema/quiz'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { createQuizSchema } from '@/lib/validation/admin-quiz'; + +export const runtime = 'nodejs'; + +const LOCALES = ['en', 'uk', 'pl'] as const; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +function textToExplanationBlocks(text: string) { + return [{ type: 'paragraph', children: [{ text }] }]; +} + +export async function POST(request: NextRequest): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:quiz:create'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = createQuizSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Invalid payload', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), + }, + { status: 400 } + ); + } + + const { categoryId, slug, timeLimitSeconds, translations, questions } = parsed.data; + + // Check duplicate slug within category + const [existing] = await db + .select({ id: quizzes.id }) + .from(quizzes) + .where(and(eq(quizzes.categoryId, categoryId), eq(quizzes.slug, slug))) + .limit(1); + + if (existing) { + return noStoreJson( + { error: 'Quiz with this slug already exists in this category', code: 'DUPLICATE_SLUG' }, + { status: 409 } + ); + } + + // Auto-assign displayOrder + const maxOrder = await getMaxQuizDisplayOrder(categoryId); + const displayOrder = maxOrder + 1; + + // 1. Insert quiz + const [quiz] = await db + .insert(quizzes) + .values({ + categoryId, + slug, + displayOrder, + questionsCount: questions.length, + timeLimitSeconds: timeLimitSeconds ?? null, + isActive: false, + status: 'draft', + }) + .returning({ id: quizzes.id }); + + const quizId = quiz.id; + + // 2. Insert quiz translations + await db.insert(quizTranslations).values( + LOCALES.map(locale => ({ + quizId, + locale, + title: translations[locale].title, + description: translations[locale].description, + })) + ); + + // 3. Insert questions + get IDs + const insertedQuestions = await db + .insert(quizQuestions) + .values( + questions.map(q => ({ + quizId, + displayOrder: q.order, + difficulty: q.difficulty, + })) + ) + .returning({ id: quizQuestions.id }); + + // 4. Insert question content (3 locales per question) + await db.insert(quizQuestionContent).values( + insertedQuestions.flatMap((dbQ, i) => + LOCALES.map(locale => ({ + quizQuestionId: dbQ.id, + locale, + questionText: questions[i][locale].q, + explanation: textToExplanationBlocks(questions[i][locale].exp), + })) + ) + ); + + // 5. Insert answers (4 per question) + get IDs + const answerValues = insertedQuestions.flatMap((dbQ, i) => + questions[i].answers.map((a, aIdx) => ({ + quizQuestionId: dbQ.id, + displayOrder: aIdx + 1, + isCorrect: a.correct, + })) + ); + + const insertedAnswers = await db + .insert(quizAnswers) + .values(answerValues) + .returning({ id: quizAnswers.id }); + + // 6. Insert answer translations (3 locales per answer) + await db.insert(quizAnswerTranslations).values( + insertedAnswers.flatMap((dbA, i) => { + const qIdx = Math.floor(i / 4); + const aIdx = i % 4; + return LOCALES.map(locale => ({ + quizAnswerId: dbA.id, + locale, + answerText: questions[qIdx].answers[aIdx][locale], + })); + }) + ); + + return noStoreJson({ success: true, quizId }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson({ code: error.code }, { status: 401 }); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code }, { status: 403 }); + } + + logError('admin_quiz_create_failed', error, { + route: request.nextUrl.pathname, + method: request.method, + }); + + return noStoreJson( + { error: 'Internal error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/components/admin/AdminSidebar.tsx b/frontend/components/admin/AdminSidebar.tsx index 80def36b..0f910f52 100644 --- a/frontend/components/admin/AdminSidebar.tsx +++ b/frontend/components/admin/AdminSidebar.tsx @@ -9,6 +9,7 @@ import { Package, PanelLeftClose, PanelLeftOpen, + Plus, ShoppingBag, } from 'lucide-react'; import { useSyncExternalStore } from 'react'; @@ -48,6 +49,7 @@ const NAV_SECTIONS: NavSection[] = [ items: [ { label: 'Quizzes', href: '/admin/quiz', icon: FileQuestion }, { label: 'Statistics', href: '/admin/quiz/statistics', icon: BarChart3 }, + { label: 'New Quiz', href: '/admin/quiz/new', icon: Plus }, ], }, { diff --git a/frontend/components/admin/quiz/CreateQuizForm.tsx b/frontend/components/admin/quiz/CreateQuizForm.tsx new file mode 100644 index 00000000..4a090156 --- /dev/null +++ b/frontend/components/admin/quiz/CreateQuizForm.tsx @@ -0,0 +1,292 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import type { AdminCategoryItem } from '@/db/queries/categories/admin-categories'; +import type { JsonQuestion } from '@/lib/validation/admin-quiz'; +import { slugify } from '@/lib/shop/slug'; + +import { InlineCategoryForm } from './InlineCategoryForm'; +import { JsonUploadArea } from './JsonUploadArea'; +import { type AdminLocale, LocaleTabs } from './LocaleTabs'; + +const LOCALES: AdminLocale[] = ['en', 'uk', 'pl']; + +const emptyTranslations = () => ({ + en: { title: '', description: '' }, + uk: { title: '', description: '' }, + pl: { title: '', description: '' }, +}); + +interface CreateQuizFormProps { + categories: AdminCategoryItem[]; + csrfTokenQuiz: string; + csrfTokenCategory: string; +} + +export function CreateQuizForm({ + categories: initialCategories, + csrfTokenQuiz, + csrfTokenCategory, +}: CreateQuizFormProps) { + const router = useRouter(); + + const [categories, setCategories] = useState(initialCategories); + const [categoryId, setCategoryId] = useState(''); + const [showNewCategory, setShowNewCategory] = useState(false); + + const [slug, setSlug] = useState(''); + const [slugTouched, setSlugTouched] = useState(false); + const [timeLimitSeconds, setTimeLimitSeconds] = useState(''); + + const [translations, setTranslations] = useState(emptyTranslations); + const [activeLocale, setActiveLocale] = useState('en'); + + const [questions, setQuestions] = useState([]); + + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + function handleTranslationChange( + field: 'title' | 'description', + value: string + ) { + setTranslations(prev => ({ + ...prev, + [activeLocale]: { ...prev[activeLocale], [field]: value }, + })); + + if (field === 'title' && activeLocale === 'en' && !slugTouched) { + setSlug(slugify(value)); + } + } + + function handleCategoryCreated(cat: { + id: string; + slug: string; + title: string; + }) { + setCategories(prev => [...prev, cat]); + setCategoryId(cat.id); + setShowNewCategory(false); + } + + function getDifficultyStats() { + const counts = { beginner: 0, medium: 0, advanced: 0}; + for (const q of questions) { + counts[q.difficulty]++; + } + return counts; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + + if (!categoryId) { + setError('Select a category'); + return; + } + + const missing = LOCALES.filter( + l => !translations[l].title.trim() || !translations[l].description.trim() + ); + if (missing.length > 0) { + setError( + `Title and description required for: ${missing.map(l => l.toUpperCase()).join(', ')}` + ); + return; + } + + if (!slug.trim()) { + setError('Slug is required'); + return; + } + + if (questions.length === 0) { + setError('Upload at least one JSON file with questions'); + return; + } + + setSubmitting(true); + try { + const body = { + categoryId, + slug: slug.trim(), + timeLimitSeconds: timeLimitSeconds ? Number(timeLimitSeconds) : null, + translations: { + en: { + title: translations.en.title.trim(), + description: translations.en.description.trim(), + }, + uk: { + title: translations.uk.title.trim(), + description: translations.uk.description.trim(), + }, + pl: { + title: translations.pl.title.trim(), + description: translations.pl.description.trim(), + }, + }, + questions, + }; + + const res = await fetch('/api/admin/quiz', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfTokenQuiz, + }, + body: JSON.stringify(body), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error ?? 'Failed to create quiz'); + return; + } + + router.push(`/admin/quiz/${data.quizId}`); + } catch { + setError('Network error'); + } finally { + setSubmitting(false); + } + } + + const stats = getDifficultyStats(); + + return ( +
+ {/* Category */} +
+ +
+ + +
+ + {showNewCategory && ( + setShowNewCategory(false)} + /> + )} +
+ + {/* Translations */} +
+
+ + +
+ + handleTranslationChange('title', e.target.value)} + placeholder={`Quiz title (${activeLocale.toUpperCase()})`} + className="border-border bg-background text-foreground w-full rounded-md border px-3 py-2 text-sm" + /> + +