aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--docs/dev/roadmap.md7
-rw-r--r--src/server/repositories/card.test.ts1
-rw-r--r--src/server/repositories/card.ts41
-rw-r--r--src/server/repositories/types.ts5
-rw-r--r--src/server/routes/cards.test.ts101
-rw-r--r--src/server/routes/cards.ts2
-rw-r--r--src/server/routes/study.test.ts107
-rw-r--r--src/server/routes/study.ts6
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);
})