diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 00:33:44 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 00:33:44 +0900 |
| commit | 5bb62819796bcd3b5e945662c23299eb8db71e34 (patch) | |
| tree | b0e3b71d68b8a019d34a9f6ef2379c5b7bbf2c1b /src | |
| parent | f464d028b452ed9b7cfda415428299123765e6f6 (diff) | |
| download | kioku-5bb62819796bcd3b5e945662c23299eb8db71e34.tar.gz kioku-5bb62819796bcd3b5e945662c23299eb8db71e34.tar.zst kioku-5bb62819796bcd3b5e945662c23299eb8db71e34.zip | |
feat(db): add Note feature database schema
Add database tables and Zod validation schemas for Anki-compatible
Note concept as outlined in the roadmap Phase 1:
- note_types: defines note structure (templates, reversibility)
- note_field_types: defines fields within a note type
- notes: container for field values belonging to a deck
- note_field_values: actual field content for notes
- cards: add nullable note_id and is_reversed columns
Includes migration file and comprehensive test coverage for all
new Zod validation schemas.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/server/db/schema.ts | 83 | ||||
| -rw-r--r-- | src/server/schemas/index.ts | 107 | ||||
| -rw-r--r-- | src/server/schemas/note.test.ts | 427 |
3 files changed, 617 insertions, 0 deletions
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 4b9631f..bd3d396 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,4 +1,5 @@ import { + boolean, integer, pgTable, real, @@ -25,6 +26,11 @@ export const Rating = { Easy: 4, } as const; +// Field types for note fields +export const FieldType = { + Text: "text", +} as const; + export const users = pgTable("users", { id: uuid("id").primaryKey().defaultRandom(), username: varchar("username", { length: 255 }).notNull().unique(), @@ -49,6 +55,45 @@ export const refreshTokens = pgTable("refresh_tokens", { .defaultNow(), }); +export const noteTypes = pgTable("note_types", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + name: varchar("name", { length: 255 }).notNull(), + frontTemplate: text("front_template").notNull(), + backTemplate: text("back_template").notNull(), + isReversible: boolean("is_reversible").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + syncVersion: integer("sync_version").notNull().default(0), +}); + +export const noteFieldTypes = pgTable("note_field_types", { + id: uuid("id").primaryKey().defaultRandom(), + noteTypeId: uuid("note_type_id") + .notNull() + .references(() => noteTypes.id), + name: varchar("name", { length: 255 }).notNull(), + order: integer("order").notNull(), + fieldType: varchar("field_type", { length: 50 }) + .notNull() + .default(FieldType.Text), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + syncVersion: integer("sync_version").notNull().default(0), +}); + export const decks = pgTable("decks", { id: uuid("id").primaryKey().defaultRandom(), userId: uuid("user_id") @@ -67,11 +112,49 @@ export const decks = pgTable("decks", { syncVersion: integer("sync_version").notNull().default(0), }); +export const notes = pgTable("notes", { + id: uuid("id").primaryKey().defaultRandom(), + deckId: uuid("deck_id") + .notNull() + .references(() => decks.id), + noteTypeId: uuid("note_type_id") + .notNull() + .references(() => noteTypes.id), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + syncVersion: integer("sync_version").notNull().default(0), +}); + +export const noteFieldValues = pgTable("note_field_values", { + id: uuid("id").primaryKey().defaultRandom(), + noteId: uuid("note_id") + .notNull() + .references(() => notes.id), + noteFieldTypeId: uuid("note_field_type_id") + .notNull() + .references(() => noteFieldTypes.id), + value: text("value").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + syncVersion: integer("sync_version").notNull().default(0), +}); + export const cards = pgTable("cards", { id: uuid("id").primaryKey().defaultRandom(), deckId: uuid("deck_id") .notNull() .references(() => decks.id), + noteId: uuid("note_id").references(() => notes.id), + isReversed: boolean("is_reversed"), front: text("front").notNull(), back: text("back").notNull(), diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts index 0227ebe..d2a8fb0 100644 --- a/src/server/schemas/index.ts +++ b/src/server/schemas/index.ts @@ -105,6 +105,98 @@ export const updateCardSchema = z.object({ back: z.string().min(1).optional(), }); +// Field type schema +export const fieldTypeSchema = z.literal("text"); + +// NoteType schema +export const noteTypeSchema = z.object({ + id: z.uuid(), + userId: z.uuid(), + name: z.string().min(1).max(255), + frontTemplate: z.string().min(1), + backTemplate: z.string().min(1), + isReversible: z.boolean(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + deletedAt: z.coerce.date().nullable(), + syncVersion: z.number().int().min(0), +}); + +// NoteType creation input schema +export const createNoteTypeSchema = z.object({ + name: z.string().min(1).max(255), + frontTemplate: z.string().min(1), + backTemplate: z.string().min(1), + isReversible: z.boolean().default(false), +}); + +// NoteType update input schema +export const updateNoteTypeSchema = z.object({ + name: z.string().min(1).max(255).optional(), + frontTemplate: z.string().min(1).optional(), + backTemplate: z.string().min(1).optional(), + isReversible: z.boolean().optional(), +}); + +// NoteFieldType schema +export const noteFieldTypeSchema = z.object({ + id: z.uuid(), + noteTypeId: z.uuid(), + name: z.string().min(1).max(255), + order: z.number().int().min(0), + fieldType: fieldTypeSchema, + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + deletedAt: z.coerce.date().nullable(), + syncVersion: z.number().int().min(0), +}); + +// NoteFieldType creation input schema +export const createNoteFieldTypeSchema = z.object({ + name: z.string().min(1).max(255), + order: z.number().int().min(0), + fieldType: fieldTypeSchema.default("text"), +}); + +// NoteFieldType update input schema +export const updateNoteFieldTypeSchema = z.object({ + name: z.string().min(1).max(255).optional(), + order: z.number().int().min(0).optional(), +}); + +// Note schema +export const noteSchema = z.object({ + id: z.uuid(), + deckId: z.uuid(), + noteTypeId: z.uuid(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + deletedAt: z.coerce.date().nullable(), + syncVersion: z.number().int().min(0), +}); + +// Note creation input schema (fields is a map of fieldTypeId -> value) +export const createNoteSchema = z.object({ + noteTypeId: z.uuid(), + fields: z.record(z.uuid(), z.string()), +}); + +// Note update input schema +export const updateNoteSchema = z.object({ + fields: z.record(z.uuid(), z.string()), +}); + +// NoteFieldValue schema +export const noteFieldValueSchema = z.object({ + id: z.uuid(), + noteId: z.uuid(), + noteFieldTypeId: z.uuid(), + value: z.string(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + syncVersion: z.number().int().min(0), +}); + // ReviewLog schema export const reviewLogSchema = z.object({ id: z.uuid(), @@ -138,3 +230,18 @@ export type CreateCardSchema = z.infer<typeof createCardSchema>; export type UpdateCardSchema = z.infer<typeof updateCardSchema>; export type ReviewLogSchema = z.infer<typeof reviewLogSchema>; export type SubmitReviewSchema = z.infer<typeof submitReviewSchema>; +export type FieldTypeSchema = z.infer<typeof fieldTypeSchema>; +export type NoteTypeSchema = z.infer<typeof noteTypeSchema>; +export type CreateNoteTypeSchema = z.infer<typeof createNoteTypeSchema>; +export type UpdateNoteTypeSchema = z.infer<typeof updateNoteTypeSchema>; +export type NoteFieldTypeSchema = z.infer<typeof noteFieldTypeSchema>; +export type CreateNoteFieldTypeSchema = z.infer< + typeof createNoteFieldTypeSchema +>; +export type UpdateNoteFieldTypeSchema = z.infer< + typeof updateNoteFieldTypeSchema +>; +export type NoteSchema = z.infer<typeof noteSchema>; +export type CreateNoteSchema = z.infer<typeof createNoteSchema>; +export type UpdateNoteSchema = z.infer<typeof updateNoteSchema>; +export type NoteFieldValueSchema = z.infer<typeof noteFieldValueSchema>; diff --git a/src/server/schemas/note.test.ts b/src/server/schemas/note.test.ts new file mode 100644 index 0000000..0a9b84b --- /dev/null +++ b/src/server/schemas/note.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from "vitest"; +import { + createNoteFieldTypeSchema, + createNoteSchema, + createNoteTypeSchema, + noteFieldTypeSchema, + noteFieldValueSchema, + noteSchema, + noteTypeSchema, + updateNoteFieldTypeSchema, + updateNoteSchema, + updateNoteTypeSchema, +} from "./index"; + +describe("NoteType schemas", () => { + describe("noteTypeSchema", () => { + const validNoteType = { + id: "550e8400-e29b-41d4-a716-446655440000", + userId: "550e8400-e29b-41d4-a716-446655440001", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + }; + + it("should parse valid note type", () => { + const result = noteTypeSchema.safeParse(validNoteType); + expect(result.success).toBe(true); + }); + + it("should parse date strings", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.createdAt).toBeInstanceOf(Date); + expect(result.data.updatedAt).toBeInstanceOf(Date); + } + }); + + it("should reject invalid UUID for id", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + id: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + + it("should reject empty name", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + name: "", + }); + expect(result.success).toBe(false); + }); + + it("should reject name longer than 255 characters", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + name: "a".repeat(256), + }); + expect(result.success).toBe(false); + }); + + it("should reject empty frontTemplate", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + frontTemplate: "", + }); + expect(result.success).toBe(false); + }); + + it("should reject empty backTemplate", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + backTemplate: "", + }); + expect(result.success).toBe(false); + }); + + it("should reject negative syncVersion", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + syncVersion: -1, + }); + expect(result.success).toBe(false); + }); + + it("should accept deletedAt as date", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + deletedAt: new Date("2024-01-02"), + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.deletedAt).toBeInstanceOf(Date); + } + }); + }); + + describe("createNoteTypeSchema", () => { + it("should parse valid create input", () => { + const result = createNoteTypeSchema.safeParse({ + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: true, + }); + expect(result.success).toBe(true); + }); + + it("should default isReversible to false", () => { + const result = createNoteTypeSchema.safeParse({ + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isReversible).toBe(false); + } + }); + + it("should reject missing name", () => { + const result = createNoteTypeSchema.safeParse({ + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }); + expect(result.success).toBe(false); + }); + + it("should reject missing frontTemplate", () => { + const result = createNoteTypeSchema.safeParse({ + name: "Basic", + backTemplate: "{{Back}}", + }); + expect(result.success).toBe(false); + }); + + it("should reject missing backTemplate", () => { + const result = createNoteTypeSchema.safeParse({ + name: "Basic", + frontTemplate: "{{Front}}", + }); + expect(result.success).toBe(false); + }); + }); + + describe("updateNoteTypeSchema", () => { + it("should parse with all optional fields", () => { + const result = updateNoteTypeSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should parse with partial fields", () => { + const result = updateNoteTypeSchema.safeParse({ + name: "Updated Name", + }); + expect(result.success).toBe(true); + }); + + it("should parse with all fields", () => { + const result = updateNoteTypeSchema.safeParse({ + name: "Updated Name", + frontTemplate: "{{NewFront}}", + backTemplate: "{{NewBack}}", + isReversible: true, + }); + expect(result.success).toBe(true); + }); + + it("should reject empty name if provided", () => { + const result = updateNoteTypeSchema.safeParse({ + name: "", + }); + expect(result.success).toBe(false); + }); + }); +}); + +describe("NoteFieldType schemas", () => { + describe("noteFieldTypeSchema", () => { + const validNoteFieldType = { + id: "550e8400-e29b-41d4-a716-446655440000", + noteTypeId: "550e8400-e29b-41d4-a716-446655440001", + name: "Front", + order: 0, + fieldType: "text" as const, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + }; + + it("should parse valid note field type", () => { + const result = noteFieldTypeSchema.safeParse(validNoteFieldType); + expect(result.success).toBe(true); + }); + + it("should reject invalid fieldType", () => { + const result = noteFieldTypeSchema.safeParse({ + ...validNoteFieldType, + fieldType: "invalid", + }); + expect(result.success).toBe(false); + }); + + it("should reject negative order", () => { + const result = noteFieldTypeSchema.safeParse({ + ...validNoteFieldType, + order: -1, + }); + expect(result.success).toBe(false); + }); + + it("should reject empty name", () => { + const result = noteFieldTypeSchema.safeParse({ + ...validNoteFieldType, + name: "", + }); + expect(result.success).toBe(false); + }); + }); + + describe("createNoteFieldTypeSchema", () => { + it("should parse valid create input", () => { + const result = createNoteFieldTypeSchema.safeParse({ + name: "Front", + order: 0, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.fieldType).toBe("text"); + } + }); + + it("should accept explicit fieldType", () => { + const result = createNoteFieldTypeSchema.safeParse({ + name: "Front", + order: 0, + fieldType: "text", + }); + expect(result.success).toBe(true); + }); + + it("should reject missing name", () => { + const result = createNoteFieldTypeSchema.safeParse({ + order: 0, + }); + expect(result.success).toBe(false); + }); + + it("should reject missing order", () => { + const result = createNoteFieldTypeSchema.safeParse({ + name: "Front", + }); + expect(result.success).toBe(false); + }); + }); + + describe("updateNoteFieldTypeSchema", () => { + it("should parse with all optional fields", () => { + const result = updateNoteFieldTypeSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should parse with name only", () => { + const result = updateNoteFieldTypeSchema.safeParse({ + name: "Updated Field", + }); + expect(result.success).toBe(true); + }); + + it("should parse with order only", () => { + const result = updateNoteFieldTypeSchema.safeParse({ + order: 1, + }); + expect(result.success).toBe(true); + }); + }); +}); + +describe("Note schemas", () => { + describe("noteSchema", () => { + const validNote = { + id: "550e8400-e29b-41d4-a716-446655440000", + deckId: "550e8400-e29b-41d4-a716-446655440001", + noteTypeId: "550e8400-e29b-41d4-a716-446655440002", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + }; + + it("should parse valid note", () => { + const result = noteSchema.safeParse(validNote); + expect(result.success).toBe(true); + }); + + it("should reject invalid deckId", () => { + const result = noteSchema.safeParse({ + ...validNote, + deckId: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + + it("should reject invalid noteTypeId", () => { + const result = noteSchema.safeParse({ + ...validNote, + noteTypeId: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + }); + + describe("createNoteSchema", () => { + it("should parse valid create input", () => { + const result = createNoteSchema.safeParse({ + noteTypeId: "550e8400-e29b-41d4-a716-446655440000", + fields: { + "550e8400-e29b-41d4-a716-446655440001": "Front content", + "550e8400-e29b-41d4-a716-446655440002": "Back content", + }, + }); + expect(result.success).toBe(true); + }); + + it("should accept empty fields", () => { + const result = createNoteSchema.safeParse({ + noteTypeId: "550e8400-e29b-41d4-a716-446655440000", + fields: {}, + }); + expect(result.success).toBe(true); + }); + + it("should reject missing noteTypeId", () => { + const result = createNoteSchema.safeParse({ + fields: {}, + }); + expect(result.success).toBe(false); + }); + + it("should reject missing fields", () => { + const result = createNoteSchema.safeParse({ + noteTypeId: "550e8400-e29b-41d4-a716-446655440000", + }); + expect(result.success).toBe(false); + }); + + it("should reject invalid UUID in fields key", () => { + const result = createNoteSchema.safeParse({ + noteTypeId: "550e8400-e29b-41d4-a716-446655440000", + fields: { + "not-a-uuid": "content", + }, + }); + expect(result.success).toBe(false); + }); + }); + + describe("updateNoteSchema", () => { + it("should parse valid update input", () => { + const result = updateNoteSchema.safeParse({ + fields: { + "550e8400-e29b-41d4-a716-446655440001": "Updated content", + }, + }); + expect(result.success).toBe(true); + }); + + it("should reject missing fields", () => { + const result = updateNoteSchema.safeParse({}); + expect(result.success).toBe(false); + }); + }); +}); + +describe("NoteFieldValue schemas", () => { + describe("noteFieldValueSchema", () => { + const validNoteFieldValue = { + id: "550e8400-e29b-41d4-a716-446655440000", + noteId: "550e8400-e29b-41d4-a716-446655440001", + noteFieldTypeId: "550e8400-e29b-41d4-a716-446655440002", + value: "Some content", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + syncVersion: 0, + }; + + it("should parse valid note field value", () => { + const result = noteFieldValueSchema.safeParse(validNoteFieldValue); + expect(result.success).toBe(true); + }); + + it("should accept empty value", () => { + const result = noteFieldValueSchema.safeParse({ + ...validNoteFieldValue, + value: "", + }); + expect(result.success).toBe(true); + }); + + it("should reject invalid noteId", () => { + const result = noteFieldValueSchema.safeParse({ + ...validNoteFieldValue, + noteId: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + + it("should reject invalid noteFieldTypeId", () => { + const result = noteFieldValueSchema.safeParse({ + ...validNoteFieldValue, + noteFieldTypeId: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + }); +}); |
