aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 00:40:11 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 00:40:11 +0900
commitfd97b55005efc72f4d3bde54e31cbe950435d0f5 (patch)
treec6564f401c4b0d69f72122bfd85b7c904c874481
parent5bb62819796bcd3b5e945662c23299eb8db71e34 (diff)
downloadkioku-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.md4
-rw-r--r--src/server/repositories/index.ts4
-rw-r--r--src/server/repositories/noteType.test.ts271
-rw-r--r--src/server/repositories/noteType.ts293
-rw-r--r--src/server/repositories/types.ts81
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>;
+}