diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 01:18:37 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 01:20:00 +0900 |
| commit | ef65c4e6d31b5df36bfff3254d614f61bf659bed (patch) | |
| tree | 4cfab0983a1aa3f58c1b90c1a8bc17fa37c4049e | |
| parent | 139dd1a9ec77921ad757ec6bb9b2b97f9b1162c4 (diff) | |
| download | kioku-ef65c4e6d31b5df36bfff3254d614f61bf659bed.tar.gz kioku-ef65c4e6d31b5df36bfff3254d614f61bf659bed.tar.zst kioku-ef65c4e6d31b5df36bfff3254d614f61bf659bed.zip | |
feat(api): include note data in Card and Study route responses
Update Card GET endpoint to return note and field values when available,
and Study GET endpoint to include note data for card display rendering.
This enables the frontend to render note-based cards with their templates.
🤖 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 | 7 | ||||
| -rw-r--r-- | src/server/repositories/card.test.ts | 1 | ||||
| -rw-r--r-- | src/server/repositories/card.ts | 41 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 5 | ||||
| -rw-r--r-- | src/server/routes/cards.test.ts | 101 | ||||
| -rw-r--r-- | src/server/routes/cards.ts | 2 | ||||
| -rw-r--r-- | src/server/routes/study.test.ts | 107 | ||||
| -rw-r--r-- | src/server/routes/study.ts | 6 |
8 files changed, 252 insertions, 18 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 957f4c4..c3a4a19 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -180,15 +180,15 @@ Create these as default note types for each user: - `PUT /api/note-types/:id/fields/:fieldId` - Update field - `DELETE /api/note-types/:id/fields/:fieldId` - Remove field - `PUT /api/note-types/:id/fields/reorder` - Reorder fields -- [ ] Add Note routes +- [x] Add Note routes - `GET /api/decks/:deckId/notes` - List notes in deck - `POST /api/decks/:deckId/notes` - Create note (auto-generates cards) - `GET /api/decks/:deckId/notes/:noteId` - Get note with field values - `PUT /api/decks/:deckId/notes/:noteId` - Update note field values - `DELETE /api/decks/:deckId/notes/:noteId` - Delete note and its cards -- [ ] Modify Card routes +- [x] Modify Card routes - Update GET to include note data when available -- [ ] Modify Study routes +- [x] Modify Study routes - Fetch note/field data for card display **Files to create/modify:** @@ -299,6 +299,7 @@ Create these as default note types for each user: - [ ] Remove deprecated `front`/`back` columns from Card (after migration) - [ ] Update all tests - [ ] Update API documentation +- [ ] Update architecture.md - [ ] Performance testing with multiple cards per note ## Migration Strategy diff --git a/src/server/repositories/card.test.ts b/src/server/repositories/card.test.ts index 22d0f41..64c071e 100644 --- a/src/server/repositories/card.test.ts +++ b/src/server/repositories/card.test.ts @@ -96,6 +96,7 @@ function createMockCardRepo(): CardRepository { softDelete: vi.fn(), softDeleteByNoteId: vi.fn(), findDueCards: vi.fn(), + findDueCardsWithNoteData: vi.fn(), updateFSRSFields: vi.fn(), }; } diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts index 830b2f7..92811d4 100644 --- a/src/server/repositories/card.ts +++ b/src/server/repositories/card.ts @@ -178,6 +178,47 @@ export const cardRepository: CardRepository = { return result; }, + async findDueCardsWithNoteData( + deckId: string, + now: Date, + limit: number, + ): Promise<CardWithNoteData[]> { + const dueCards = await this.findDueCards(deckId, now, limit); + + const cardsWithNoteData: CardWithNoteData[] = []; + + for (const card of dueCards) { + if (!card.noteId) { + cardsWithNoteData.push({ + ...card, + note: null, + fieldValues: [], + }); + continue; + } + + 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)); + + cardsWithNoteData.push({ + ...card, + note, + fieldValues: fieldValuesResult, + }); + } + + return cardsWithNoteData; + }, + async updateFSRSFields( id: string, deckId: string, diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index 3b910f3..8b86061 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -134,6 +134,11 @@ export interface CardRepository { softDelete(id: string, deckId: string): Promise<boolean>; softDeleteByNoteId(noteId: string): Promise<boolean>; findDueCards(deckId: string, now: Date, limit: number): Promise<Card[]>; + findDueCardsWithNoteData( + deckId: string, + now: Date, + limit: number, + ): Promise<CardWithNoteData[]>; updateFSRSFields( id: string, deckId: string, diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts index 129efa6..53991f3 100644 --- a/src/server/routes/cards.test.ts +++ b/src/server/routes/cards.test.ts @@ -6,8 +6,11 @@ import { errorHandler } from "../middleware/index.js"; import type { Card, CardRepository, + CardWithNoteData, Deck, DeckRepository, + Note, + NoteFieldValue, } from "../repositories/index.js"; import { createCardsRouter } from "./cards.js"; @@ -22,6 +25,7 @@ function createMockCardRepo(): CardRepository { softDelete: vi.fn(), softDeleteByNoteId: vi.fn(), findDueCards: vi.fn(), + findDueCardsWithNoteData: vi.fn(), updateFSRSFields: vi.fn(), }; } @@ -91,7 +95,7 @@ function createMockCard(overrides: Partial<Card> = {}): Card { } interface CardResponse { - card?: Card; + card?: Card | CardWithNoteData; cards?: Card[]; success?: boolean; error?: { @@ -100,6 +104,44 @@ interface CardResponse { }; } +function createMockCardWithNoteData( + overrides: Partial<CardWithNoteData> = {}, +): CardWithNoteData { + return { + ...createMockCard(overrides), + note: overrides.note ?? null, + fieldValues: overrides.fieldValues ?? [], + }; +} + +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, + }; +} + const DECK_ID = "00000000-0000-4000-8000-000000000001"; const CARD_ID = "00000000-0000-4000-8000-000000000002"; @@ -361,12 +403,19 @@ describe("GET /api/decks/:deckId/cards/:cardId", () => { authToken = await createTestToken("user-uuid-123"); }); - it("returns card by id", async () => { - const mockCard = createMockCard({ id: CARD_ID, deckId: DECK_ID }); + it("returns card by id with note data", async () => { + const mockCardWithNote = createMockCardWithNoteData({ + id: CARD_ID, + deckId: DECK_ID, + note: null, + fieldValues: [], + }); vi.mocked(mockDeckRepo.findById).mockResolvedValue( createMockDeck({ id: DECK_ID }), ); - vi.mocked(mockCardRepo.findById).mockResolvedValue(mockCard); + vi.mocked(mockCardRepo.findByIdWithNoteData).mockResolvedValue( + mockCardWithNote, + ); const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { method: "GET", @@ -376,7 +425,47 @@ describe("GET /api/decks/:deckId/cards/:cardId", () => { expect(res.status).toBe(200); const body = (await res.json()) as CardResponse; expect(body.card?.id).toBe(CARD_ID); - expect(mockCardRepo.findById).toHaveBeenCalledWith(CARD_ID, DECK_ID); + expect(mockCardRepo.findByIdWithNoteData).toHaveBeenCalledWith( + CARD_ID, + DECK_ID, + ); + }); + + it("returns card with note and field values when available", async () => { + const mockNote = createMockNote({ id: "note-1" }); + const mockFieldValues = [ + createMockNoteFieldValue({ noteId: "note-1", value: "Front content" }), + createMockNoteFieldValue({ + id: "fv-2", + noteId: "note-1", + value: "Back content", + }), + ]; + const mockCardWithNote = createMockCardWithNoteData({ + id: CARD_ID, + deckId: DECK_ID, + noteId: "note-1", + note: mockNote, + fieldValues: mockFieldValues, + }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.findByIdWithNoteData).mockResolvedValue( + mockCardWithNote, + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + const card = body.card as CardWithNoteData; + expect(card?.id).toBe(CARD_ID); + expect(card?.note?.id).toBe("note-1"); + expect(card?.fieldValues).toHaveLength(2); }); it("returns 404 for non-existent deck", async () => { @@ -396,7 +485,7 @@ describe("GET /api/decks/:deckId/cards/:cardId", () => { vi.mocked(mockDeckRepo.findById).mockResolvedValue( createMockDeck({ id: DECK_ID }), ); - vi.mocked(mockCardRepo.findById).mockResolvedValue(undefined); + vi.mocked(mockCardRepo.findByIdWithNoteData).mockResolvedValue(undefined); const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { method: "GET", diff --git a/src/server/routes/cards.ts b/src/server/routes/cards.ts index 91fa647..88249fc 100644 --- a/src/server/routes/cards.ts +++ b/src/server/routes/cards.ts @@ -75,7 +75,7 @@ export function createCardsRouter(deps: CardDependencies) { throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); } - const card = await cardRepo.findById(cardId, deckId); + const card = await cardRepo.findByIdWithNoteData(cardId, deckId); if (!card) { throw Errors.notFound("Card not found", "CARD_NOT_FOUND"); } diff --git a/src/server/routes/study.test.ts b/src/server/routes/study.test.ts index d709750..77cb15c 100644 --- a/src/server/routes/study.test.ts +++ b/src/server/routes/study.test.ts @@ -6,8 +6,11 @@ import { errorHandler } from "../middleware/index.js"; import type { Card, CardRepository, + CardWithNoteData, Deck, DeckRepository, + Note, + NoteFieldValue, ReviewLog, ReviewLogRepository, } from "../repositories/index.js"; @@ -24,6 +27,7 @@ function createMockCardRepo(): CardRepository { softDelete: vi.fn(), softDeleteByNoteId: vi.fn(), findDueCards: vi.fn(), + findDueCardsWithNoteData: vi.fn(), updateFSRSFields: vi.fn(), }; } @@ -114,9 +118,47 @@ function createMockReviewLog(overrides: Partial<ReviewLog> = {}): ReviewLog { }; } +function createMockCardWithNoteData( + overrides: Partial<CardWithNoteData> = {}, +): CardWithNoteData { + return { + ...createMockCard(overrides), + note: overrides.note ?? null, + fieldValues: overrides.fieldValues ?? [], + }; +} + +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, + }; +} + interface StudyResponse { card?: Card; - cards?: Card[]; + cards?: CardWithNoteData[]; error?: { code: string; message: string; @@ -153,7 +195,7 @@ describe("GET /api/decks/:deckId/study", () => { vi.mocked(mockDeckRepo.findById).mockResolvedValue( createMockDeck({ id: DECK_ID }), ); - vi.mocked(mockCardRepo.findDueCards).mockResolvedValue([]); + vi.mocked(mockCardRepo.findDueCardsWithNoteData).mockResolvedValue([]); const res = await app.request(`/api/decks/${DECK_ID}/study`, { method: "GET", @@ -167,22 +209,36 @@ describe("GET /api/decks/:deckId/study", () => { DECK_ID, "user-uuid-123", ); - expect(mockCardRepo.findDueCards).toHaveBeenCalledWith( + expect(mockCardRepo.findDueCardsWithNoteData).toHaveBeenCalledWith( DECK_ID, expect.any(Date), 100, ); }); - it("returns due cards", async () => { + it("returns due cards with note data", async () => { const mockCards = [ - createMockCard({ id: "card-1", front: "Q1", back: "A1" }), - createMockCard({ id: "card-2", front: "Q2", back: "A2" }), + createMockCardWithNoteData({ + id: "card-1", + front: "Q1", + back: "A1", + note: null, + fieldValues: [], + }), + createMockCardWithNoteData({ + id: "card-2", + front: "Q2", + back: "A2", + note: null, + fieldValues: [], + }), ]; vi.mocked(mockDeckRepo.findById).mockResolvedValue( createMockDeck({ id: DECK_ID }), ); - vi.mocked(mockCardRepo.findDueCards).mockResolvedValue(mockCards); + vi.mocked(mockCardRepo.findDueCardsWithNoteData).mockResolvedValue( + mockCards, + ); const res = await app.request(`/api/decks/${DECK_ID}/study`, { method: "GET", @@ -194,6 +250,43 @@ describe("GET /api/decks/:deckId/study", () => { expect(body.cards).toHaveLength(2); }); + it("returns due cards with note and field values when available", async () => { + const mockNote = createMockNote({ id: "note-1" }); + const mockFieldValues = [ + createMockNoteFieldValue({ noteId: "note-1", value: "Front" }), + createMockNoteFieldValue({ + id: "fv-2", + noteId: "note-1", + value: "Back", + }), + ]; + const mockCards = [ + createMockCardWithNoteData({ + id: "card-1", + noteId: "note-1", + note: mockNote, + fieldValues: mockFieldValues, + }), + ]; + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.findDueCardsWithNoteData).mockResolvedValue( + mockCards, + ); + + const res = await app.request(`/api/decks/${DECK_ID}/study`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as StudyResponse; + expect(body.cards).toHaveLength(1); + expect(body.cards?.[0]?.note?.id).toBe("note-1"); + expect(body.cards?.[0]?.fieldValues).toHaveLength(2); + }); + it("returns 404 for non-existent deck", async () => { vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined); diff --git a/src/server/routes/study.ts b/src/server/routes/study.ts index 26e949a..ccb0692 100644 --- a/src/server/routes/study.ts +++ b/src/server/routes/study.ts @@ -51,7 +51,11 @@ export function createStudyRouter(deps: StudyDependencies) { } const now = new Date(); - const dueCards = await cardRepo.findDueCards(deckId, now, 100); + const dueCards = await cardRepo.findDueCardsWithNoteData( + deckId, + now, + 100, + ); return c.json({ cards: dueCards }, 200); }) |
