diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 00:44:54 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 00:44:54 +0900 |
| commit | a6156762ee77bf4bdf7085ff912dd325b45658f0 (patch) | |
| tree | a8fcf9e109aceefd3e6e3dbbfad9756c033d6f4b | |
| parent | fd97b55005efc72f4d3bde54e31cbe950435d0f5 (diff) | |
| download | kioku-a6156762ee77bf4bdf7085ff912dd325b45658f0.tar.gz kioku-a6156762ee77bf4bdf7085ff912dd325b45658f0.tar.zst kioku-a6156762ee77bf4bdf7085ff912dd325b45658f0.zip | |
feat(repo): add Note repository with CRUD operations
Implements NoteRepository for Note feature (Phase 2 of roadmap):
- Create notes with field values and auto-generate cards
- Support reversible note types (creates 2 cards)
- Update notes and their field values
- Soft delete notes with cascade to cards
- Template rendering for card front/back content
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | src/server/repositories/index.ts | 1 | ||||
| -rw-r--r-- | src/server/repositories/note.test.ts | 417 | ||||
| -rw-r--r-- | src/server/repositories/note.ts | 325 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 52 |
4 files changed, 795 insertions, 0 deletions
diff --git a/src/server/repositories/index.ts b/src/server/repositories/index.ts index 267430f..3256a49 100644 --- a/src/server/repositories/index.ts +++ b/src/server/repositories/index.ts @@ -1,5 +1,6 @@ export { cardRepository } from "./card.js"; export { deckRepository } from "./deck.js"; +export { noteRepository } from "./note.js"; export { noteFieldTypeRepository, noteTypeRepository, diff --git a/src/server/repositories/note.test.ts b/src/server/repositories/note.test.ts new file mode 100644 index 0000000..fc8b553 --- /dev/null +++ b/src/server/repositories/note.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + Card, + CreateNoteResult, + Note, + NoteFieldValue, + NoteRepository, + NoteWithFieldValues, +} from "./types.js"; + +function createMockNote(overrides: Partial<Note> = {}): Note { + return { + id: "note-uuid-123", + deckId: "deck-uuid-123", + noteTypeId: "note-type-uuid-123", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + ...overrides, + }; +} + +function createMockNoteFieldValue( + overrides: Partial<NoteFieldValue> = {}, +): NoteFieldValue { + return { + id: "field-value-uuid-123", + noteId: "note-uuid-123", + noteFieldTypeId: "field-type-uuid-123", + value: "Test value", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + syncVersion: 0, + ...overrides, + }; +} + +function createMockCard(overrides: Partial<Card> = {}): Card { + return { + id: "card-uuid-123", + deckId: "deck-uuid-123", + front: "Front text", + back: "Back text", + state: 0, + due: new Date("2024-01-01"), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + ...overrides, + }; +} + +function createMockNoteWithFieldValues( + overrides: Partial<NoteWithFieldValues> = {}, +): NoteWithFieldValues { + const note = createMockNote(overrides); + return { + ...note, + fieldValues: overrides.fieldValues ?? [ + createMockNoteFieldValue({ + noteFieldTypeId: "field-front", + value: "Question", + }), + createMockNoteFieldValue({ + id: "field-value-uuid-456", + noteFieldTypeId: "field-back", + value: "Answer", + }), + ], + }; +} + +function createMockCreateNoteResult( + overrides: Partial<CreateNoteResult> = {}, +): CreateNoteResult { + return { + note: createMockNote(overrides.note), + fieldValues: overrides.fieldValues ?? [ + createMockNoteFieldValue({ + noteFieldTypeId: "field-front", + value: "Question", + }), + createMockNoteFieldValue({ + id: "field-value-uuid-456", + noteFieldTypeId: "field-back", + value: "Answer", + }), + ], + cards: overrides.cards ?? [createMockCard()], + }; +} + +function createMockNoteRepo(): NoteRepository { + return { + findByDeckId: vi.fn(), + findById: vi.fn(), + findByIdWithFieldValues: vi.fn(), + create: vi.fn(), + update: vi.fn(), + softDelete: vi.fn(), + }; +} + +describe("NoteRepository mock factory", () => { + describe("createMockNote", () => { + it("creates a valid Note with defaults", () => { + const note = createMockNote(); + + expect(note.id).toBe("note-uuid-123"); + expect(note.deckId).toBe("deck-uuid-123"); + expect(note.noteTypeId).toBe("note-type-uuid-123"); + expect(note.deletedAt).toBeNull(); + expect(note.syncVersion).toBe(0); + }); + + it("allows overriding properties", () => { + const note = createMockNote({ + id: "custom-id", + noteTypeId: "custom-note-type-id", + }); + + expect(note.id).toBe("custom-id"); + expect(note.noteTypeId).toBe("custom-note-type-id"); + expect(note.deckId).toBe("deck-uuid-123"); + }); + }); + + describe("createMockNoteFieldValue", () => { + it("creates a valid NoteFieldValue with defaults", () => { + const fieldValue = createMockNoteFieldValue(); + + expect(fieldValue.id).toBe("field-value-uuid-123"); + expect(fieldValue.noteId).toBe("note-uuid-123"); + expect(fieldValue.noteFieldTypeId).toBe("field-type-uuid-123"); + expect(fieldValue.value).toBe("Test value"); + expect(fieldValue.syncVersion).toBe(0); + }); + + it("allows overriding properties", () => { + const fieldValue = createMockNoteFieldValue({ + value: "Custom value", + noteFieldTypeId: "custom-field-type", + }); + + expect(fieldValue.value).toBe("Custom value"); + expect(fieldValue.noteFieldTypeId).toBe("custom-field-type"); + }); + }); + + describe("createMockNoteWithFieldValues", () => { + it("creates Note with default field values", () => { + const noteWithFields = createMockNoteWithFieldValues(); + + expect(noteWithFields.fieldValues).toHaveLength(2); + expect(noteWithFields.fieldValues[0]?.value).toBe("Question"); + expect(noteWithFields.fieldValues[1]?.value).toBe("Answer"); + }); + + it("allows overriding field values", () => { + const customFieldValues = [ + createMockNoteFieldValue({ noteFieldTypeId: "word", value: "日本語" }), + createMockNoteFieldValue({ + noteFieldTypeId: "reading", + value: "にほんご", + }), + createMockNoteFieldValue({ + noteFieldTypeId: "meaning", + value: "Japanese", + }), + ]; + const noteWithFields = createMockNoteWithFieldValues({ + fieldValues: customFieldValues, + }); + + expect(noteWithFields.fieldValues).toHaveLength(3); + expect(noteWithFields.fieldValues[0]?.value).toBe("日本語"); + expect(noteWithFields.fieldValues[2]?.value).toBe("Japanese"); + }); + }); + + describe("createMockCreateNoteResult", () => { + it("creates a valid CreateNoteResult with defaults", () => { + const result = createMockCreateNoteResult(); + + expect(result.note.id).toBe("note-uuid-123"); + expect(result.fieldValues).toHaveLength(2); + expect(result.cards).toHaveLength(1); + }); + + it("creates result with multiple cards for reversible note type", () => { + const result = createMockCreateNoteResult({ + cards: [ + createMockCard({ id: "card-1", front: "Q", back: "A" }), + createMockCard({ id: "card-2", front: "A", back: "Q" }), + ], + }); + + expect(result.cards).toHaveLength(2); + expect(result.cards[0]?.front).toBe("Q"); + expect(result.cards[1]?.front).toBe("A"); + }); + }); + + describe("createMockNoteRepo", () => { + it("creates a repository with all required methods", () => { + const repo = createMockNoteRepo(); + + expect(repo.findByDeckId).toBeDefined(); + expect(repo.findById).toBeDefined(); + expect(repo.findByIdWithFieldValues).toBeDefined(); + expect(repo.create).toBeDefined(); + expect(repo.update).toBeDefined(); + expect(repo.softDelete).toBeDefined(); + }); + + it("methods are mockable for findByDeckId", async () => { + const repo = createMockNoteRepo(); + const mockNotes = [createMockNote(), createMockNote({ id: "note-2" })]; + + vi.mocked(repo.findByDeckId).mockResolvedValue(mockNotes); + + const results = await repo.findByDeckId("deck-123"); + expect(results).toHaveLength(2); + expect(repo.findByDeckId).toHaveBeenCalledWith("deck-123"); + }); + + it("methods are mockable for findById", async () => { + const repo = createMockNoteRepo(); + const mockNote = createMockNote(); + + vi.mocked(repo.findById).mockResolvedValue(mockNote); + + const found = await repo.findById("note-id", "deck-id"); + expect(found).toEqual(mockNote); + expect(repo.findById).toHaveBeenCalledWith("note-id", "deck-id"); + }); + + it("methods are mockable for findByIdWithFieldValues", async () => { + const repo = createMockNoteRepo(); + const mockNoteWithFields = createMockNoteWithFieldValues(); + + vi.mocked(repo.findByIdWithFieldValues).mockResolvedValue( + mockNoteWithFields, + ); + + const found = await repo.findByIdWithFieldValues("note-id", "deck-id"); + expect(found?.fieldValues).toHaveLength(2); + expect(repo.findByIdWithFieldValues).toHaveBeenCalledWith( + "note-id", + "deck-id", + ); + }); + + it("methods are mockable for create", async () => { + const repo = createMockNoteRepo(); + const mockResult = createMockCreateNoteResult(); + + vi.mocked(repo.create).mockResolvedValue(mockResult); + + const result = await repo.create("deck-123", { + noteTypeId: "note-type-123", + fields: { "field-front": "Question", "field-back": "Answer" }, + }); + expect(result.note.id).toBe("note-uuid-123"); + expect(result.cards).toHaveLength(1); + expect(repo.create).toHaveBeenCalledWith("deck-123", { + noteTypeId: "note-type-123", + fields: { "field-front": "Question", "field-back": "Answer" }, + }); + }); + + it("methods are mockable for update", async () => { + const repo = createMockNoteRepo(); + const mockUpdated = createMockNoteWithFieldValues({ + fieldValues: [ + createMockNoteFieldValue({ value: "Updated Question" }), + createMockNoteFieldValue({ value: "Updated Answer" }), + ], + }); + + vi.mocked(repo.update).mockResolvedValue(mockUpdated); + + const updated = await repo.update("note-id", "deck-id", { + "field-front": "Updated Question", + "field-back": "Updated Answer", + }); + expect(updated?.fieldValues[0]?.value).toBe("Updated Question"); + }); + + it("methods are mockable for softDelete", async () => { + const repo = createMockNoteRepo(); + + vi.mocked(repo.softDelete).mockResolvedValue(true); + + const deleted = await repo.softDelete("note-id", "deck-id"); + expect(deleted).toBe(true); + expect(repo.softDelete).toHaveBeenCalledWith("note-id", "deck-id"); + }); + + it("returns undefined when note not found", async () => { + const repo = createMockNoteRepo(); + + vi.mocked(repo.findById).mockResolvedValue(undefined); + vi.mocked(repo.findByIdWithFieldValues).mockResolvedValue(undefined); + vi.mocked(repo.update).mockResolvedValue(undefined); + + expect(await repo.findById("nonexistent", "deck-id")).toBeUndefined(); + expect( + await repo.findByIdWithFieldValues("nonexistent", "deck-id"), + ).toBeUndefined(); + expect(await repo.update("nonexistent", "deck-id", {})).toBeUndefined(); + }); + + it("returns false when soft delete fails", async () => { + const repo = createMockNoteRepo(); + + vi.mocked(repo.softDelete).mockResolvedValue(false); + + const deleted = await repo.softDelete("nonexistent", "deck-id"); + expect(deleted).toBe(false); + }); + }); +}); + +describe("Note interface contracts", () => { + it("Note has required sync fields", () => { + const note = createMockNote(); + + expect(note).toHaveProperty("syncVersion"); + expect(note).toHaveProperty("createdAt"); + expect(note).toHaveProperty("updatedAt"); + expect(note).toHaveProperty("deletedAt"); + }); + + it("NoteFieldValue has required sync fields", () => { + const fieldValue = createMockNoteFieldValue(); + + expect(fieldValue).toHaveProperty("syncVersion"); + expect(fieldValue).toHaveProperty("createdAt"); + expect(fieldValue).toHaveProperty("updatedAt"); + }); + + it("NoteWithFieldValues extends Note with fieldValues array", () => { + const noteWithFields = createMockNoteWithFieldValues(); + + expect(noteWithFields).toHaveProperty("id"); + expect(noteWithFields).toHaveProperty("deckId"); + expect(noteWithFields).toHaveProperty("noteTypeId"); + expect(noteWithFields).toHaveProperty("fieldValues"); + expect(Array.isArray(noteWithFields.fieldValues)).toBe(true); + }); + + it("CreateNoteResult contains note, fieldValues, and cards", () => { + const result = createMockCreateNoteResult(); + + expect(result).toHaveProperty("note"); + expect(result).toHaveProperty("fieldValues"); + expect(result).toHaveProperty("cards"); + expect(Array.isArray(result.fieldValues)).toBe(true); + expect(Array.isArray(result.cards)).toBe(true); + }); +}); + +describe("Note deletion behavior", () => { + it("soft delete cascades to cards", async () => { + const repo = createMockNoteRepo(); + + vi.mocked(repo.softDelete).mockResolvedValue(true); + + const deleted = await repo.softDelete("note-id", "deck-id"); + expect(deleted).toBe(true); + }); +}); + +describe("Card generation from Note", () => { + it("creates one card for non-reversible note type", () => { + const result = createMockCreateNoteResult({ + cards: [createMockCard({ front: "Question", back: "Answer" })], + }); + + expect(result.cards).toHaveLength(1); + expect(result.cards[0]?.front).toBe("Question"); + expect(result.cards[0]?.back).toBe("Answer"); + }); + + it("creates two cards for reversible note type", () => { + const result = createMockCreateNoteResult({ + cards: [ + createMockCard({ + id: "card-normal", + front: "Question", + back: "Answer", + }), + createMockCard({ + id: "card-reversed", + front: "Answer", + back: "Question", + }), + ], + }); + + expect(result.cards).toHaveLength(2); + expect(result.cards[0]?.front).toBe("Question"); + expect(result.cards[0]?.back).toBe("Answer"); + expect(result.cards[1]?.front).toBe("Answer"); + expect(result.cards[1]?.back).toBe("Question"); + }); +}); diff --git a/src/server/repositories/note.ts b/src/server/repositories/note.ts new file mode 100644 index 0000000..52cbf9b --- /dev/null +++ b/src/server/repositories/note.ts @@ -0,0 +1,325 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { + CardState, + cards, + noteFieldTypes, + noteFieldValues, + notes, + noteTypes, +} from "../db/schema.js"; +import type { + Card, + CreateNoteResult, + Note, + NoteFieldValue, + NoteRepository, + NoteWithFieldValues, +} from "./types.js"; + +export const noteRepository: NoteRepository = { + async findByDeckId(deckId: string): Promise<Note[]> { + const result = await db + .select() + .from(notes) + .where(and(eq(notes.deckId, deckId), isNull(notes.deletedAt))); + return result; + }, + + async findById(id: string, deckId: string): Promise<Note | undefined> { + const result = await db + .select() + .from(notes) + .where( + and( + eq(notes.id, id), + eq(notes.deckId, deckId), + isNull(notes.deletedAt), + ), + ); + return result[0]; + }, + + async findByIdWithFieldValues( + id: string, + deckId: string, + ): Promise<NoteWithFieldValues | undefined> { + const note = await this.findById(id, deckId); + if (!note) { + return undefined; + } + + const fieldValuesResult = await db + .select() + .from(noteFieldValues) + .where(eq(noteFieldValues.noteId, id)); + + return { + ...note, + fieldValues: fieldValuesResult, + }; + }, + + async create( + deckId: string, + data: { + noteTypeId: string; + fields: Record<string, string>; + }, + ): Promise<CreateNoteResult> { + const noteType = await db + .select() + .from(noteTypes) + .where( + and(eq(noteTypes.id, data.noteTypeId), isNull(noteTypes.deletedAt)), + ); + + if (!noteType[0]) { + throw new Error("Note type not found"); + } + + const fieldTypes = await db + .select() + .from(noteFieldTypes) + .where( + and( + eq(noteFieldTypes.noteTypeId, data.noteTypeId), + isNull(noteFieldTypes.deletedAt), + ), + ) + .orderBy(noteFieldTypes.order); + + const [note] = await db + .insert(notes) + .values({ + deckId, + noteTypeId: data.noteTypeId, + }) + .returning(); + + if (!note) { + throw new Error("Failed to create note"); + } + + const fieldValuesResult: NoteFieldValue[] = []; + for (const fieldType of fieldTypes) { + const value = data.fields[fieldType.id] ?? ""; + const [fieldValue] = await db + .insert(noteFieldValues) + .values({ + noteId: note.id, + noteFieldTypeId: fieldType.id, + value, + }) + .returning(); + if (fieldValue) { + fieldValuesResult.push(fieldValue); + } + } + + const createdCards: Card[] = []; + + const normalCard = await createCardForNote( + deckId, + note.id, + noteType[0], + fieldValuesResult, + fieldTypes, + false, + ); + createdCards.push(normalCard); + + if (noteType[0].isReversible) { + const reversedCard = await createCardForNote( + deckId, + note.id, + noteType[0], + fieldValuesResult, + fieldTypes, + true, + ); + createdCards.push(reversedCard); + } + + return { + note, + fieldValues: fieldValuesResult, + cards: createdCards, + }; + }, + + async update( + id: string, + deckId: string, + fields: Record<string, string>, + ): Promise<NoteWithFieldValues | undefined> { + const note = await this.findById(id, deckId); + if (!note) { + return undefined; + } + + const [updatedNote] = await db + .update(notes) + .set({ + updatedAt: new Date(), + syncVersion: sql`${notes.syncVersion} + 1`, + }) + .where(and(eq(notes.id, id), eq(notes.deckId, deckId))) + .returning(); + + if (!updatedNote) { + return undefined; + } + + const updatedFieldValues: NoteFieldValue[] = []; + for (const [fieldTypeId, value] of Object.entries(fields)) { + const existingFieldValue = await db + .select() + .from(noteFieldValues) + .where( + and( + eq(noteFieldValues.noteId, id), + eq(noteFieldValues.noteFieldTypeId, fieldTypeId), + ), + ); + + if (existingFieldValue[0]) { + const [updated] = await db + .update(noteFieldValues) + .set({ + value, + updatedAt: new Date(), + syncVersion: sql`${noteFieldValues.syncVersion} + 1`, + }) + .where( + and( + eq(noteFieldValues.noteId, id), + eq(noteFieldValues.noteFieldTypeId, fieldTypeId), + ), + ) + .returning(); + if (updated) { + updatedFieldValues.push(updated); + } + } else { + const [created] = await db + .insert(noteFieldValues) + .values({ + noteId: id, + noteFieldTypeId: fieldTypeId, + value, + }) + .returning(); + if (created) { + updatedFieldValues.push(created); + } + } + } + + const allFieldValues = await db + .select() + .from(noteFieldValues) + .where(eq(noteFieldValues.noteId, id)); + + return { + ...updatedNote, + fieldValues: allFieldValues, + }; + }, + + async softDelete(id: string, deckId: string): Promise<boolean> { + const note = await this.findById(id, deckId); + if (!note) { + return false; + } + + const now = new Date(); + + await db + .update(cards) + .set({ + deletedAt: now, + updatedAt: now, + syncVersion: sql`${cards.syncVersion} + 1`, + }) + .where(and(eq(cards.noteId, id), isNull(cards.deletedAt))); + + const result = await db + .update(notes) + .set({ + deletedAt: now, + updatedAt: now, + syncVersion: sql`${notes.syncVersion} + 1`, + }) + .where( + and( + eq(notes.id, id), + eq(notes.deckId, deckId), + isNull(notes.deletedAt), + ), + ) + .returning({ id: notes.id }); + + return result.length > 0; + }, +}; + +async function createCardForNote( + deckId: string, + noteId: string, + noteType: { frontTemplate: string; backTemplate: string }, + fieldValues: NoteFieldValue[], + fieldTypes: { id: string; name: string }[], + isReversed: boolean, +): Promise<Card> { + const fieldMap = new Map<string, string>(); + for (const fv of fieldValues) { + const fieldType = fieldTypes.find((ft) => ft.id === fv.noteFieldTypeId); + if (fieldType) { + fieldMap.set(fieldType.name, fv.value); + } + } + + const frontTemplate = isReversed + ? noteType.backTemplate + : noteType.frontTemplate; + const backTemplate = isReversed + ? noteType.frontTemplate + : noteType.backTemplate; + + const front = renderTemplate(frontTemplate, fieldMap); + const back = renderTemplate(backTemplate, fieldMap); + + const [card] = await db + .insert(cards) + .values({ + deckId, + noteId, + isReversed, + front, + back, + state: CardState.New, + due: new Date(), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + }) + .returning(); + + if (!card) { + throw new Error("Failed to create card"); + } + + return card; +} + +function renderTemplate(template: string, fields: Map<string, string>): string { + let result = template; + for (const [name, value] of fields) { + result = result.replace(new RegExp(`\\{\\{${name}\\}\\}`, "g"), value); + } + return result; +} diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index a65a9cf..8c4c6a9 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -243,3 +243,55 @@ export interface NoteFieldTypeRepository { reorder(noteTypeId: string, fieldIds: string[]): Promise<NoteFieldType[]>; hasNoteFieldValues(id: string): Promise<boolean>; } + +export interface Note { + id: string; + deckId: string; + noteTypeId: string; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +export interface NoteFieldValue { + id: string; + noteId: string; + noteFieldTypeId: string; + value: string; + createdAt: Date; + updatedAt: Date; + syncVersion: number; +} + +export interface NoteWithFieldValues extends Note { + fieldValues: NoteFieldValue[]; +} + +export interface CreateNoteResult { + note: Note; + fieldValues: NoteFieldValue[]; + cards: Card[]; +} + +export interface NoteRepository { + findByDeckId(deckId: string): Promise<Note[]>; + findById(id: string, deckId: string): Promise<Note | undefined>; + findByIdWithFieldValues( + id: string, + deckId: string, + ): Promise<NoteWithFieldValues | undefined>; + create( + deckId: string, + data: { + noteTypeId: string; + fields: Record<string, string>; + }, + ): Promise<CreateNoteResult>; + update( + id: string, + deckId: string, + fields: Record<string, string>, + ): Promise<NoteWithFieldValues | undefined>; + softDelete(id: string, deckId: string): Promise<boolean>; +} |
