diff --git a/packages/backend/drizzle/0001_fuzzy_silhouette.sql b/packages/backend/drizzle/0001_fuzzy_silhouette.sql new file mode 100644 index 0000000..f9ea158 --- /dev/null +++ b/packages/backend/drizzle/0001_fuzzy_silhouette.sql @@ -0,0 +1,42 @@ +CREATE TABLE `auth_tokens` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, + `token_hash` text NOT NULL, + `purpose` text NOT NULL, + `payload` text, + `expires_at` integer NOT NULL, + `used_at` integer, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `sessions_auth` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` integer NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `expires_at` integer NOT NULL, + `last_used_at` integer DEFAULT (unixepoch()) NOT NULL, + `user_agent` text, + `ip` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `email` text NOT NULL, + `display_name` text NOT NULL, + `password_hash` text, + `role` text DEFAULT 'user' NOT NULL, + `is_active` integer DEFAULT true NOT NULL, + `email_verified_at` integer, + `pending_email` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `auth_tokens_hash_idx` ON `auth_tokens` (`token_hash`);--> statement-breakpoint +CREATE INDEX `auth_tokens_user_purpose_idx` ON `auth_tokens` (`user_id`,`purpose`);--> statement-breakpoint +CREATE INDEX `sessions_auth_user_idx` ON `sessions_auth` (`user_id`);--> statement-breakpoint +CREATE INDEX `sessions_auth_expires_idx` ON `sessions_auth` (`expires_at`);--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint +CREATE INDEX `users_email_idx` ON `users` (`email`); \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0001_snapshot.json b/packages/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..d37fde8 --- /dev/null +++ b/packages/backend/drizzle/meta/0001_snapshot.json @@ -0,0 +1,758 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "87d22455-00f9-454b-baac-bc7638274645", + "prevId": "00509e45-a6d9-417d-b3c0-a7e936e7001f", + "tables": { + "attempts": { + "name": "attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "card_id": { + "name": "card_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_to_answer_ms": { + "name": "time_to_answer_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "attempts_session_idx": { + "name": "attempts_session_idx", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "attempts_card_idx": { + "name": "attempts_card_idx", + "columns": [ + "card_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "attempts_session_id_sessions_id_fk": { + "name": "attempts_session_id_sessions_id_fk", + "tableFrom": "attempts", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attempts_card_id_cards_id_fk": { + "name": "attempts_card_id_cards_id_fk", + "tableFrom": "attempts", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "auth_tokens": { + "name": "auth_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "auth_tokens_hash_idx": { + "name": "auth_tokens_hash_idx", + "columns": [ + "token_hash" + ], + "isUnique": false + }, + "auth_tokens_user_purpose_idx": { + "name": "auth_tokens_user_purpose_idx", + "columns": [ + "user_id", + "purpose" + ], + "isUnique": false + } + }, + "foreignKeys": { + "auth_tokens_user_id_users_id_fk": { + "name": "auth_tokens_user_id_users_id_fk", + "tableFrom": "auth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "card_progress": { + "name": "card_progress", + "columns": { + "card_id": { + "name": "card_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "box": { + "name": "box", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "correct_count": { + "name": "correct_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "incorrect_count": { + "name": "incorrect_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_shown_at": { + "name": "last_shown_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_due_at": { + "name": "next_due_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "card_progress_pk": { + "name": "card_progress_pk", + "columns": [ + "card_id", + "direction" + ], + "isUnique": false + }, + "card_progress_due_idx": { + "name": "card_progress_due_idx", + "columns": [ + "next_due_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "card_progress_card_id_cards_id_fk": { + "name": "card_progress_card_id_cards_id_fk", + "tableFrom": "card_progress", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "cards": { + "name": "cards", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hint": { + "name": "hint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "cards_lesson_idx": { + "name": "cards_lesson_idx", + "columns": [ + "lesson_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "cards_lesson_id_lessons_id_fk": { + "name": "cards_lesson_id_lessons_id_fk", + "tableFrom": "cards", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "lessons": { + "name": "lessons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bidirectional": { + "name": "bidirectional", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cards_shown": { + "name": "cards_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cards_correct": { + "name": "cards_correct", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cards_incorrect": { + "name": "cards_incorrect", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "queue_snapshot": { + "name": "queue_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_status_idx": { + "name": "sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_lesson_id_lessons_id_fk": { + "name": "sessions_lesson_id_lessons_id_fk", + "tableFrom": "sessions", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "sessions_auth": { + "name": "sessions_auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_auth_user_idx": { + "name": "sessions_auth_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_auth_expires_idx": { + "name": "sessions_auth_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_auth_user_id_users_id_fk": { + "name": "sessions_auth_user_id_users_id_fk", + "tableFrom": "sessions_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_email": { + "name": "pending_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json index 8d41a15..76e9881 100644 --- a/packages/backend/drizzle/meta/_journal.json +++ b/packages/backend/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1779302371078, "tag": "0000_exotic_wrecker", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1779309705807, + "tag": "0001_fuzzy_silhouette", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema.ts b/packages/backend/src/db/schema.ts index b1e68f1..173e5a5 100644 --- a/packages/backend/src/db/schema.ts +++ b/packages/backend/src/db/schema.ts @@ -78,8 +78,63 @@ export const attempts = sqliteTable( }) ); +export const users = sqliteTable( + 'users', + { + id: integer('id').primaryKey({ autoIncrement: true }), + email: text('email').notNull().unique(), + displayName: text('display_name').notNull(), + passwordHash: text('password_hash'), + role: text('role', { enum: ['user', 'sysadmin'] }).notNull().default('user'), + isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true), + emailVerifiedAt: integer('email_verified_at'), + pendingEmail: text('pending_email'), + createdAt: integer('created_at').notNull().default(sql`(unixepoch())`), + updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`), + }, + (t) => ({ emailIdx: index('users_email_idx').on(t.email) }) +); + +export const sessionsAuth = sqliteTable( + 'sessions_auth', + { + id: text('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + createdAt: integer('created_at').notNull().default(sql`(unixepoch())`), + expiresAt: integer('expires_at').notNull(), + lastUsedAt: integer('last_used_at').notNull().default(sql`(unixepoch())`), + userAgent: text('user_agent'), + ip: text('ip'), + }, + (t) => ({ + userIdx: index('sessions_auth_user_idx').on(t.userId), + expIdx: index('sessions_auth_expires_idx').on(t.expiresAt), + }) +); + +export const authTokens = sqliteTable( + 'auth_tokens', + { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + tokenHash: text('token_hash').notNull(), + purpose: text('purpose', { enum: ['verify_email', 'password_reset', 'invite', 'change_email'] }).notNull(), + payload: text('payload'), + expiresAt: integer('expires_at').notNull(), + usedAt: integer('used_at'), + createdAt: integer('created_at').notNull().default(sql`(unixepoch())`), + }, + (t) => ({ + hashIdx: index('auth_tokens_hash_idx').on(t.tokenHash), + userPurposeIdx: index('auth_tokens_user_purpose_idx').on(t.userId, t.purpose), + }) +); + export type LessonRow = typeof lessons.$inferSelect; export type CardRow = typeof cards.$inferSelect; export type CardProgressRow = typeof cardProgress.$inferSelect; export type SessionRow = typeof sessions.$inferSelect; export type AttemptRow = typeof attempts.$inferSelect; +export type UserRow = typeof users.$inferSelect; +export type SessionAuthRow = typeof sessionsAuth.$inferSelect; +export type AuthTokenRow = typeof authTokens.$inferSelect;