aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 00:33:44 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 00:33:44 +0900
commit5bb62819796bcd3b5e945662c23299eb8db71e34 (patch)
treeb0e3b71d68b8a019d34a9f6ef2379c5b7bbf2c1b /src
parentf464d028b452ed9b7cfda415428299123765e6f6 (diff)
downloadkioku-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.ts83
-rw-r--r--src/server/schemas/index.ts107
-rw-r--r--src/server/schemas/note.test.ts427
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);
+ });
+ });
+});