From ef65c4e6d31b5df36bfff3254d614f61bf659bed Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 31 Dec 2025 01:18:37 +0900 Subject: feat(api): include note data in Card and Study route responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/server/repositories/card.test.ts | 1 + src/server/repositories/card.ts | 41 ++++++++++++++ src/server/repositories/types.ts | 5 ++ src/server/routes/cards.test.ts | 101 +++++++++++++++++++++++++++++++-- src/server/routes/cards.ts | 2 +- src/server/routes/study.test.ts | 107 ++++++++++++++++++++++++++++++++--- src/server/routes/study.ts | 6 +- 7 files changed, 248 insertions(+), 15 deletions(-) (limited to 'src/server') 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 { + 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; softDeleteByNoteId(noteId: string): Promise; findDueCards(deckId: string, now: Date, limit: number): Promise; + findDueCardsWithNoteData( + deckId: string, + now: Date, + limit: number, + ): Promise; 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 { } interface CardResponse { - card?: Card; + card?: Card | CardWithNoteData; cards?: Card[]; success?: boolean; error?: { @@ -100,6 +104,44 @@ interface CardResponse { }; } +function createMockCardWithNoteData( + overrides: Partial = {}, +): CardWithNoteData { + return { + ...createMockCard(overrides), + note: overrides.note ?? null, + fieldValues: overrides.fieldValues ?? [], + }; +} + +function createMockNote(overrides: Partial = {}): 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 { + 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 { }; } +function createMockCardWithNoteData( + overrides: Partial = {}, +): CardWithNoteData { + return { + ...createMockCard(overrides), + note: overrides.note ?? null, + fieldValues: overrides.fieldValues ?? [], + }; +} + +function createMockNote(overrides: Partial = {}): 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 { + 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); }) -- cgit v1.2.3-70-g09d2