diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 00:40:11 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 00:40:11 +0900 |
| commit | fd97b55005efc72f4d3bde54e31cbe950435d0f5 (patch) | |
| tree | c6564f401c4b0d69f72122bfd85b7c904c874481 | |
| parent | 5bb62819796bcd3b5e945662c23299eb8db71e34 (diff) | |
| download | kioku-fd97b55005efc72f4d3bde54e31cbe950435d0f5.tar.gz kioku-fd97b55005efc72f4d3bde54e31cbe950435d0f5.tar.zst kioku-fd97b55005efc72f4d3bde54e31cbe950435d0f5.zip | |
feat(repo): add NoteType and NoteFieldType repositories
Implement repository layer for Note feature (Phase 2 of roadmap):
- Add NoteTypeRepository with CRUD, findByIdWithFields, hasNotes
- Add NoteFieldTypeRepository with CRUD, reorder, hasNoteFieldValues
- Add type definitions for NoteType, NoteFieldType, NoteTypeWithFields
- Add unit tests for mock factories and interface contracts
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/dev/roadmap.md | 4 | ||||
| -rw-r--r-- | src/server/repositories/index.ts | 4 | ||||
| -rw-r--r-- | src/server/repositories/noteType.test.ts | 271 | ||||
| -rw-r--r-- | src/server/repositories/noteType.ts | 293 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 81 |
5 files changed, 651 insertions, 2 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index e868ab7..1005f42 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -146,10 +146,10 @@ Create these as default note types for each user: ### Phase 2: Server Repositories **Tasks:** -- [ ] Create `NoteTypeRepository` +- [x] Create `NoteTypeRepository` - CRUD operations - Include fields when fetching -- [ ] Create `NoteTypeFieldRepository` +- [x] Create `NoteTypeFieldRepository` - CRUD operations - Reorder fields - [ ] Create `NoteRepository` diff --git a/src/server/repositories/index.ts b/src/server/repositories/index.ts index 7770d60..267430f 100644 --- a/src/server/repositories/index.ts +++ b/src/server/repositories/index.ts @@ -1,5 +1,9 @@ export { cardRepository } from "./card.js"; export { deckRepository } from "./deck.js"; +export { + noteFieldTypeRepository, + noteTypeRepository, +} from "./noteType.js"; export { refreshTokenRepository } from "./refresh-token.js"; export { reviewLogRepository } from "./review-log.js"; export { syncRepository } from "./sync.js"; diff --git a/src/server/repositories/noteType.test.ts b/src/server/repositories/noteType.test.ts new file mode 100644 index 0000000..236feee --- /dev/null +++ b/src/server/repositories/noteType.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + NoteFieldType, + NoteFieldTypeRepository, + NoteType, + NoteTypeRepository, + NoteTypeWithFields, +} from "./types.js"; + +function createMockNoteType(overrides: Partial<NoteType> = {}): NoteType { + return { + id: "note-type-uuid-123", + userId: "user-uuid-123", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + ...overrides, + }; +} + +function createMockNoteFieldType( + overrides: Partial<NoteFieldType> = {}, +): NoteFieldType { + return { + id: "field-type-uuid-123", + noteTypeId: "note-type-uuid-123", + name: "Front", + order: 0, + fieldType: "text", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + ...overrides, + }; +} + +function createMockNoteTypeWithFields( + overrides: Partial<NoteTypeWithFields> = {}, +): NoteTypeWithFields { + const noteType = createMockNoteType(overrides); + return { + ...noteType, + fields: overrides.fields ?? [ + createMockNoteFieldType({ name: "Front", order: 0 }), + createMockNoteFieldType({ + id: "field-type-uuid-456", + name: "Back", + order: 1, + }), + ], + }; +} + +function createMockNoteTypeRepo(): NoteTypeRepository { + return { + findByUserId: vi.fn(), + findById: vi.fn(), + findByIdWithFields: vi.fn(), + create: vi.fn(), + update: vi.fn(), + softDelete: vi.fn(), + hasNotes: vi.fn(), + }; +} + +function createMockNoteFieldTypeRepo(): NoteFieldTypeRepository { + return { + findByNoteTypeId: vi.fn(), + findById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + softDelete: vi.fn(), + reorder: vi.fn(), + hasNoteFieldValues: vi.fn(), + }; +} + +describe("NoteTypeRepository mock factory", () => { + describe("createMockNoteType", () => { + it("creates a valid NoteType with defaults", () => { + const noteType = createMockNoteType(); + + expect(noteType.id).toBe("note-type-uuid-123"); + expect(noteType.userId).toBe("user-uuid-123"); + expect(noteType.name).toBe("Basic"); + expect(noteType.frontTemplate).toBe("{{Front}}"); + expect(noteType.backTemplate).toBe("{{Back}}"); + expect(noteType.isReversible).toBe(false); + expect(noteType.deletedAt).toBeNull(); + expect(noteType.syncVersion).toBe(0); + }); + + it("allows overriding properties", () => { + const noteType = createMockNoteType({ + id: "custom-id", + name: "Basic (and reversed)", + isReversible: true, + }); + + expect(noteType.id).toBe("custom-id"); + expect(noteType.name).toBe("Basic (and reversed)"); + expect(noteType.isReversible).toBe(true); + expect(noteType.userId).toBe("user-uuid-123"); + }); + }); + + describe("createMockNoteFieldType", () => { + it("creates a valid NoteFieldType with defaults", () => { + const fieldType = createMockNoteFieldType(); + + expect(fieldType.id).toBe("field-type-uuid-123"); + expect(fieldType.noteTypeId).toBe("note-type-uuid-123"); + expect(fieldType.name).toBe("Front"); + expect(fieldType.order).toBe(0); + expect(fieldType.fieldType).toBe("text"); + expect(fieldType.deletedAt).toBeNull(); + }); + + it("allows overriding properties", () => { + const fieldType = createMockNoteFieldType({ + name: "Back", + order: 1, + }); + + expect(fieldType.name).toBe("Back"); + expect(fieldType.order).toBe(1); + }); + }); + + describe("createMockNoteTypeWithFields", () => { + it("creates NoteType with default fields", () => { + const noteTypeWithFields = createMockNoteTypeWithFields(); + + expect(noteTypeWithFields.fields).toHaveLength(2); + expect(noteTypeWithFields.fields[0]?.name).toBe("Front"); + expect(noteTypeWithFields.fields[1]?.name).toBe("Back"); + }); + + it("allows overriding fields", () => { + const customFields = [ + createMockNoteFieldType({ name: "Word", order: 0 }), + createMockNoteFieldType({ name: "Reading", order: 1 }), + createMockNoteFieldType({ name: "Meaning", order: 2 }), + ]; + const noteTypeWithFields = createMockNoteTypeWithFields({ + name: "Japanese Vocabulary", + fields: customFields, + }); + + expect(noteTypeWithFields.name).toBe("Japanese Vocabulary"); + expect(noteTypeWithFields.fields).toHaveLength(3); + expect(noteTypeWithFields.fields[2]?.name).toBe("Meaning"); + }); + }); + + describe("createMockNoteTypeRepo", () => { + it("creates a repository with all required methods", () => { + const repo = createMockNoteTypeRepo(); + + expect(repo.findByUserId).toBeDefined(); + expect(repo.findById).toBeDefined(); + expect(repo.findByIdWithFields).toBeDefined(); + expect(repo.create).toBeDefined(); + expect(repo.update).toBeDefined(); + expect(repo.softDelete).toBeDefined(); + expect(repo.hasNotes).toBeDefined(); + }); + + it("methods are mockable", async () => { + const repo = createMockNoteTypeRepo(); + const mockNoteType = createMockNoteType(); + + vi.mocked(repo.findByUserId).mockResolvedValue([mockNoteType]); + vi.mocked(repo.findById).mockResolvedValue(mockNoteType); + vi.mocked(repo.create).mockResolvedValue(mockNoteType); + vi.mocked(repo.hasNotes).mockResolvedValue(false); + + const results = await repo.findByUserId("user-123"); + expect(results).toHaveLength(1); + expect(repo.findByUserId).toHaveBeenCalledWith("user-123"); + + const found = await repo.findById("note-type-id", "user-123"); + expect(found).toEqual(mockNoteType); + + const created = await repo.create({ + userId: "user-123", + name: "Test", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }); + expect(created.name).toBe("Basic"); + + const hasNotes = await repo.hasNotes("note-type-id", "user-123"); + expect(hasNotes).toBe(false); + }); + }); + + describe("createMockNoteFieldTypeRepo", () => { + it("creates a repository with all required methods", () => { + const repo = createMockNoteFieldTypeRepo(); + + expect(repo.findByNoteTypeId).toBeDefined(); + expect(repo.findById).toBeDefined(); + expect(repo.create).toBeDefined(); + expect(repo.update).toBeDefined(); + expect(repo.softDelete).toBeDefined(); + expect(repo.reorder).toBeDefined(); + expect(repo.hasNoteFieldValues).toBeDefined(); + }); + + it("methods are mockable", async () => { + const repo = createMockNoteFieldTypeRepo(); + const mockFields = [ + createMockNoteFieldType({ name: "Front", order: 0 }), + createMockNoteFieldType({ name: "Back", order: 1 }), + ]; + + vi.mocked(repo.findByNoteTypeId).mockResolvedValue(mockFields); + vi.mocked(repo.reorder).mockResolvedValue(mockFields.reverse()); + vi.mocked(repo.hasNoteFieldValues).mockResolvedValue(true); + + const fields = await repo.findByNoteTypeId("note-type-id"); + expect(fields).toHaveLength(2); + + const reordered = await repo.reorder("note-type-id", [ + "field-2", + "field-1", + ]); + expect(reordered[0]?.name).toBe("Back"); + + const hasValues = await repo.hasNoteFieldValues("field-id"); + expect(hasValues).toBe(true); + }); + }); +}); + +describe("NoteType interface contracts", () => { + it("NoteType has required FSRS sync fields", () => { + const noteType = createMockNoteType(); + + expect(noteType).toHaveProperty("syncVersion"); + expect(noteType).toHaveProperty("createdAt"); + expect(noteType).toHaveProperty("updatedAt"); + expect(noteType).toHaveProperty("deletedAt"); + }); + + it("NoteFieldType has required sync fields", () => { + const fieldType = createMockNoteFieldType(); + + expect(fieldType).toHaveProperty("syncVersion"); + expect(fieldType).toHaveProperty("createdAt"); + expect(fieldType).toHaveProperty("updatedAt"); + expect(fieldType).toHaveProperty("deletedAt"); + }); + + it("NoteTypeWithFields extends NoteType with fields array", () => { + const noteTypeWithFields = createMockNoteTypeWithFields(); + + expect(noteTypeWithFields).toHaveProperty("id"); + expect(noteTypeWithFields).toHaveProperty("userId"); + expect(noteTypeWithFields).toHaveProperty("name"); + expect(noteTypeWithFields).toHaveProperty("fields"); + expect(Array.isArray(noteTypeWithFields.fields)).toBe(true); + }); +}); diff --git a/src/server/repositories/noteType.ts b/src/server/repositories/noteType.ts new file mode 100644 index 0000000..25aa8ff --- /dev/null +++ b/src/server/repositories/noteType.ts @@ -0,0 +1,293 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { noteFieldTypes, notes, noteTypes } from "../db/schema.js"; +import type { + NoteFieldType, + NoteFieldTypeRepository, + NoteType, + NoteTypeRepository, + NoteTypeWithFields, +} from "./types.js"; + +export const noteTypeRepository: NoteTypeRepository = { + async findByUserId(userId: string): Promise<NoteType[]> { + const result = await db + .select() + .from(noteTypes) + .where(and(eq(noteTypes.userId, userId), isNull(noteTypes.deletedAt))); + return result; + }, + + async findById(id: string, userId: string): Promise<NoteType | undefined> { + const result = await db + .select() + .from(noteTypes) + .where( + and( + eq(noteTypes.id, id), + eq(noteTypes.userId, userId), + isNull(noteTypes.deletedAt), + ), + ); + return result[0]; + }, + + async findByIdWithFields( + id: string, + userId: string, + ): Promise<NoteTypeWithFields | undefined> { + const noteType = await this.findById(id, userId); + if (!noteType) { + return undefined; + } + + const fields = await db + .select() + .from(noteFieldTypes) + .where( + and( + eq(noteFieldTypes.noteTypeId, id), + isNull(noteFieldTypes.deletedAt), + ), + ) + .orderBy(noteFieldTypes.order); + + return { + ...noteType, + fields, + }; + }, + + async create(data: { + userId: string; + name: string; + frontTemplate: string; + backTemplate: string; + isReversible?: boolean; + }): Promise<NoteType> { + const [noteType] = await db + .insert(noteTypes) + .values({ + userId: data.userId, + name: data.name, + frontTemplate: data.frontTemplate, + backTemplate: data.backTemplate, + isReversible: data.isReversible ?? false, + }) + .returning(); + if (!noteType) { + throw new Error("Failed to create note type"); + } + return noteType; + }, + + async update( + id: string, + userId: string, + data: { + name?: string; + frontTemplate?: string; + backTemplate?: string; + isReversible?: boolean; + }, + ): Promise<NoteType | undefined> { + const result = await db + .update(noteTypes) + .set({ + ...data, + updatedAt: new Date(), + syncVersion: sql`${noteTypes.syncVersion} + 1`, + }) + .where( + and( + eq(noteTypes.id, id), + eq(noteTypes.userId, userId), + isNull(noteTypes.deletedAt), + ), + ) + .returning(); + return result[0]; + }, + + async softDelete(id: string, userId: string): Promise<boolean> { + const result = await db + .update(noteTypes) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + syncVersion: sql`${noteTypes.syncVersion} + 1`, + }) + .where( + and( + eq(noteTypes.id, id), + eq(noteTypes.userId, userId), + isNull(noteTypes.deletedAt), + ), + ) + .returning({ id: noteTypes.id }); + return result.length > 0; + }, + + async hasNotes(id: string, userId: string): Promise<boolean> { + const noteType = await this.findById(id, userId); + if (!noteType) { + return false; + } + + const result = await db + .select({ id: notes.id }) + .from(notes) + .where(and(eq(notes.noteTypeId, id), isNull(notes.deletedAt))) + .limit(1); + + return result.length > 0; + }, +}; + +export const noteFieldTypeRepository: NoteFieldTypeRepository = { + async findByNoteTypeId(noteTypeId: string): Promise<NoteFieldType[]> { + const result = await db + .select() + .from(noteFieldTypes) + .where( + and( + eq(noteFieldTypes.noteTypeId, noteTypeId), + isNull(noteFieldTypes.deletedAt), + ), + ) + .orderBy(noteFieldTypes.order); + return result; + }, + + async findById( + id: string, + noteTypeId: string, + ): Promise<NoteFieldType | undefined> { + const result = await db + .select() + .from(noteFieldTypes) + .where( + and( + eq(noteFieldTypes.id, id), + eq(noteFieldTypes.noteTypeId, noteTypeId), + isNull(noteFieldTypes.deletedAt), + ), + ); + return result[0]; + }, + + async create( + noteTypeId: string, + data: { + name: string; + order: number; + fieldType?: string; + }, + ): Promise<NoteFieldType> { + const [field] = await db + .insert(noteFieldTypes) + .values({ + noteTypeId, + name: data.name, + order: data.order, + fieldType: data.fieldType ?? "text", + }) + .returning(); + if (!field) { + throw new Error("Failed to create note field type"); + } + return field; + }, + + async update( + id: string, + noteTypeId: string, + data: { + name?: string; + order?: number; + }, + ): Promise<NoteFieldType | undefined> { + const result = await db + .update(noteFieldTypes) + .set({ + ...data, + updatedAt: new Date(), + syncVersion: sql`${noteFieldTypes.syncVersion} + 1`, + }) + .where( + and( + eq(noteFieldTypes.id, id), + eq(noteFieldTypes.noteTypeId, noteTypeId), + isNull(noteFieldTypes.deletedAt), + ), + ) + .returning(); + return result[0]; + }, + + async softDelete(id: string, noteTypeId: string): Promise<boolean> { + const result = await db + .update(noteFieldTypes) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + syncVersion: sql`${noteFieldTypes.syncVersion} + 1`, + }) + .where( + and( + eq(noteFieldTypes.id, id), + eq(noteFieldTypes.noteTypeId, noteTypeId), + isNull(noteFieldTypes.deletedAt), + ), + ) + .returning({ id: noteFieldTypes.id }); + return result.length > 0; + }, + + async reorder( + noteTypeId: string, + fieldIds: string[], + ): Promise<NoteFieldType[]> { + const updatedFields: NoteFieldType[] = []; + + for (let i = 0; i < fieldIds.length; i++) { + const fieldId = fieldIds[i]; + if (!fieldId) { + continue; + } + const result = await db + .update(noteFieldTypes) + .set({ + order: i, + updatedAt: new Date(), + syncVersion: sql`${noteFieldTypes.syncVersion} + 1`, + }) + .where( + and( + eq(noteFieldTypes.id, fieldId), + eq(noteFieldTypes.noteTypeId, noteTypeId), + isNull(noteFieldTypes.deletedAt), + ), + ) + .returning(); + + if (result[0]) { + updatedFields.push(result[0]); + } + } + + return updatedFields.sort((a, b) => a.order - b.order); + }, + + async hasNoteFieldValues(id: string): Promise<boolean> { + const { noteFieldValues } = await import("../db/schema.js"); + + const result = await db + .select({ id: noteFieldValues.id }) + .from(noteFieldValues) + .where(eq(noteFieldValues.noteFieldTypeId, id)) + .limit(1); + + return result.length > 0; + }, +}; diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index c5ca946..a65a9cf 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -162,3 +162,84 @@ export interface ReviewLogRepository { durationMs?: number | null; }): Promise<ReviewLog>; } + +export interface NoteType { + id: string; + userId: string; + name: string; + frontTemplate: string; + backTemplate: string; + isReversible: boolean; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +export interface NoteFieldType { + id: string; + noteTypeId: string; + name: string; + order: number; + fieldType: string; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +export interface NoteTypeWithFields extends NoteType { + fields: NoteFieldType[]; +} + +export interface NoteTypeRepository { + findByUserId(userId: string): Promise<NoteType[]>; + findById(id: string, userId: string): Promise<NoteType | undefined>; + findByIdWithFields( + id: string, + userId: string, + ): Promise<NoteTypeWithFields | undefined>; + create(data: { + userId: string; + name: string; + frontTemplate: string; + backTemplate: string; + isReversible?: boolean; + }): Promise<NoteType>; + update( + id: string, + userId: string, + data: { + name?: string; + frontTemplate?: string; + backTemplate?: string; + isReversible?: boolean; + }, + ): Promise<NoteType | undefined>; + softDelete(id: string, userId: string): Promise<boolean>; + hasNotes(id: string, userId: string): Promise<boolean>; +} + +export interface NoteFieldTypeRepository { + findByNoteTypeId(noteTypeId: string): Promise<NoteFieldType[]>; + findById(id: string, noteTypeId: string): Promise<NoteFieldType | undefined>; + create( + noteTypeId: string, + data: { + name: string; + order: number; + fieldType?: string; + }, + ): Promise<NoteFieldType>; + update( + id: string, + noteTypeId: string, + data: { + name?: string; + order?: number; + }, + ): Promise<NoteFieldType | undefined>; + softDelete(id: string, noteTypeId: string): Promise<boolean>; + reorder(noteTypeId: string, fieldIds: string[]): Promise<NoteFieldType[]>; + hasNoteFieldValues(id: string): Promise<boolean>; +} |
