Skip to content

refactor(db): normalize categories and questions tables for i18n#74

Merged
ViktorSvertoka merged 2 commits intodevelopfrom
sl/feat/quiz
Dec 21, 2025
Merged

refactor(db): normalize categories and questions tables for i18n#74
ViktorSvertoka merged 2 commits intodevelopfrom
sl/feat/quiz

Conversation

@LesiaUKR
Copy link
Collaborator

@LesiaUKR LesiaUKR commented Dec 21, 2025

  • categories + category_translations (composite PK)
  • questions + question_translations (composite PK)
  • quizzes.categoryId FK to categories
  • Update seeds, API routes

Summary by CodeRabbit

Release Notes

  • New Features

    • Added multi-language support for categories and questions with localized content
  • Bug Fixes

    • Improved error logging for quiz result operations
    • Added guard to prevent unnecessary component evaluations when no pending data exists
  • Chores

    • Updated database schema for improved data organization with translation support
    • Removed debug logging statements

✏️ Tip: You can customize this high-level summary in your review settings.

- Split categories into categories + category_translations
- Split questions into questions + question_translations
- Use composite primary keys (entity_id, locale) for translations
- CASCADE delete for translations, RESTRICT for content FKs
- Replace quizzes.topicId with categoryId (FK to categories)
- Link quizzes.categoryId to categories.id with FK constraint
- Update seed scripts for new normalized structure
- Update /api/questions/[category] route with JOIN queries
@netlify
Copy link

netlify bot commented Dec 21, 2025

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit e9a49a0
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/69485ba732fc5a0007017367
😎 Deploy Preview https://deploy-preview-74--develop-devlovers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 21, 2025

Walkthrough

This PR introduces multi-locale translation architecture by refactoring the database schema to separate translatable content (categories, questions) into dedicated translation tables with composite primary keys, adds two database migrations, updates all API routes and seed scripts to manage both primary and translation records, and includes minor logging improvements.

Changes

Cohort / File(s) Summary
Database Schema & Migrations
frontend/db/schema/categories.ts, frontend/db/schema/questions.ts, frontend/db/schema/quiz.ts, frontend/drizzle/0004_tough_ultron.sql, frontend/drizzle/0005_furry_warstar.sql
Introduces categoryTranslations and questionTranslations tables with composite primary keys (categoryId/questionId, locale). Converts primary key IDs to UUIDs with auto-generation defaults. Replaces quizzes.topicId with categoryId and establishes foreign key relationships with cascade/restrict delete rules. Adds columns: categories.slug, categories.displayOrder, categories.createdAt; questions.sortOrder, questions.difficulty, questions.createdAt, questions.updatedAt.
Database Seeding
frontend/db/seed-categories.ts, frontend/db/seed-questions.ts, frontend/db/seed-quiz-from-json.ts, frontend/db/seed-quiz-javascript.ts, frontend/db/seed-quiz-react.ts
Refactors seed scripts to perform per-record category/question lookup by slug, validate existence, and insert into both primary and translation tables separately. Replaces randomUUID() generation with category lookups; adds onConflictDoNothing for idempotency. Updates all quiz seeding to resolve categoryId before insert and removes topicId references.
API & Component Updates
frontend/app/api/questions/[category]/route.ts, frontend/app/api/quiz/guest-result/route.ts, frontend/components/quiz/PendingResultHandler.tsx
Refactors questions API to resolve category by slug, join with questionTranslations for filtering and pagination, and return translated content. Adds console.error logging in guest-result error handling. Adds early guard (if !pending return;) in PendingResultHandler before fetch logic.
Schema Metadata & Journal
frontend/drizzle/meta/0004_snapshot.json, frontend/drizzle/meta/0005_snapshot.json, frontend/drizzle/meta/_journal.json
Adds schema snapshots for migrations 0004 and 0005, capturing full table definitions, constraints, indexes, and enums. Updates journal with two new migration entries.
Utility Functions
frontend/lib/guest-quiz.ts
Removes debug console.log statements from savePendingQuizResult and getPendingQuizResult functions; control flow and behavior unchanged.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

  • Migration correctness: Verify SQL foreign key constraints, cascade/restrict rules, UUID generation defaults, and NOT NULL constraints are properly applied.
  • Schema relations: Confirm one-to-many and many-to-one relation mappings between categories↔categoryTranslations and questions↔questionTranslations are syntactically valid.
  • API join logic: Review the questionTranslations inner join for correct column selections, filtering, counting, and pagination offset handling in the questions route.
  • Seed script safety: Validate that per-record lookups, onConflictDoNothing, and translation batch inserts handle edge cases (missing categories, duplicate locales) without data loss or orphaned records.

Possibly related PRs

  • Sl/feat/quiz #70 — Modifies the same guest-quiz files and schema for categories/questions/translations; both PRs evolve the translation-based architecture together.
  • refactoring files #61 — Earlier refactor of the questions API route; this PR builds on its foundation with translation table joins.

Suggested reviewers

  • AM1007
  • ViktorSvertoka

Poem

🐰 Translations bloom where locales dance,
Categories split, translations advance,
Composite keys lock the tale in place,
Migrations glide with a graceful pace,
Multi-tongue wisdom in every trace!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: a database schema refactor normalizing categories and questions tables for internationalization (i18n) support.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch sl/feat/quiz

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/components/quiz/PendingResultHandler.tsx (1)

13-33: Remove redundant conditional check.

After the early return on line 13, the if (pending) check on line 15 is redundant—execution only reaches line 15 when pending is truthy. Remove the conditional and unindent the fetch block.

🔎 Proposed refactor
  useEffect(() => {
    const pending = getPendingQuizResult();
    if (!pending) return;

-    if (pending) {
-      fetch("/api/quiz/guest-result", {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({
-          userId,
-          quizId: pending.quizId,
-          answers: pending.answers,
-          violations: pending.violations,
-          timeSpentSeconds: pending.timeSpentSeconds,
-        }),
-      })
-        .then(() => {
-          clearPendingQuizResult();
-        })
-        .catch(err => {
-          console.error("Guest-result fetch error:", err);
-        });
-    }
+    fetch("/api/quiz/guest-result", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        userId,
+        quizId: pending.quizId,
+        answers: pending.answers,
+        violations: pending.violations,
+        timeSpentSeconds: pending.timeSpentSeconds,
+      }),
+    })
+      .then(() => {
+        clearPendingQuizResult();
+      })
+      .catch(err => {
+        console.error("Guest-result fetch error:", err);
+      });
  }, [userId]);
🧹 Nitpick comments (5)
frontend/drizzle/meta/0004_snapshot.json (1)

55-107: Consider adding indexes on translation tables for query performance.

The translation tables (category_translations, question_translations) have composite primary keys on (entity_id, locale) but no additional indexes. For queries filtering by locale alone (common in i18n scenarios), a secondary index on locale or a covering index could improve performance as data grows.

Also applies to: 108-166

frontend/db/seed-quiz-from-json.ts (1)

66-126: Inconsistent indentation inside the try block.

Lines 67-115 use 2-space indentation while lines 117-126 use 4-space indentation within the same try block. This inconsistency affects readability.

Suggested fix: Normalize indentation

Either indent lines 117-172 with 2 spaces to match lines 67-115, or vice versa. The common convention is consistent indentation throughout the block.

frontend/db/seed-questions.ts (1)

27-60: Consider caching category lookups to avoid N+1 queries.

Each question triggers a separate category lookup query. For large datasets, this could significantly slow down seeding.

Proposed optimization: Cache categories upfront
 async function seedQuestions() {
   if (!data.length) {
     console.log('No questions to seed — skipping.');
     return;
   }

+  // Pre-fetch all categories into a map
+  const allCategories = await db.select().from(categories);
+  const categoryMap = new Map(allCategories.map(c => [c.slug, c]));
+
   for (const q of data) {
-    // Find category by slug
-    const [category] = await db
-      .select()
-      .from(categories)
-      .where(eq(categories.slug, q.category))
-      .limit(1);
+    const category = categoryMap.get(q.category);

     if (!category) {
       console.log(`Category ${q.category} not found, skipping question...`);
       continue;
     }
     // ... rest of the code
frontend/db/seed-categories.ts (1)

9-36: Consider edge cases in slug generation for future category names.

The current name.toLowerCase() approach works for the existing category names (single words like 'HTML', 'React'), but may produce invalid or unexpected slugs if categories with spaces or special characters are added in the future (e.g., "Vue.js" → "vue.js", "Node JS" → "node js").

If you anticipate more complex category names, consider using a proper slugify utility or adding validation.

🔎 Optional: Add slug sanitization
-    const slug = name.toLowerCase();
+    const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
frontend/db/schema/quiz.ts (1)

23-39: Minor formatting: Inconsistent indentation on categoryId definition.

The categoryId column definition is semantically correct with appropriate onDelete: 'restrict' behavior. However, lines 24-25 have inconsistent indentation compared to other column definitions in the file.

🔎 Fix indentation
     categoryId: uuid('category_id')
-  .notNull()
-  .references(() => categories.id, { onDelete: 'restrict' }),
+      .notNull()
+      .references(() => categories.id, { onDelete: 'restrict' }),
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9a2407b and e9a49a0.

📒 Files selected for processing (17)
  • frontend/app/api/questions/[category]/route.ts (3 hunks)
  • frontend/app/api/quiz/guest-result/route.ts (1 hunks)
  • frontend/components/quiz/PendingResultHandler.tsx (1 hunks)
  • frontend/db/schema/categories.ts (1 hunks)
  • frontend/db/schema/questions.ts (1 hunks)
  • frontend/db/schema/quiz.ts (2 hunks)
  • frontend/db/seed-categories.ts (1 hunks)
  • frontend/db/seed-questions.ts (2 hunks)
  • frontend/db/seed-quiz-from-json.ts (5 hunks)
  • frontend/db/seed-quiz-javascript.ts (5 hunks)
  • frontend/db/seed-quiz-react.ts (5 hunks)
  • frontend/drizzle/0004_tough_ultron.sql (1 hunks)
  • frontend/drizzle/0005_furry_warstar.sql (1 hunks)
  • frontend/drizzle/meta/0004_snapshot.json (1 hunks)
  • frontend/drizzle/meta/0005_snapshot.json (1 hunks)
  • frontend/drizzle/meta/_journal.json (1 hunks)
  • frontend/lib/guest-quiz.ts (0 hunks)
💤 Files with no reviewable changes (1)
  • frontend/lib/guest-quiz.ts
🧰 Additional context used
🧬 Code graph analysis (8)
frontend/db/seed-quiz-javascript.ts (3)
frontend/db/index.ts (1)
  • db (17-17)
frontend/db/schema/categories.ts (1)
  • categories (12-19)
frontend/db/seed.ts (1)
  • main (19-50)
frontend/db/seed-categories.ts (4)
frontend/data/category.ts (1)
  • categoryNames (1-9)
frontend/db/index.ts (1)
  • db (17-17)
frontend/db/schema/categories.ts (2)
  • categories (12-19)
  • categoryTranslations (21-33)
frontend/drizzle/schema.ts (1)
  • categories (19-30)
frontend/app/api/questions/[category]/route.ts (4)
frontend/db/index.ts (1)
  • db (17-17)
frontend/db/schema/categories.ts (1)
  • categories (12-19)
frontend/drizzle/schema.ts (2)
  • categories (19-30)
  • questions (32-47)
frontend/db/schema/questions.ts (2)
  • questions (14-27)
  • questionTranslations (29-42)
frontend/db/schema/categories.ts (1)
frontend/drizzle/schema.ts (1)
  • categories (19-30)
frontend/db/seed-quiz-from-json.ts (2)
frontend/db/index.ts (1)
  • db (17-17)
frontend/db/schema/categories.ts (1)
  • categories (12-19)
frontend/db/seed-questions.ts (4)
frontend/db/index.ts (1)
  • db (17-17)
frontend/db/schema/categories.ts (1)
  • categories (12-19)
frontend/drizzle/schema.ts (2)
  • categories (19-30)
  • questions (32-47)
frontend/db/schema/questions.ts (2)
  • questions (14-27)
  • questionTranslations (29-42)
frontend/db/schema/quiz.ts (1)
frontend/db/schema/categories.ts (1)
  • categories (12-19)
frontend/db/schema/questions.ts (2)
frontend/drizzle/schema.ts (2)
  • questions (32-47)
  • categories (19-30)
frontend/db/schema/categories.ts (1)
  • categories (12-19)
🔇 Additional comments (25)
frontend/app/api/quiz/guest-result/route.ts (1)

83-83: LGTM! Error logging improves observability.

The addition of error logging in the catch block helps with debugging and monitoring failed guest quiz result saves.

frontend/drizzle/meta/0004_snapshot.json (1)

1-1620: LGTM!

The schema snapshot correctly reflects the i18n normalization changes with properly defined composite primary keys for translation tables and appropriate FK constraints (CASCADE for translations, RESTRICT for questions→categories).

frontend/db/schema/questions.ts (3)

14-27: LGTM - Well-structured questions table with appropriate constraints.

The table correctly separates locale-independent fields (categoryId, sortOrder, difficulty, timestamps) from translatable content. The onDelete: 'restrict' on categoryId appropriately prevents accidental data loss.


29-42: LGTM - Proper composite primary key for translations.

The translation table correctly uses a composite primary key on (questionId, locale) with cascading deletes to the parent question.


44-60: LGTM - Relations properly defined.

The bidirectional relations between questions and translations, plus the category relation, enable proper ORM query composition.

frontend/db/schema/categories.ts (2)

12-19: LGTM - Categories table correctly normalized.

The slug field with unique constraint enables locale-independent URL routing while displayOrder allows controlled presentation order.


21-47: LGTM - Translation table and relations properly structured.

Consistent pattern with questionTranslations - composite PK, cascading deletes, and bidirectional relations.

frontend/drizzle/meta/_journal.json (1)

32-46: LGTM!

The journal correctly tracks the two new migrations in sequential order with appropriate timestamps.

frontend/drizzle/0005_furry_warstar.sql (1)

1-4: Migration is safe; FK constraint will enforce data integrity.

The migration renames topic_id to category_id and adds a foreign key constraint to categories(id). Since both columns use UUID types and topic_id was already referencing categories, no separate data migration is needed. The FK constraint (Line 3) will automatically validate that all existing values correspond to valid category IDs—if they don't, the migration will fail, alerting you to fix the data before proceeding.

Likely an incorrect or invalid review comment.

frontend/db/seed-quiz-from-json.ts (1)

7-7: LGTM: Category-based seeding pattern correctly implemented.

The migration from topicId to categoryId with slug-based lookup is well-implemented. The error message properly guides users to run seed:categories first.

Also applies to: 24-25, 67-76

frontend/db/seed-quiz-javascript.ts (1)

78-149: LGTM: Clean category-based quiz seeding implementation.

The ensureQuizExists function correctly resolves the category by slug, handles the missing category case with a clear error message, and properly uses categoryId for both update and insert paths.

frontend/drizzle/meta/0005_snapshot.json (1)

1-1634: LGTM: Migration snapshot reflects the normalized i18n schema correctly.

The schema snapshot properly captures:

  • Composite primary keys for translation tables (category_id, locale, question_id, locale)
  • Appropriate ON DELETE CASCADE for translations
  • ON DELETE RESTRICT for parent references (preventing orphan data)
  • Unique constraint on quizzes(category_id, slug) enforcing one quiz slug per category
frontend/app/api/questions/[category]/route.ts (2)

32-47: Returning 200 with empty result for missing category is a reasonable choice.

This approach provides a consistent response structure. However, consider if a 404 would be more semantically correct for API consumers trying to distinguish between "category exists but has no questions" vs "category doesn't exist".


49-91: LGTM: Translation-aware querying correctly implemented.

The INNER JOIN with questionTranslations ensures:

  1. Only questions with translations for the requested locale are returned
  2. Count and items queries are consistent
  3. Pagination works correctly with offset

Note that questions without a translation for the requested locale will be excluded from results, which is the expected behavior for i18n.

frontend/db/seed-quiz-react.ts (5)

2-4: LGTM!

The new imports for eq and categories are correctly added to support the category-based lookup pattern.


50-51: LGTM!

Slug-based category lookup is more maintainable than hardcoded IDs and aligns with the category seeding approach.


886-902: LGTM!

The category lookup pattern is well-implemented with proper error handling and a clear error message guiding users to run the category seed first.


904-917: LGTM!

Log messages are clear and consistent with the seeding flow.


970-976: LGTM!

The summary output provides useful feedback, and the error handling correctly logs and re-throws.

frontend/db/seed-categories.ts (2)

3-6: LGTM!

The imports are correctly updated to include categoryTranslations, and the locale order is consistent with other seed scripts.


38-43: LGTM!

Clear and concise logging for success and failure cases.

frontend/db/schema/quiz.ts (1)

17-17: LGTM!

Import correctly added to support the new foreign key reference.

frontend/drizzle/0004_tough_ultron.sql (3)

1-14: LGTM!

The translation tables are correctly structured with composite primary keys on (entity_id, locale), which is the standard pattern for i18n.


34-36: Verify data migration plan before running in production.

This migration drops columns (name, question, answer_blocks) that may contain existing data. If there's any data in the current categories and questions tables, it will be lost when these columns are dropped.

Before deploying to an environment with existing data, ensure you have a data migration plan to:

  1. Copy existing categories.name to category_translations (for a default locale)
  2. Copy existing questions.question and questions.answer_blocks to question_translations

31-37: LGTM!

The foreign key constraints are well-designed:

  • CASCADE on translation tables ensures translations are cleaned up when parent entities are deleted
  • RESTRICT on questions.category_id prevents accidental deletion of categories with associated questions
  • Unique constraint on categories.slug prevents duplicate slugs

Comment on lines +14 to 42
export const questions = pgTable('questions', {
id: uuid('id').defaultRandom().primaryKey(),
categoryId: uuid('category_id')
.notNull()
.references(() => categories.id, { onDelete: 'restrict' }),
sortOrder: integer('sort_order').notNull().default(0),
difficulty: varchar('difficulty', { length: 20 }).default('medium'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow(),
});

export const questions = pgTable(
'questions',
export const questionTranslations = pgTable(
'question_translations',
{
id: uuid('id').defaultRandom().primaryKey(),
categorySlug: varchar('category_slug', { length: 50 }).notNull(),
questionId: uuid('question_id')
.notNull()
.references(() => questions.id, { onDelete: 'cascade' }),
locale: varchar('locale', { length: 5 }).notNull(),
question: text('question').notNull(),
answerBlocks: jsonb('answer_blocks').notNull(),
sortOrder: integer('sort_order').default(0),
},
table => [
unique('questions_category_locale_order_unique').on(
table.categorySlug,
table.locale,
table.sortOrder
),
]
table => ({
pk: primaryKey({ columns: [table.questionId, table.locale] }),
})
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if drizzle/schema.ts exists and compare structure
if [ -f "frontend/drizzle/schema.ts" ]; then
  echo "=== frontend/drizzle/schema.ts questions table ==="
  rg -n -A 20 'export const questions = pgTable' frontend/drizzle/schema.ts
  echo ""
  echo "=== frontend/db/schema/questions.ts questions table ==="
  rg -n -A 15 'export const questions = pgTable' frontend/db/schema/questions.ts
else
  echo "File frontend/drizzle/schema.ts does not exist"
fi

Repository: DevLoversTeam/devlovers.net

Length of output: 1430


🏁 Script executed:

#!/bin/bash
# Check the questionTranslations table in questions.ts
echo "=== frontend/db/schema/questions.ts - questionTranslations table ==="
rg -n -A 12 'export const questionTranslations = pgTable' frontend/db/schema/questions.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 612


Regenerate frontend/drizzle/schema.ts to match the updated schema structure.

The file frontend/drizzle/schema.ts contains an outdated questions table definition with question and answerBlocks directly on it, whereas the authoritative schema in frontend/db/schema/questions.ts has moved those columns to the questionTranslations table. Additionally, the new schema adds sortOrder, difficulty, createdAt, and updatedAt columns to the questions table, and changes the foreign key constraint from onDelete: 'cascade' to onDelete: 'restrict'. Run drizzle-kit pull to regenerate this introspection file.

@ViktorSvertoka ViktorSvertoka merged commit 01c31a2 into develop Dec 21, 2025
5 checks passed
@ViktorSvertoka ViktorSvertoka deleted the sl/feat/quiz branch December 21, 2025 20:52
@LesiaUKR LesiaUKR restored the sl/feat/quiz branch December 21, 2025 23:04
liudmylasovetovs pushed a commit that referenced this pull request Jan 9, 2026
refactor(db): normalize categories and questions tables for i18n
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants