aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 00:53:48 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 00:53:48 +0900
commitc77da463a60061877cd7ddea1bd7b723b3bf2455 (patch)
tree48227c1a66403a6729213aceb510bd4c699ee494
parenta6156762ee77bf4bdf7085ff912dd325b45658f0 (diff)
downloadkioku-c77da463a60061877cd7ddea1bd7b723b3bf2455.tar.gz
kioku-c77da463a60061877cd7ddea1bd7b723b3bf2455.tar.zst
kioku-c77da463a60061877cd7ddea1bd7b723b3bf2455.zip
feat(repo): extend CardRepository with note data support
Add noteId and isReversed fields to Card interface for note-based cards. Implement findByIdWithNoteData, findByNoteId, and softDeleteByNoteId methods to support fetching cards with their associated note data and cascading deletions. 🤖 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/card.test.ts402
-rw-r--r--src/server/repositories/card.ts62
-rw-r--r--src/server/repositories/note.test.ts2
-rw-r--r--src/server/repositories/types.ts13
-rw-r--r--src/server/routes/cards.test.ts5
-rw-r--r--src/server/routes/study.test.ts5
-rw-r--r--src/server/routes/sync.test.ts4
8 files changed, 493 insertions, 4 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 1005f42..730eca5 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -152,11 +152,11 @@ Create these as default note types for each user:
- [x] Create `NoteTypeFieldRepository`
- CRUD operations
- Reorder fields
-- [ ] Create `NoteRepository`
+- [x] Create `NoteRepository`
- Create note with field values (auto-generate cards based on `is_reversible`)
- Update note (updates field values)
- Delete note (cascade soft-delete to cards)
-- [ ] Modify `CardRepository`
+- [x] Modify `CardRepository`
- Fetch card with note data for display
- Support note-based card creation
diff --git a/src/server/repositories/card.test.ts b/src/server/repositories/card.test.ts
new file mode 100644
index 0000000..22d0f41
--- /dev/null
+++ b/src/server/repositories/card.test.ts
@@ -0,0 +1,402 @@
+import { describe, expect, it, vi } from "vitest";
+import type {
+ Card,
+ CardRepository,
+ CardWithNoteData,
+ Note,
+ NoteFieldValue,
+} from "./types.js";
+
+function createMockCard(overrides: Partial<Card> = {}): Card {
+ return {
+ id: "card-uuid-123",
+ deckId: "deck-uuid-123",
+ noteId: null,
+ isReversed: null,
+ 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 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 createMockCardWithNoteData(
+ overrides: Partial<CardWithNoteData> = {},
+): CardWithNoteData {
+ const card = createMockCard({
+ noteId: "note-uuid-123",
+ isReversed: false,
+ ...overrides,
+ });
+ return {
+ ...card,
+ note: overrides.note ?? createMockNote(),
+ fieldValues: overrides.fieldValues ?? [
+ createMockNoteFieldValue({
+ noteFieldTypeId: "field-front",
+ value: "Question",
+ }),
+ createMockNoteFieldValue({
+ id: "field-value-uuid-456",
+ noteFieldTypeId: "field-back",
+ value: "Answer",
+ }),
+ ],
+ };
+}
+
+function createMockCardRepo(): CardRepository {
+ return {
+ findByDeckId: vi.fn(),
+ findById: vi.fn(),
+ findByIdWithNoteData: vi.fn(),
+ findByNoteId: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ softDelete: vi.fn(),
+ softDeleteByNoteId: vi.fn(),
+ findDueCards: vi.fn(),
+ updateFSRSFields: vi.fn(),
+ };
+}
+
+describe("CardRepository mock factory", () => {
+ describe("createMockCard", () => {
+ it("creates a valid Card with defaults", () => {
+ const card = createMockCard();
+
+ expect(card.id).toBe("card-uuid-123");
+ expect(card.deckId).toBe("deck-uuid-123");
+ expect(card.noteId).toBeNull();
+ expect(card.isReversed).toBeNull();
+ expect(card.front).toBe("Front text");
+ expect(card.back).toBe("Back text");
+ expect(card.state).toBe(0);
+ expect(card.deletedAt).toBeNull();
+ expect(card.syncVersion).toBe(0);
+ });
+
+ it("allows overriding properties", () => {
+ const card = createMockCard({
+ id: "custom-id",
+ noteId: "note-uuid-456",
+ isReversed: true,
+ front: "Custom front",
+ });
+
+ expect(card.id).toBe("custom-id");
+ expect(card.noteId).toBe("note-uuid-456");
+ expect(card.isReversed).toBe(true);
+ expect(card.front).toBe("Custom front");
+ });
+
+ it("creates card with note association", () => {
+ const card = createMockCard({
+ noteId: "note-uuid-123",
+ isReversed: false,
+ });
+
+ expect(card.noteId).toBe("note-uuid-123");
+ expect(card.isReversed).toBe(false);
+ });
+
+ it("creates reversed card", () => {
+ const card = createMockCard({
+ noteId: "note-uuid-123",
+ isReversed: true,
+ });
+
+ expect(card.noteId).toBe("note-uuid-123");
+ expect(card.isReversed).toBe(true);
+ });
+ });
+
+ describe("createMockCardWithNoteData", () => {
+ it("creates CardWithNoteData with defaults", () => {
+ const cardWithNote = createMockCardWithNoteData();
+
+ expect(cardWithNote.noteId).toBe("note-uuid-123");
+ expect(cardWithNote.isReversed).toBe(false);
+ expect(cardWithNote.note).toBeDefined();
+ expect(cardWithNote.note?.id).toBe("note-uuid-123");
+ expect(cardWithNote.fieldValues).toHaveLength(2);
+ });
+
+ it("allows overriding note", () => {
+ const customNote = createMockNote({
+ id: "custom-note-id",
+ noteTypeId: "custom-note-type",
+ });
+ const cardWithNote = createMockCardWithNoteData({
+ note: customNote,
+ });
+
+ expect(cardWithNote.note?.id).toBe("custom-note-id");
+ expect(cardWithNote.note?.noteTypeId).toBe("custom-note-type");
+ });
+
+ it("allows overriding field values", () => {
+ const customFieldValues = [
+ createMockNoteFieldValue({ noteFieldTypeId: "word", value: "日本語" }),
+ ];
+ const cardWithNote = createMockCardWithNoteData({
+ fieldValues: customFieldValues,
+ });
+
+ expect(cardWithNote.fieldValues).toHaveLength(1);
+ expect(cardWithNote.fieldValues[0]?.value).toBe("日本語");
+ });
+
+ it("can represent legacy card with null note", () => {
+ // For legacy cards without notes, we explicitly construct the type
+ const legacyCard: CardWithNoteData = {
+ ...createMockCard({ noteId: null, isReversed: null }),
+ note: null,
+ fieldValues: [],
+ };
+
+ expect(legacyCard.noteId).toBeNull();
+ expect(legacyCard.note).toBeNull();
+ expect(legacyCard.fieldValues).toHaveLength(0);
+ });
+ });
+
+ describe("createMockCardRepo", () => {
+ it("creates a repository with all required methods", () => {
+ const repo = createMockCardRepo();
+
+ expect(repo.findByDeckId).toBeDefined();
+ expect(repo.findById).toBeDefined();
+ expect(repo.findByIdWithNoteData).toBeDefined();
+ expect(repo.findByNoteId).toBeDefined();
+ expect(repo.create).toBeDefined();
+ expect(repo.update).toBeDefined();
+ expect(repo.softDelete).toBeDefined();
+ expect(repo.softDeleteByNoteId).toBeDefined();
+ expect(repo.findDueCards).toBeDefined();
+ expect(repo.updateFSRSFields).toBeDefined();
+ });
+
+ it("methods are mockable for findByDeckId", async () => {
+ const repo = createMockCardRepo();
+ const mockCards = [createMockCard(), createMockCard({ id: "card-2" })];
+
+ vi.mocked(repo.findByDeckId).mockResolvedValue(mockCards);
+
+ 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 = createMockCardRepo();
+ const mockCard = createMockCard();
+
+ vi.mocked(repo.findById).mockResolvedValue(mockCard);
+
+ const found = await repo.findById("card-id", "deck-id");
+ expect(found).toEqual(mockCard);
+ expect(repo.findById).toHaveBeenCalledWith("card-id", "deck-id");
+ });
+
+ it("methods are mockable for findByIdWithNoteData", async () => {
+ const repo = createMockCardRepo();
+ const mockCardWithNote = createMockCardWithNoteData();
+
+ vi.mocked(repo.findByIdWithNoteData).mockResolvedValue(mockCardWithNote);
+
+ const found = await repo.findByIdWithNoteData("card-id", "deck-id");
+ expect(found?.note).toBeDefined();
+ expect(found?.fieldValues).toHaveLength(2);
+ expect(repo.findByIdWithNoteData).toHaveBeenCalledWith(
+ "card-id",
+ "deck-id",
+ );
+ });
+
+ it("methods are mockable for findByNoteId", async () => {
+ const repo = createMockCardRepo();
+ const mockCards = [
+ createMockCard({ id: "card-1", isReversed: false }),
+ createMockCard({ id: "card-2", isReversed: true }),
+ ];
+
+ vi.mocked(repo.findByNoteId).mockResolvedValue(mockCards);
+
+ const found = await repo.findByNoteId("note-id");
+ expect(found).toHaveLength(2);
+ expect(found[0]?.isReversed).toBe(false);
+ expect(found[1]?.isReversed).toBe(true);
+ expect(repo.findByNoteId).toHaveBeenCalledWith("note-id");
+ });
+
+ it("methods are mockable for softDeleteByNoteId", async () => {
+ const repo = createMockCardRepo();
+
+ vi.mocked(repo.softDeleteByNoteId).mockResolvedValue(true);
+
+ const deleted = await repo.softDeleteByNoteId("note-id");
+ expect(deleted).toBe(true);
+ expect(repo.softDeleteByNoteId).toHaveBeenCalledWith("note-id");
+ });
+
+ it("returns undefined when card not found", async () => {
+ const repo = createMockCardRepo();
+
+ vi.mocked(repo.findById).mockResolvedValue(undefined);
+ vi.mocked(repo.findByIdWithNoteData).mockResolvedValue(undefined);
+
+ expect(await repo.findById("nonexistent", "deck-id")).toBeUndefined();
+ expect(
+ await repo.findByIdWithNoteData("nonexistent", "deck-id"),
+ ).toBeUndefined();
+ });
+
+ it("returns false when soft delete fails", async () => {
+ const repo = createMockCardRepo();
+
+ vi.mocked(repo.softDelete).mockResolvedValue(false);
+ vi.mocked(repo.softDeleteByNoteId).mockResolvedValue(false);
+
+ expect(await repo.softDelete("nonexistent", "deck-id")).toBe(false);
+ expect(await repo.softDeleteByNoteId("nonexistent")).toBe(false);
+ });
+
+ it("returns empty array when no cards found for note", async () => {
+ const repo = createMockCardRepo();
+
+ vi.mocked(repo.findByNoteId).mockResolvedValue([]);
+
+ const found = await repo.findByNoteId("nonexistent-note");
+ expect(found).toHaveLength(0);
+ });
+ });
+});
+
+describe("Card interface contracts", () => {
+ it("Card has required sync fields", () => {
+ const card = createMockCard();
+
+ expect(card).toHaveProperty("syncVersion");
+ expect(card).toHaveProperty("createdAt");
+ expect(card).toHaveProperty("updatedAt");
+ expect(card).toHaveProperty("deletedAt");
+ });
+
+ it("Card has required note association fields", () => {
+ const card = createMockCard();
+
+ expect(card).toHaveProperty("noteId");
+ expect(card).toHaveProperty("isReversed");
+ });
+
+ it("Card has required FSRS fields", () => {
+ const card = createMockCard();
+
+ expect(card).toHaveProperty("state");
+ expect(card).toHaveProperty("due");
+ expect(card).toHaveProperty("stability");
+ expect(card).toHaveProperty("difficulty");
+ expect(card).toHaveProperty("elapsedDays");
+ expect(card).toHaveProperty("scheduledDays");
+ expect(card).toHaveProperty("reps");
+ expect(card).toHaveProperty("lapses");
+ expect(card).toHaveProperty("lastReview");
+ });
+
+ it("CardWithNoteData extends Card with note and fieldValues", () => {
+ const cardWithNote = createMockCardWithNoteData();
+
+ expect(cardWithNote).toHaveProperty("id");
+ expect(cardWithNote).toHaveProperty("deckId");
+ expect(cardWithNote).toHaveProperty("note");
+ expect(cardWithNote).toHaveProperty("fieldValues");
+ expect(Array.isArray(cardWithNote.fieldValues)).toBe(true);
+ });
+});
+
+describe("Card and Note relationship", () => {
+ it("legacy card has null noteId and isReversed", () => {
+ const card = createMockCard();
+
+ expect(card.noteId).toBeNull();
+ expect(card.isReversed).toBeNull();
+ });
+
+ it("note-based card has noteId and isReversed set", () => {
+ const card = createMockCard({
+ noteId: "note-uuid-123",
+ isReversed: false,
+ });
+
+ expect(card.noteId).toBe("note-uuid-123");
+ expect(card.isReversed).toBe(false);
+ });
+
+ it("reversed card has isReversed true", () => {
+ const card = createMockCard({
+ noteId: "note-uuid-123",
+ isReversed: true,
+ });
+
+ expect(card.noteId).toBe("note-uuid-123");
+ expect(card.isReversed).toBe(true);
+ });
+
+ it("multiple cards can reference the same note", () => {
+ const normalCard = createMockCard({
+ id: "card-normal",
+ noteId: "shared-note-id",
+ isReversed: false,
+ });
+ const reversedCard = createMockCard({
+ id: "card-reversed",
+ noteId: "shared-note-id",
+ isReversed: true,
+ });
+
+ expect(normalCard.noteId).toBe(reversedCard.noteId);
+ expect(normalCard.isReversed).toBe(false);
+ expect(reversedCard.isReversed).toBe(true);
+ });
+});
diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts
index 76c9d30..830b2f7 100644
--- a/src/server/repositories/card.ts
+++ b/src/server/repositories/card.ts
@@ -1,7 +1,7 @@
import { and, eq, isNull, lte, sql } from "drizzle-orm";
import { db } from "../db/index.js";
-import { CardState, cards } from "../db/schema.js";
-import type { Card, CardRepository } from "./types.js";
+import { CardState, cards, noteFieldValues, notes } from "../db/schema.js";
+import type { Card, CardRepository, CardWithNoteData } from "./types.js";
export const cardRepository: CardRepository = {
async findByDeckId(deckId: string): Promise<Card[]> {
@@ -26,6 +26,50 @@ export const cardRepository: CardRepository = {
return result[0];
},
+ async findByIdWithNoteData(
+ id: string,
+ deckId: string,
+ ): Promise<CardWithNoteData | undefined> {
+ const card = await this.findById(id, deckId);
+ if (!card) {
+ return undefined;
+ }
+
+ if (!card.noteId) {
+ return {
+ ...card,
+ note: null,
+ fieldValues: [],
+ };
+ }
+
+ const noteResult = await db
+ .select()
+ .from(notes)
+ .where(and(eq(notes.id, card.noteId), isNull(notes.deletedAt)));
+
+ const note = noteResult[0] ?? null;
+
+ const fieldValuesResult = await db
+ .select()
+ .from(noteFieldValues)
+ .where(eq(noteFieldValues.noteId, card.noteId));
+
+ return {
+ ...card,
+ note,
+ fieldValues: fieldValuesResult,
+ };
+ },
+
+ async findByNoteId(noteId: string): Promise<Card[]> {
+ const result = await db
+ .select()
+ .from(cards)
+ .where(and(eq(cards.noteId, noteId), isNull(cards.deletedAt)));
+ return result;
+ },
+
async create(
deckId: string,
data: {
@@ -100,6 +144,20 @@ export const cardRepository: CardRepository = {
return result.length > 0;
},
+ async softDeleteByNoteId(noteId: string): Promise<boolean> {
+ const now = new Date();
+ const result = await db
+ .update(cards)
+ .set({
+ deletedAt: now,
+ updatedAt: now,
+ syncVersion: sql`${cards.syncVersion} + 1`,
+ })
+ .where(and(eq(cards.noteId, noteId), isNull(cards.deletedAt)))
+ .returning({ id: cards.id });
+ return result.length > 0;
+ },
+
async findDueCards(
deckId: string,
now: Date,
diff --git a/src/server/repositories/note.test.ts b/src/server/repositories/note.test.ts
index fc8b553..70dcb4a 100644
--- a/src/server/repositories/note.test.ts
+++ b/src/server/repositories/note.test.ts
@@ -40,6 +40,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card {
return {
id: "card-uuid-123",
deckId: "deck-uuid-123",
+ noteId: null,
+ isReversed: null,
front: "Front text",
back: "Back text",
state: 0,
diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts
index 8c4c6a9..3b910f3 100644
--- a/src/server/repositories/types.ts
+++ b/src/server/repositories/types.ts
@@ -81,6 +81,8 @@ export interface DeckRepository {
export interface Card {
id: string;
deckId: string;
+ noteId: string | null;
+ isReversed: boolean | null;
front: string;
back: string;
@@ -101,9 +103,19 @@ export interface Card {
syncVersion: number;
}
+export interface CardWithNoteData extends Card {
+ note: Note | null;
+ fieldValues: NoteFieldValue[];
+}
+
export interface CardRepository {
findByDeckId(deckId: string): Promise<Card[]>;
findById(id: string, deckId: string): Promise<Card | undefined>;
+ findByIdWithNoteData(
+ id: string,
+ deckId: string,
+ ): Promise<CardWithNoteData | undefined>;
+ findByNoteId(noteId: string): Promise<Card[]>;
create(
deckId: string,
data: {
@@ -120,6 +132,7 @@ export interface CardRepository {
},
): Promise<Card | undefined>;
softDelete(id: string, deckId: string): Promise<boolean>;
+ softDeleteByNoteId(noteId: string): Promise<boolean>;
findDueCards(deckId: string, now: Date, limit: number): Promise<Card[]>;
updateFSRSFields(
id: string,
diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts
index d319b33..129efa6 100644
--- a/src/server/routes/cards.test.ts
+++ b/src/server/routes/cards.test.ts
@@ -15,9 +15,12 @@ function createMockCardRepo(): CardRepository {
return {
findByDeckId: vi.fn(),
findById: vi.fn(),
+ findByIdWithNoteData: vi.fn(),
+ findByNoteId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
+ softDeleteByNoteId: vi.fn(),
findDueCards: vi.fn(),
updateFSRSFields: vi.fn(),
};
@@ -66,6 +69,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card {
return {
id: "card-uuid-123",
deckId: "deck-uuid-123",
+ noteId: null,
+ isReversed: null,
front: "Question",
back: "Answer",
state: CardState.New,
diff --git a/src/server/routes/study.test.ts b/src/server/routes/study.test.ts
index 6c45d3a..d709750 100644
--- a/src/server/routes/study.test.ts
+++ b/src/server/routes/study.test.ts
@@ -17,9 +17,12 @@ function createMockCardRepo(): CardRepository {
return {
findByDeckId: vi.fn(),
findById: vi.fn(),
+ findByIdWithNoteData: vi.fn(),
+ findByNoteId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
+ softDeleteByNoteId: vi.fn(),
findDueCards: vi.fn(),
updateFSRSFields: vi.fn(),
};
@@ -74,6 +77,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card {
return {
id: "card-uuid-123",
deckId: "deck-uuid-123",
+ noteId: null,
+ isReversed: null,
front: "Question",
back: "Answer",
state: CardState.New,
diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts
index e1b389f..7492b49 100644
--- a/src/server/routes/sync.test.ts
+++ b/src/server/routes/sync.test.ts
@@ -584,6 +584,8 @@ describe("GET /api/sync/pull", () => {
const mockCard: Card = {
id: "550e8400-e29b-41d4-a716-446655440001",
deckId: "550e8400-e29b-41d4-a716-446655440000",
+ noteId: null,
+ isReversed: null,
front: "Question",
back: "Answer",
state: 2,
@@ -679,6 +681,8 @@ describe("GET /api/sync/pull", () => {
const mockCard: Card = {
id: "550e8400-e29b-41d4-a716-446655440001",
deckId: "550e8400-e29b-41d4-a716-446655440000",
+ noteId: null,
+ isReversed: null,
front: "Q",
back: "A",
state: 0,