refactor(db): normalize categories and questions tables for i18n#74
refactor(db): normalize categories and questions tables for i18n#74ViktorSvertoka merged 2 commits intodevelopfrom
Conversation
- 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
✅ Deploy Preview for develop-devlovers ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
WalkthroughThis 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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes
Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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 whenpendingis 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 bylocalealone (common in i18n scenarios), a secondary index onlocaleor 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
tryblock. 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 codefrontend/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 oncategoryIddefinition.The
categoryIdcolumn definition is semantically correct with appropriateonDelete: '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
📒 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
slugfield with unique constraint enables locale-independent URL routing whiledisplayOrderallows 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_idtocategory_idand adds a foreign key constraint tocategories(id). Since both columns use UUID types andtopic_idwas 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
topicIdtocategoryIdwith slug-based lookup is well-implemented. The error message properly guides users to runseed:categoriesfirst.Also applies to: 24-25, 67-76
frontend/db/seed-quiz-javascript.ts (1)
78-149: LGTM: Clean category-based quiz seeding implementation.The
ensureQuizExistsfunction correctly resolves the category by slug, handles the missing category case with a clear error message, and properly usescategoryIdfor 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 CASCADEfor translationsON DELETE RESTRICTfor parent references (preventing orphan data)- Unique constraint on
quizzes(category_id, slug)enforcing one quiz slug per categoryfrontend/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 JOINwithquestionTranslationsensures:
- Only questions with translations for the requested locale are returned
- Count and items queries are consistent
- 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
eqandcategoriesare 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 currentcategoriesandquestionstables, 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:
- Copy existing
categories.nametocategory_translations(for a default locale)- Copy existing
questions.questionandquestions.answer_blockstoquestion_translations
31-37: LGTM!The foreign key constraints are well-designed:
CASCADEon translation tables ensures translations are cleaned up when parent entities are deletedRESTRICTonquestions.category_idprevents accidental deletion of categories with associated questions- Unique constraint on
categories.slugprevents duplicate slugs
| 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] }), | ||
| }) | ||
| ); |
There was a problem hiding this comment.
🧩 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"
fiRepository: 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.tsRepository: 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.
refactor(db): normalize categories and questions tables for i18n
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes
Chores
✏️ Tip: You can customize this high-level summary in your review settings.