Module Update
Some checks failed
CI / build-and-test (push) Failing after 1m31s
CI / deploy (push) Has been skipped

This commit is contained in:
SpecialX
2025-12-30 14:42:30 +08:00
parent f1797265b2
commit e7c902e8e1
148 changed files with 19317 additions and 113 deletions

275
src/shared/db/schema.ts Normal file
View File

@@ -0,0 +1,275 @@
import {
mysqlTable,
varchar,
text,
timestamp,
int,
primaryKey,
index,
json,
mysqlEnum,
boolean,
foreignKey
} from "drizzle-orm/mysql-core";
import { createId } from "@paralleldrive/cuid2";
import type { AdapterAccountType } from "next-auth/adapters";
// --- Helper for ID generation (CUID2) ---
const id = (name: string) => varchar(name, { length: 128 }).notNull().$defaultFn(() => createId());
// --- 1. Users & Auth (Auth.js v5 Standard + RBAC) ---
export const users = mysqlTable("users", {
id: id("id").primaryKey(),
name: varchar("name", { length: 255 }),
email: varchar("email", { length: 255 }).notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: varchar("image", { length: 255 }),
// Custom Role Field for RBAC (Default Role)
role: varchar("role", { length: 50 }).default("student"),
// Credentials Auth (Optional)
password: varchar("password", { length: 255 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
emailIdx: index("email_idx").on(table.email),
}));
// Auth.js: Accounts (OAuth providers)
export const accounts = mysqlTable("accounts", {
userId: varchar("userId", { length: 128 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: varchar("type", { length: 255 }).$type<AdapterAccountType>().notNull(),
provider: varchar("provider", { length: 255 }).notNull(),
providerAccountId: varchar("providerAccountId", { length: 255 }).notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: int("expires_at"),
token_type: varchar("token_type", { length: 255 }),
scope: varchar("scope", { length: 255 }),
id_token: text("id_token"),
session_state: varchar("session_state", { length: 255 }),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
userIdIdx: index("account_userId_idx").on(account.userId),
})
);
// Auth.js: Sessions
export const sessions = mysqlTable("sessions", {
sessionToken: varchar("sessionToken", { length: 255 }).primaryKey(),
userId: varchar("userId", { length: 128 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
}, (table) => ({
userIdIdx: index("session_userId_idx").on(table.userId),
}));
// Auth.js: Verification Tokens
export const verificationTokens = mysqlTable("verificationTokens", {
identifier: varchar("identifier", { length: 255 }).notNull(),
token: varchar("token", { length: 255 }).notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
}, (vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
}));
// --- Custom RBAC Extensions ---
export const roles = mysqlTable("roles", {
id: id("id").primaryKey(),
name: varchar("name", { length: 50 }).notNull().unique(), // e.g., 'admin', 'teacher', 'student', 'grade_head'
description: varchar("description", { length: 255 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
});
// Many-to-Many: Users <-> Roles
// Solves: "A teacher can also be a Grade Head"
export const usersToRoles = mysqlTable("users_to_roles", {
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
roleId: varchar("role_id", { length: 128 }).notNull().references(() => roles.id, { onDelete: "cascade" }),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.roleId] }),
userIdIdx: index("user_id_idx").on(table.userId),
}));
// --- 2. Knowledge Points (Tree Structure) ---
export const knowledgePoints = mysqlTable("knowledge_points", {
id: id("id").primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
// Tree Structure: Parent KP
parentId: varchar("parent_id", { length: 128 }), // Self-reference defined in relations
// Metadata for ordering or level
level: int("level").default(0),
order: int("order").default(0),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
parentIdIdx: index("parent_id_idx").on(table.parentId),
}));
// --- 3. Question Bank (Core) ---
export const questionTypeEnum = mysqlEnum("type", ["single_choice", "multiple_choice", "text", "judgment", "composite"]);
export const questions = mysqlTable("questions", {
id: id("id").primaryKey(),
// Content can be JSON to store rich text, images, etc. or just text.
// Using JSON for flexibility in a modern ed-tech app (e.g. SlateJS nodes).
content: json("content").notNull(),
type: questionTypeEnum.notNull(),
difficulty: int("difficulty").default(1), // 1-5
// Self-reference for "Infinite Nesting" (Parent Question)
// e.g., A reading comprehension passage (Parent) -> 5 sub-questions (Children)
parentId: varchar("parent_id", { length: 128 }),
authorId: varchar("author_id", { length: 128 }).notNull().references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
parentIdIdx: index("parent_id_idx").on(table.parentId), // Critical for querying children
authorIdIdx: index("author_id_idx").on(table.authorId),
// In a real large-scale system, we might add Full-Text Search index on content
}));
// Many-to-Many: Questions <-> Knowledge Points
export const questionsToKnowledgePoints = mysqlTable("questions_to_knowledge_points", {
questionId: varchar("question_id", { length: 128 }).notNull(),
knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.questionId, table.knowledgePointId] }),
kpIdx: index("kp_idx").on(table.knowledgePointId), // For querying "All questions under this KP"
qFk: foreignKey({
columns: [table.questionId],
foreignColumns: [questions.id],
name: "q_kp_qid_fk"
}).onDelete("cascade"),
kpFk: foreignKey({
columns: [table.knowledgePointId],
foreignColumns: [knowledgePoints.id],
name: "q_kp_kpid_fk"
}).onDelete("cascade"),
}));
// --- 4. Academic / Teaching Flow ---
export const textbooks = mysqlTable("textbooks", {
id: id("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
subject: varchar("subject", { length: 100 }).notNull(),
grade: varchar("grade", { length: 50 }),
publisher: varchar("publisher", { length: 100 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
});
export const chapters = mysqlTable("chapters", {
id: id("id").primaryKey(),
textbookId: varchar("textbook_id", { length: 128 }).notNull().references(() => textbooks.id, { onDelete: "cascade" }),
title: varchar("title", { length: 255 }).notNull(),
order: int("order").default(0),
// Chapters can also be nested
parentId: varchar("parent_id", { length: 128 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
textbookIdx: index("textbook_idx").on(table.textbookId),
parentIdIdx: index("parent_id_idx").on(table.parentId),
}));
export const exams = mysqlTable("exams", {
id: id("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
/**
* Stores the hierarchical structure of the exam.
* Expected JSON Schema:
* type ExamStructure = Array<
* | { type: 'group', title: string, children: ExamStructure }
* | { type: 'question', questionId: string, score: number }
* >
*
* Note: This is for UI presentation/ordering.
* Real relational integrity is maintained in 'exam_questions' table.
*/
structure: json("structure"),
creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id),
startTime: timestamp("start_time"),
endTime: timestamp("end_time"),
// Status: draft, published, ongoing, finished
status: varchar("status", { length: 50 }).default("draft"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
});
// Linking Exams to Questions (Many-to-Many often, or One-to-Many if specific to exam)
// Usually questions are reused, so Many-to-Many
export const examQuestions = mysqlTable("exam_questions", {
examId: varchar("exam_id", { length: 128 }).notNull().references(() => exams.id, { onDelete: "cascade" }),
questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id, { onDelete: "cascade" }),
score: int("score").default(0),
order: int("order").default(0),
}, (table) => ({
pk: primaryKey({ columns: [table.examId, table.questionId] }),
}));
export const examSubmissions = mysqlTable("exam_submissions", {
id: id("id").primaryKey(),
examId: varchar("exam_id", { length: 128 }).notNull().references(() => exams.id, { onDelete: "cascade" }),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
score: int("score"), // Total score
status: varchar("status", { length: 50 }).default("started"), // started, submitted, graded
submittedAt: timestamp("submitted_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
examStudentIdx: index("exam_student_idx").on(table.examId, table.studentId),
}));
export const submissionAnswers = mysqlTable("submission_answers", {
id: id("id").primaryKey(),
submissionId: varchar("submission_id", { length: 128 }).notNull().references(() => examSubmissions.id, { onDelete: "cascade" }),
questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id),
answerContent: json("answer_content"), // Student's answer
score: int("score"), // Score for this specific question
feedback: text("feedback"), // Teacher's feedback
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
submissionIdx: index("submission_idx").on(table.submissionId),
}));
// Re-export old courses table if needed or deprecate it.
// Assuming we are replacing the old simple schema with this robust one.
// But if there were existing tables, we might keep them or comment them out.
// For this task, I will overwrite completely as this is a "System Architect" redesign.