feat: add db:seed command to package.json and update README.md for seeding reference data. Enhance job status handling in generation logic to support completed_partial status, improving error reporting and user feedback. Introduce new utility functions for job outcome resolution and non-retryable error detection, along with corresponding tests. Update database migration scripts to include new tables and fields for improved data management.
ef46e44 | import { relations } from "drizzle-orm"; | |
| import { | |
| pgTable, | |
| text, | |
| timestamp, | |
| boolean, | |
| integer, | |
| index, | |
| jsonb, | |
| uuid, | |
| unique, | |
| } from "drizzle-orm/pg-core"; | |
| // ββ Reference / Lookup Tables ββββββββββββββββββββββββββββββ | |
| export const examType = pgTable("exam_type", { | |
| id: text("id").primaryKey(), // e.g. "IELTS", "TOEFL", "JLPT", "HSK", "GOETHE" | |
| name: text("name").notNull(), | |
| language: text("language").notNull(), // e.g. "English", "Japanese" | |
| description: text("description"), | |
| }); | |
| export const sectionType = pgTable("section_type", { | |
| id: text("id").primaryKey(), // e.g. "READING", "WRITING", "LISTENING", "SPEAKING" | |
| name: text("name").notNull(), | |
| }); | |
| // ββ Questions ββββββββββββββββββββββββββββββββββββββββββββββ | |
| export const question = pgTable( | |
| "question", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| examTypeId: text("exam_type_id") | |
| .notNull() | |
| .references(() => examType.id, { onDelete: "cascade" }), | |
| sectionTypeId: text("section_type_id") | |
| .notNull() | |
| .references(() => sectionType.id, { onDelete: "cascade" }), | |
| // question format, e.g. "multiple_choice", "true_false_not_given", "matching", "fill_blank", "synonym", "cloze", "reference", "author_view", "sentence_completion", "summary_completion", "kanji_reading", "particle_choice", "article_case" | |
| format: text("format").notNull(), | |
| // the reading passage / context text | |
| passageText: text("passage_text").notNull(), | |
| // the actual question prompt | |
| questionText: text("question_text").notNull(), | |
| // array of options for multiple choice / matching | |
| options: jsonb("options"), | |
| // correct answer β could be an option key, text, or array for multiple correct | |
| correctAnswer: text("correct_answer").notNull(), | |
| // explanation shown after submission | |
| explanation: text("explanation"), | |
| // difficulty 1-5 | |
| difficulty: integer("difficulty").notNull().default(3), | |
| // e.g. ["grammar", "vocabulary", "inference", "main_idea", "detail"] | |
| skillTags: text("skill_tags").array().default([]), | |
| isCaseSensitive: boolean("is_case_sensitive").default(false).notNull(), | |
| source: text("source").notNull().default("manual"), // "ai" | "manual" | |
| aiModel: text("ai_model"), | |
| aiPromptUsed: text("ai_prompt_used"), | |
| creatorUserId: text("creator_user_id") | |
| .notNull() | |
| .references(() => user.id, { onDelete: "cascade" }), | |
| isPublic: boolean("is_public").default(false).notNull(), | |
| isFeatured: boolean("is_featured").default(false).notNull(), | |
| usageCount: integer("usage_count").default(0).notNull(), | |
| avgRating: integer("avg_rating"), | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| updatedAt: timestamp("updated_at") | |
| .defaultNow() | |
| .$onUpdate(() => new Date()) | |
| .notNull(), | |
| }, | |
| (table) => [ | |
| index("question_examTypeId_idx").on(table.examTypeId), | |
| index("question_sectionTypeId_idx").on(table.sectionTypeId), | |
| index("question_creatorUserId_idx").on(table.creatorUserId), | |
| index("question_isPublic_idx").on(table.isPublic), | |
| index("question_format_idx").on(table.format), | |
| index("question_difficulty_idx").on(table.difficulty), | |
| ], | |
| ); | |
| // ββ Packages (Test Bundles) ββββββββββββββββββββββββββββββββ | |
| export const testPackage = pgTable( | |
| "test_package", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| title: text("title").notNull(), | |
| description: text("description"), | |
| examTypeId: text("exam_type_id") | |
| .notNull() | |
| .references(() => examType.id, { onDelete: "cascade" }), | |
| creatorUserId: text("creator_user_id") | |
| .notNull() | |
| .references(() => user.id, { onDelete: "cascade" }), | |
| isPublic: boolean("is_public").default(false).notNull(), | |
| totalQuestions: integer("total_questions").default(0).notNull(), | |
| totalSections: integer("total_sections").default(0).notNull(), | |
| estimatedDurationMin: integer("estimated_duration_min"), | |
| usageCount: integer("usage_count").default(0).notNull(), | |
| avgRating: integer("avg_rating"), | |
| isFeatured: boolean("is_featured").default(false).notNull(), | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| updatedAt: timestamp("updated_at") | |
| .defaultNow() | |
| .$onUpdate(() => new Date()) | |
| .notNull(), | |
| }, | |
| (table) => [ | |
| index("testPackage_examTypeId_idx").on(table.examTypeId), | |
| index("testPackage_creatorUserId_idx").on(table.creatorUserId), | |
| index("testPackage_isPublic_idx").on(table.isPublic), | |
| ], | |
| ); | |
| export const packageSection = pgTable( | |
| "package_section", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| packageId: uuid("package_id") | |
| .notNull() | |
| .references(() => testPackage.id, { onDelete: "cascade" }), | |
| sectionTypeId: text("section_type_id") | |
| .notNull() | |
| .references(() => sectionType.id, { onDelete: "cascade" }), | |
| title: text("title").notNull(), | |
| orderIndex: integer("order_index").notNull().default(0), | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| }, | |
| (table) => [ | |
| index("packageSection_packageId_idx").on(table.packageId), | |
| index("packageSection_sectionTypeId_idx").on(table.sectionTypeId), | |
| ], | |
| ); | |
| export const sectionQuestion = pgTable( | |
| "section_question", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| sectionId: uuid("section_id") | |
| .notNull() | |
| .references(() => packageSection.id, { onDelete: "cascade" }), | |
| questionId: uuid("question_id") | |
| .notNull() | |
| .references(() => question.id, { onDelete: "cascade" }), | |
| orderIndex: integer("order_index").notNull().default(0), | |
| }, | |
| (table) => [ | |
| unique("sectionQuestion_unique_idx").on(table.sectionId, table.questionId), | |
| index("sectionQuestion_sectionId_idx").on(table.sectionId), | |
| ], | |
| ); | |
| // ββ Combo Packages βββββββββββββββββββββββββββββββββββββββββ | |
| export const comboPackage = pgTable( | |
| "combo_package", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| title: text("title").notNull(), | |
| description: text("description"), | |
| creatorUserId: text("creator_user_id") | |
| .notNull() | |
| .references(() => user.id, { onDelete: "cascade" }), | |
| isPublic: boolean("is_public").default(false).notNull(), | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| updatedAt: timestamp("updated_at") | |
| .defaultNow() | |
| .$onUpdate(() => new Date()) | |
| .notNull(), | |
| }, | |
| (table) => [ | |
| index("comboPackage_creatorUserId_idx").on(table.creatorUserId), | |
| index("comboPackage_isPublic_idx").on(table.isPublic), | |
| ], | |
| ); | |
| export const comboSection = pgTable( | |
| "combo_section", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| comboId: uuid("combo_id") | |
| .notNull() | |
| .references(() => comboPackage.id, { onDelete: "cascade" }), | |
| sourcePackageId: uuid("source_package_id") | |
| .notNull() | |
| .references(() => testPackage.id, { onDelete: "cascade" }), | |
| sourceSectionId: uuid("source_section_id") | |
| .notNull() | |
| .references(() => packageSection.id, { onDelete: "cascade" }), | |
| orderIndex: integer("order_index").notNull().default(0), | |
| }, | |
| (table) => [ | |
| index("comboSection_comboId_idx").on(table.comboId), | |
| index("comboSection_sourcePackageId_idx").on(table.sourcePackageId), | |
| ], | |
| ); | |
| // ββ Test Attempts & Results ββββββββββββββββββββββββββββββββ | |
| export const testAttempt = pgTable( | |
| "test_attempt", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| userId: text("user_id") | |
| .notNull() | |
| .references(() => user.id, { onDelete: "cascade" }), | |
| // could be either a package or a combo | |
| packageId: uuid("package_id").references(() => testPackage.id, { onDelete: "set null" }), | |
| comboId: uuid("combo_id").references(() => comboPackage.id, { onDelete: "set null" }), | |
| startedAt: timestamp("started_at").defaultNow().notNull(), | |
| finishedAt: timestamp("finished_at"), | |
| totalScore: integer("total_score"), | |
| maxScore: integer("max_score"), | |
| status: text("status").notNull().default("in_progress"), // "in_progress" | "completed" | "abandoned" | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| }, | |
| (table) => [ | |
| index("testAttempt_userId_idx").on(table.userId), | |
| index("testAttempt_packageId_idx").on(table.packageId), | |
| index("testAttempt_status_idx").on(table.status), | |
| ], | |
| ); | |
| export const sectionResult = pgTable( | |
| "section_result", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| attemptId: uuid("attempt_id") | |
| .notNull() | |
| .references(() => testAttempt.id, { onDelete: "cascade" }), | |
| sectionTypeId: text("section_type_id") | |
| .notNull() | |
| .references(() => sectionType.id, { onDelete: "cascade" }), | |
| score: integer("score"), | |
| maxScore: integer("max_score"), | |
| timeSpentSec: integer("time_spent_sec"), | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| }, | |
| (table) => [ | |
| index("sectionResult_attemptId_idx").on(table.attemptId), | |
| ], | |
| ); | |
| export const answer = pgTable( | |
| "answer", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| sectionResultId: uuid("section_result_id") | |
| .notNull() | |
| .references(() => sectionResult.id, { onDelete: "cascade" }), | |
| questionId: uuid("question_id") | |
| .notNull() | |
| .references(() => question.id, { onDelete: "cascade" }), | |
| userAnswer: text("user_answer"), | |
| isCorrect: boolean("is_correct"), | |
| partialScore: integer("partial_score"), // 0-100 for partial credit formats (ordering, matching) | |
| timeSpentSec: integer("time_spent_sec"), | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| }, | |
| (table) => [ | |
| unique("answer_unique_idx").on(table.sectionResultId, table.questionId), | |
| index("answer_questionId_idx").on(table.questionId), | |
| ], | |
| ); | |
| // ββ Question Ratings βββββββββββββββββββββββββββββββββββββββ | |
| export const questionRating = pgTable( | |
| "question_rating", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| userId: text("user_id") | |
| .notNull() | |
| .references(() => user.id, { onDelete: "cascade" }), | |
| questionId: uuid("question_id") | |
| .notNull() | |
| .references(() => question.id, { onDelete: "cascade" }), | |
| score: integer("score").notNull(), // 1-5 | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| }, | |
| (table) => [ | |
| unique("questionRating_unique_idx").on(table.userId, table.questionId), | |
| index("questionRating_questionId_idx").on(table.questionId), | |
| ], | |
| ); | |
| // ββ Question Feedback (thumbs up/down) βββββββββββββββββββββ | |
| export const questionFeedback = pgTable( | |
| "question_feedback", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| userId: text("user_id") | |
| .notNull() | |
| .references(() => user.id, { onDelete: "cascade" }), | |
| questionId: uuid("question_id") | |
| .notNull() | |
| .references(() => question.id, { onDelete: "cascade" }), | |
| type: text("type").notNull(), // "up" | "down" | |
| note: text("note"), | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| }, | |
| (table) => [ | |
| unique("questionFeedback_unique_idx").on(table.userId, table.questionId), | |
| index("questionFeedback_questionId_idx").on(table.questionId), | |
| ], | |
| ); | |
| // ββ Package Ratings ββββββββββββββββββββββββββββββββββββββββ | |
| export const packageRating = pgTable( | |
| "package_rating", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| userId: text("user_id") | |
| .notNull() | |
| .references(() => user.id, { onDelete: "cascade" }), | |
| packageId: uuid("package_id") | |
| .notNull() | |
| .references(() => testPackage.id, { onDelete: "cascade" }), | |
| score: integer("score").notNull(), // 1-5 | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| }, | |
| (table) => [ | |
| unique("packageRating_unique_idx").on(table.userId, table.packageId), | |
| index("packageRating_packageId_idx").on(table.packageId), | |
| ], | |
| ); | |
| // ββ Relations ββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Need user from auth.ts for relations | |
| import { user } from "./auth"; | |
| export const questionRelations = relations(question, ({ one, many }) => ({ | |
| examType: one(examType, { | |
| fields: [question.examTypeId], | |
| references: [examType.id], | |
| }), | |
| sectionType: one(sectionType, { | |
| fields: [question.sectionTypeId], | |
| references: [sectionType.id], | |
| }), | |
| creator: one(user, { | |
| fields: [question.creatorUserId], | |
| references: [user.id], | |
| }), | |
| sectionQuestions: many(sectionQuestion), | |
| ratings: many(questionRating), | |
| feedbacks: many(questionFeedback), | |
| answers: many(answer), | |
| })); | |
| export const examTypeRelations = relations(examType, ({ many }) => ({ | |
| questions: many(question), | |
| packages: many(testPackage), | |
| })); | |
| export const sectionTypeRelations = relations(sectionType, ({ many }) => ({ | |
| questions: many(question), | |
| packageSections: many(packageSection), | |
| sectionResults: many(sectionResult), | |
| })); | |
| export const testPackageRelations = relations(testPackage, ({ one, many }) => ({ | |
| examType: one(examType, { | |
| fields: [testPackage.examTypeId], | |
| references: [examType.id], | |
| }), | |
| creator: one(user, { | |
| fields: [testPackage.creatorUserId], | |
| references: [user.id], | |
| }), | |
| sections: many(packageSection), | |
| comboSections: many(comboSection), | |
| attempts: many(testAttempt), | |
| })); | |
| export const packageSectionRelations = relations(packageSection, ({ one, many }) => ({ | |
| package: one(testPackage, { | |
| fields: [packageSection.packageId], | |
| references: [testPackage.id], | |
| }), | |
| sectionType: one(sectionType, { | |
| fields: [packageSection.sectionTypeId], | |
| references: [sectionType.id], | |
| }), | |
| questions: many(sectionQuestion), | |
| comboSections: many(comboSection), | |
| })); | |
| export const sectionQuestionRelations = relations(sectionQuestion, ({ one }) => ({ | |
| section: one(packageSection, { | |
| fields: [sectionQuestion.sectionId], | |
| references: [packageSection.id], | |
| }), | |
| question: one(question, { | |
| fields: [sectionQuestion.questionId], | |
| references: [question.id], | |
| }), | |
| })); | |
| export const comboPackageRelations = relations(comboPackage, ({ one, many }) => ({ | |
| creator: one(user, { | |
| fields: [comboPackage.creatorUserId], | |
| references: [user.id], | |
| }), | |
| sections: many(comboSection), | |
| attempts: many(testAttempt), | |
| })); | |
| export const comboSectionRelations = relations(comboSection, ({ one }) => ({ | |
| combo: one(comboPackage, { | |
| fields: [comboSection.comboId], | |
| references: [comboPackage.id], | |
| }), | |
| sourcePackage: one(testPackage, { | |
| fields: [comboSection.sourcePackageId], | |
| references: [testPackage.id], | |
| }), | |
| sourceSection: one(packageSection, { | |
| fields: [comboSection.sourceSectionId], | |
| references: [packageSection.id], | |
| }), | |
| })); | |
| export const testAttemptRelations = relations(testAttempt, ({ one, many }) => ({ | |
| user: one(user, { | |
| fields: [testAttempt.userId], | |
| references: [user.id], | |
| }), | |
| package: one(testPackage, { | |
| fields: [testAttempt.packageId], | |
| references: [testPackage.id], | |
| }), | |
| combo: one(comboPackage, { | |
| fields: [testAttempt.comboId], | |
| references: [comboPackage.id], | |
| }), | |
| sectionResults: many(sectionResult), | |
| })); | |
| export const sectionResultRelations = relations(sectionResult, ({ one, many }) => ({ | |
| attempt: one(testAttempt, { | |
| fields: [sectionResult.attemptId], | |
| references: [testAttempt.id], | |
| }), | |
| sectionType: one(sectionType, { | |
| fields: [sectionResult.sectionTypeId], | |
| references: [sectionType.id], | |
| }), | |
| answers: many(answer), | |
| })); | |
| export const answerRelations = relations(answer, ({ one }) => ({ | |
| sectionResult: one(sectionResult, { | |
| fields: [answer.sectionResultId], | |
| references: [sectionResult.id], | |
| }), | |
| question: one(question, { | |
| fields: [answer.questionId], | |
| references: [question.id], | |
| }), | |
| })); | |
| export const questionRatingRelations = relations(questionRating, ({ one }) => ({ | |
| user: one(user, { | |
| fields: [questionRating.userId], | |
| references: [user.id], | |
| }), | |
| question: one(question, { | |
| fields: [questionRating.questionId], | |
| references: [question.id], | |
| }), | |
| })); | |
| export const questionFeedbackRelations = relations(questionFeedback, ({ one }) => ({ | |
| user: one(user, { | |
| fields: [questionFeedback.userId], | |
| references: [user.id], | |
| }), | |
| question: one(question, { | |
| fields: [questionFeedback.questionId], | |
| references: [question.id], | |
| }), | |
| })); | |
| export const packageRatingRelations = relations(packageRating, ({ one }) => ({ | |
| user: one(user, { | |
| fields: [packageRating.userId], | |
| references: [user.id], | |
| }), | |
| package: one(testPackage, { | |
| fields: [packageRating.packageId], | |
| references: [testPackage.id], | |
| }), | |
| })); | |
| // ββ Generation Jobs (Background AI Jobs) βββββββββββββββββββ | |
| export const generationJob = pgTable( | |
| "generation_job", | |
| { | |
| id: uuid("id").defaultRandom().primaryKey(), | |
| userId: text("user_id") | |
| .notNull() | |
| .references(() => user.id, { onDelete: "cascade" }), | |
| status: text("status").notNull().default("pending"), // "pending" | "running_*" | "partial_ready" | "completed" | "completed_partial" | "failed" | "cancelled" | |
| mode: text("mode").notNull().default("quick"), // "quick" | "agentic" | |
| examTypeId: text("exam_type_id").notNull(), | |
| sectionTypeId: text("section_type_id").notNull(), | |
| questionCount: integer("question_count").notNull(), | |
| progress: integer("progress").default(0).notNull(), // 0-100 | |
| progressMessage: text("progress_message"), | |
| logs: jsonb("logs"), // Array of {step, message, timestamp, status} | |
| resultJson: jsonb("result_json"), // GenerationResult | |
| inputJson: jsonb("input_json"), // GenerationInput (stored for retry) | |
| errorMessage: text("error_message"), | |
| tokensUsed: integer("tokens_used"), | |
| durationMs: integer("duration_ms"), | |
| createdAt: timestamp("created_at").defaultNow().notNull(), | |
| updatedAt: timestamp("updated_at") | |
| .defaultNow() | |
| .$onUpdate(() => new Date()) | |
| .notNull(), | |
| completedAt: timestamp("completed_at"), | |
| }, | |
| (table) => [ | |
| index("generationJob_userId_idx").on(table.userId), | |
| index("generationJob_status_idx").on(table.status), | |
| ], | |
| ); | |
| export const generationJobRelations = relations(generationJob, ({ one }) => ({ | |
| user: one(user, { | |
| fields: [generationJob.userId], | |
| references: [user.id], | |
| }), | |
| })); | |