diff options
| -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); }) |
