diff options
28 files changed, 1082 insertions, 353 deletions
diff --git a/drizzle/0003_brave_blacklash.sql b/drizzle/0003_brave_blacklash.sql new file mode 100644 index 0000000..d129f36 --- /dev/null +++ b/drizzle/0003_brave_blacklash.sql @@ -0,0 +1,2 @@ +ALTER TABLE "cards" ALTER COLUMN "note_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "cards" ALTER COLUMN "is_reversed" SET NOT NULL;
\ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..1a46c49 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,837 @@ +{ + "id": "64c4ab9c-4036-44de-99cb-c4b14f494a4d", + "prevId": "4b30f43f-1827-40c6-9814-d749334da729", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note_id": { + "name": "note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_reversed": { + "name": "is_reversed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "front": { + "name": "front", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back": { + "name": "back", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "due": { + "name": "due", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stability": { + "name": "stability", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reps": { + "name": "reps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lapses": { + "name": "lapses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_review": { + "name": "last_review", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "cards_deck_id_decks_id_fk": { + "name": "cards_deck_id_decks_id_fk", + "tableFrom": "cards", + "tableTo": "decks", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cards_note_id_notes_id_fk": { + "name": "cards_note_id_notes_id_fk", + "tableFrom": "cards", + "tableTo": "notes", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decks": { + "name": "decks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_cards_per_day": { + "name": "new_cards_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "decks_user_id_users_id_fk": { + "name": "decks_user_id_users_id_fk", + "tableFrom": "decks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_field_types": { + "name": "note_field_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "note_type_id": { + "name": "note_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "note_field_types_note_type_id_note_types_id_fk": { + "name": "note_field_types_note_type_id_note_types_id_fk", + "tableFrom": "note_field_types", + "tableTo": "note_types", + "columnsFrom": [ + "note_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_field_values": { + "name": "note_field_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "note_id": { + "name": "note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note_field_type_id": { + "name": "note_field_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "note_field_values_note_id_notes_id_fk": { + "name": "note_field_values_note_id_notes_id_fk", + "tableFrom": "note_field_values", + "tableTo": "notes", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "note_field_values_note_field_type_id_note_field_types_id_fk": { + "name": "note_field_values_note_field_type_id_note_field_types_id_fk", + "tableFrom": "note_field_values", + "tableTo": "note_field_types", + "columnsFrom": [ + "note_field_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_types": { + "name": "note_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "front_template": { + "name": "front_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back_template": { + "name": "back_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_reversible": { + "name": "is_reversible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "note_types_user_id_users_id_fk": { + "name": "note_types_user_id_users_id_fk", + "tableFrom": "note_types", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note_type_id": { + "name": "note_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "notes_deck_id_decks_id_fk": { + "name": "notes_deck_id_decks_id_fk", + "tableFrom": "notes", + "tableTo": "decks", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notes_note_type_id_note_types_id_fk": { + "name": "notes_note_type_id_note_types_id_fk", + "tableFrom": "notes", + "tableTo": "note_types", + "columnsFrom": [ + "note_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_logs": { + "name": "review_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "review_logs_card_id_cards_id_fk": { + "name": "review_logs_card_id_cards_id_fk", + "tableFrom": "review_logs", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "review_logs_user_id_users_id_fk": { + "name": "review_logs_user_id_users_id_fk", + "tableFrom": "review_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +}
\ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5da2908..cdfca60 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1767108756805, "tag": "0002_married_moondragon", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1767156818092, + "tag": "0003_brave_blacklash", + "breakpoints": true } ] }
\ No newline at end of file diff --git a/src/client/components/DeleteNoteModal.test.tsx b/src/client/components/DeleteNoteModal.test.tsx index 85aaa14..a17323a 100644 --- a/src/client/components/DeleteNoteModal.test.tsx +++ b/src/client/components/DeleteNoteModal.test.tsx @@ -175,7 +175,7 @@ describe("DeleteNoteModal", () => { const user = userEvent.setup(); // Create a promise that we can control - let resolveDelete: (value: unknown) => void; + let resolveDelete: ((value: unknown) => void) | undefined; const deletePromise = new Promise((resolve) => { resolveDelete = resolve; }); @@ -190,19 +190,17 @@ describe("DeleteNoteModal", () => { expect(screen.getByText("Deleting...")).toBeDefined(); // Resolve the delete request to cleanup - if (resolveDelete) { - resolveDelete({ - ok: true, - json: async () => ({ success: true }), - }); - } + resolveDelete?.({ + ok: true, + json: async () => ({ success: true }), + }); }); it("disables buttons while deleting", async () => { const user = userEvent.setup(); // Create a promise that we can control - let resolveDelete: (value: unknown) => void; + let resolveDelete: ((value: unknown) => void) | undefined; const deletePromise = new Promise((resolve) => { resolveDelete = resolve; }); @@ -224,11 +222,9 @@ describe("DeleteNoteModal", () => { ); // Resolve the delete request to cleanup - if (resolveDelete) { - resolveDelete({ - ok: true, - json: async () => ({ success: true }), - }); - } + resolveDelete?.({ + ok: true, + json: async () => ({ success: true }), + }); }); }); diff --git a/src/client/db/index.test.ts b/src/client/db/index.test.ts index a30f94b..0a4882d 100644 --- a/src/client/db/index.test.ts +++ b/src/client/db/index.test.ts @@ -132,8 +132,8 @@ describe("KiokuDatabase", () => { const testCard: LocalCard = { id: "card-1", deckId: "deck-1", - noteId: null, - isReversed: null, + noteId: "test-note-id", + isReversed: false, front: "Question", back: "Answer", state: CardState.New, diff --git a/src/client/db/index.ts b/src/client/db/index.ts index 5318b17..53df476 100644 --- a/src/client/db/index.ts +++ b/src/client/db/index.ts @@ -122,8 +122,8 @@ export interface LocalNoteFieldValue { export interface LocalCard { id: string; deckId: string; - noteId: string | null; - isReversed: boolean | null; + noteId: string; + isReversed: boolean; front: string; back: string; @@ -241,6 +241,18 @@ export class KiokuDatabase extends Dexie { } }); }); + + // Version 3: noteId and isReversed are now required (NOT NULL) + // No migration needed as production has no legacy data without notes + this.version(3).stores({ + decks: "id, userId, updatedAt", + cards: "id, deckId, noteId, updatedAt, due, state", + reviewLogs: "id, cardId, userId, reviewedAt", + noteTypes: "id, userId, updatedAt", + noteFieldTypes: "id, noteTypeId, updatedAt", + notes: "id, deckId, noteTypeId, updatedAt", + noteFieldValues: "id, noteId, noteFieldTypeId, updatedAt", + }); } } diff --git a/src/client/db/repositories.test.ts b/src/client/db/repositories.test.ts index 2fca210..da0f0d3 100644 --- a/src/client/db/repositories.test.ts +++ b/src/client/db/repositories.test.ts @@ -240,6 +240,8 @@ describe("localCardRepository", () => { it("should create a card with FSRS defaults", async () => { const card = await localCardRepository.create({ deckId, + noteId: "test-note-id", + isReversed: false, front: "Question", back: "Answer", }); @@ -260,8 +262,8 @@ describe("localCardRepository", () => { describe("findByDeckId", () => { it("should return all cards for a deck", async () => { - await localCardRepository.create({ deckId, front: "Q1", back: "A1" }); - await localCardRepository.create({ deckId, front: "Q2", back: "A2" }); + await localCardRepository.create({ deckId, noteId: "test-note-id", isReversed: false, front: "Q1", back: "A1" }); + await localCardRepository.create({ deckId, noteId: "test-note-id-2", isReversed: false, front: "Q2", back: "A2" }); const cards = await localCardRepository.findByDeckId(deckId); expect(cards).toHaveLength(2); @@ -270,6 +272,8 @@ describe("localCardRepository", () => { it("should exclude soft-deleted cards", async () => { const card = await localCardRepository.create({ deckId, + noteId: "test-note-id", + isReversed: false, front: "Q", back: "A", }); @@ -287,6 +291,8 @@ describe("localCardRepository", () => { const card1 = await localCardRepository.create({ deckId, + noteId: "test-note-id", + isReversed: false, front: "Due", back: "A", }); @@ -294,6 +300,8 @@ describe("localCardRepository", () => { const card2 = await localCardRepository.create({ deckId, + noteId: "test-note-id-2", + isReversed: false, front: "Not Due", back: "B", }); @@ -308,6 +316,8 @@ describe("localCardRepository", () => { for (let i = 0; i < 5; i++) { await localCardRepository.create({ deckId, + noteId: `test-note-id-${i}`, + isReversed: false, front: `Q${i}`, back: `A${i}`, }); @@ -322,12 +332,16 @@ describe("localCardRepository", () => { it("should return only new cards", async () => { await localCardRepository.create({ deckId, + noteId: "test-note-id", + isReversed: false, front: "New", back: "A", }); const reviewedCard = await localCardRepository.create({ deckId, + noteId: "test-note-id-2", + isReversed: false, front: "Reviewed", back: "B", }); @@ -343,6 +357,8 @@ describe("localCardRepository", () => { it("should update card content", async () => { const card = await localCardRepository.create({ deckId, + noteId: "test-note-id", + isReversed: false, front: "Original", back: "Original", }); @@ -362,6 +378,8 @@ describe("localCardRepository", () => { it("should update FSRS scheduling data", async () => { const card = await localCardRepository.create({ deckId, + noteId: "test-note-id", + isReversed: false, front: "Q", back: "A", }); @@ -391,6 +409,8 @@ describe("localCardRepository", () => { it("should soft delete a card", async () => { const card = await localCardRepository.create({ deckId, + noteId: "test-note-id", + isReversed: false, front: "Q", back: "A", }); @@ -423,6 +443,8 @@ describe("localReviewLogRepository", () => { const card = await localCardRepository.create({ deckId, + noteId: "test-note-id", + isReversed: false, front: "Q", back: "A", }); diff --git a/src/client/db/repositories.ts b/src/client/db/repositories.ts index 104f026..e01254e 100644 --- a/src/client/db/repositories.ts +++ b/src/client/db/repositories.ts @@ -176,8 +176,6 @@ export const localCardRepository = { data: Omit< LocalCard, | "id" - | "noteId" - | "isReversed" | "state" | "due" | "stability" @@ -192,14 +190,11 @@ export const localCardRepository = { | "deletedAt" | "syncVersion" | "_synced" - > & - Partial<Pick<LocalCard, "noteId" | "isReversed">>, + >, ): Promise<LocalCard> { const now = new Date(); const card: LocalCard = { id: uuidv4(), - noteId: null, - isReversed: null, ...data, state: CardState.New, due: now, diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx index 35303d9..e02302f 100644 --- a/src/client/pages/DeckDetailPage.test.tsx +++ b/src/client/pages/DeckDetailPage.test.tsx @@ -51,13 +51,13 @@ const mockDeck = { updatedAt: "2024-01-01T00:00:00Z", }; -// Legacy cards (no noteId) for backward compatibility testing -const mockLegacyCards = [ +// Basic note-based cards (each with its own note) +const mockBasicCards = [ { id: "card-1", deckId: "deck-1", - noteId: null, - isReversed: null, + noteId: "note-1", + isReversed: false, front: "Hello", back: "こんにちは", state: 0, @@ -77,8 +77,8 @@ const mockLegacyCards = [ { id: "card-2", deckId: "deck-1", - noteId: null, - isReversed: null, + noteId: "note-2", + isReversed: false, front: "Goodbye", back: "さようなら", state: 2, @@ -143,11 +143,8 @@ const mockNoteBasedCards = [ }, ]; -// Mixed cards (both legacy and note-based) -const mockMixedCards = [...mockLegacyCards, ...mockNoteBasedCards]; - -// Alias for backward compatibility in existing tests -const mockCards = mockLegacyCards; +// Alias for existing tests +const mockCards = mockBasicCards; function renderWithProviders(path = "/decks/deck-1") { const { hook } = memoryLocation({ path, static: true }); @@ -430,8 +427,8 @@ describe("DeckDetailPage", () => { expect(screen.queryByText("Common Japanese words")).toBeNull(); }); - describe("Delete Card", () => { - it("shows Delete button for each card", async () => { + describe("Delete Note", () => { + it("shows Delete button for each note", async () => { mockFetch .mockResolvedValueOnce({ ok: true, @@ -449,7 +446,7 @@ describe("DeckDetailPage", () => { }); const deleteButtons = screen.getAllByRole("button", { - name: "Delete card", + name: "Delete note", }); expect(deleteButtons.length).toBe(2); }); @@ -474,7 +471,7 @@ describe("DeckDetailPage", () => { }); const deleteButtons = screen.getAllByRole("button", { - name: "Delete card", + name: "Delete note", }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { @@ -483,7 +480,7 @@ describe("DeckDetailPage", () => { expect(screen.getByRole("dialog")).toBeDefined(); expect( - screen.getByRole("heading", { name: "Delete Card" }), + screen.getByRole("heading", { name: "Delete Note" }), ).toBeDefined(); }); @@ -507,7 +504,7 @@ describe("DeckDetailPage", () => { }); const deleteButtons = screen.getAllByRole("button", { - name: "Delete card", + name: "Delete note", }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { @@ -521,7 +518,7 @@ describe("DeckDetailPage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - it("deletes card and refreshes list on confirmation", async () => { + it("deletes note and refreshes list on confirmation", async () => { const user = userEvent.setup(); mockFetch @@ -537,7 +534,7 @@ describe("DeckDetailPage", () => { // Delete request .mockResolvedValueOnce({ ok: true, - json: async () => ({}), + json: async () => ({ success: true }), }) // Refresh cards after deletion .mockResolvedValueOnce({ @@ -552,7 +549,7 @@ describe("DeckDetailPage", () => { }); const deleteButtons = screen.getAllByRole("button", { - name: "Delete card", + name: "Delete note", }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { @@ -575,8 +572,8 @@ describe("DeckDetailPage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - // Verify DELETE request was made - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/cards/card-1", { + // Verify DELETE request was made to notes endpoint + expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", { method: "DELETE", headers: { Authorization: "Bearer access-token" }, }); @@ -604,7 +601,7 @@ describe("DeckDetailPage", () => { .mockResolvedValueOnce({ ok: false, status: 500, - json: async () => ({ error: "Failed to delete card" }), + json: async () => ({ error: "Failed to delete note" }), }); renderWithProviders(); @@ -614,7 +611,7 @@ describe("DeckDetailPage", () => { }); const deleteButtons = screen.getAllByRole("button", { - name: "Delete card", + name: "Delete note", }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { @@ -635,7 +632,7 @@ describe("DeckDetailPage", () => { // Error should be displayed in the modal await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( - "Failed to delete card", + "Failed to delete note", ); }); }); @@ -665,32 +662,6 @@ describe("DeckDetailPage", () => { expect(noteCards.length).toBe(2); }); - it("displays legacy cards separately from note groups", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockMixedCards }), - }); - - renderWithProviders(); - - await waitFor(() => { - // Should show both note groups and legacy cards - expect(screen.getByTestId("note-group")).toBeDefined(); - }); - - const legacyCards = screen.getAllByTestId("legacy-card"); - expect(legacyCards.length).toBe(2); // 2 legacy cards - - // Should show "Legacy" badge for legacy cards - const legacyBadges = screen.getAllByText("Legacy"); - expect(legacyBadges.length).toBe(2); - }); - it("shows Normal and Reversed badges for note-based cards", async () => { mockFetch .mockResolvedValueOnce({ diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index 87f9dc3..d018d1f 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -21,8 +21,8 @@ import { EditNoteModal } from "../components/EditNoteModal"; interface Card { id: string; deckId: string; - noteId: string | null; - isReversed: boolean | null; + noteId: string; + isReversed: boolean; front: string; back: string; state: number; @@ -33,10 +33,8 @@ interface Card { updatedAt: string; } -/** Combined type for display: either a note group or a legacy card */ -type CardDisplayItem = - | { type: "note"; noteId: string; cards: Card[] } - | { type: "legacy"; card: Card }; +/** Combined type for display: note group */ +type CardDisplayItem = { type: "note"; noteId: string; cards: Card[] }; interface Deck { id: string; @@ -178,95 +176,6 @@ function NoteGroupCard({ ); } -/** Component for displaying a legacy card (without note association) */ -function LegacyCardItem({ - card, - index, - onEdit, - onDelete, -}: { - card: Card; - index: number; - onEdit: () => void; - onDelete: () => void; -}) { - return ( - <div - data-testid="legacy-card" - className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200" - style={{ animationDelay: `${index * 30}ms` }} - > - <div className="flex items-start justify-between gap-4"> - <div className="flex-1 min-w-0"> - {/* Front/Back Preview */} - <div className="grid grid-cols-2 gap-4 mb-3"> - <div> - <span className="text-xs font-medium text-muted uppercase tracking-wide"> - Front - </span> - <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words"> - {card.front} - </p> - </div> - <div> - <span className="text-xs font-medium text-muted uppercase tracking-wide"> - Back - </span> - <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words"> - {card.back} - </p> - </div> - </div> - - {/* Card Stats */} - <div className="flex items-center gap-3 text-xs"> - <span - className={`px-2 py-0.5 rounded-full font-medium ${CardStateColors[card.state] || "bg-muted/10 text-muted"}`} - > - {CardStateLabels[card.state] || "Unknown"} - </span> - <span className="px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"> - Legacy - </span> - <span className="text-muted">{card.reps} reviews</span> - {card.lapses > 0 && ( - <span className="text-muted">{card.lapses} lapses</span> - )} - </div> - </div> - - {/* Actions */} - <div className="flex items-center gap-1 shrink-0"> - <button - type="button" - onClick={onEdit} - className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors" - title="Edit card" - > - <FontAwesomeIcon - icon={faPen} - className="w-4 h-4" - aria-hidden="true" - /> - </button> - <button - type="button" - onClick={onDelete} - className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors" - title="Delete card" - > - <FontAwesomeIcon - icon={faTrash} - className="w-4 h-4" - aria-hidden="true" - /> - </button> - </div> - </div> - </div> - ); -} - export function DeckDetailPage() { const { deckId } = useParams<{ deckId: string }>(); const [deck, setDeck] = useState<Deck | null>(null); @@ -282,24 +191,17 @@ export function DeckDetailPage() { // Group cards by note for display const displayItems = useMemo((): CardDisplayItem[] => { const noteGroups = new Map<string, Card[]>(); - const legacyCards: Card[] = []; for (const card of cards) { - if (card.noteId) { - const existing = noteGroups.get(card.noteId); - if (existing) { - existing.push(card); - } else { - noteGroups.set(card.noteId, [card]); - } + const existing = noteGroups.get(card.noteId); + if (existing) { + existing.push(card); } else { - legacyCards.push(card); + noteGroups.set(card.noteId, [card]); } } - const items: CardDisplayItem[] = []; - - // Add note groups first, sorted by earliest card creation + // Sort note groups by earliest card creation (newest first) const sortedNoteGroups = Array.from(noteGroups.entries()).sort( ([, cardsA], [, cardsB]) => { const minA = Math.min( @@ -312,6 +214,7 @@ export function DeckDetailPage() { }, ); + const items: CardDisplayItem[] = []; for (const [noteId, noteCards] of sortedNoteGroups) { // Sort cards within group: normal first, then reversed noteCards.sort((a, b) => { @@ -321,15 +224,6 @@ export function DeckDetailPage() { items.push({ type: "note", noteId, cards: noteCards }); } - // Add legacy cards, newest first - legacyCards.sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - for (const card of legacyCards) { - items.push({ type: "legacy", card }); - } - return items; }, [cards]); @@ -551,26 +445,16 @@ export function DeckDetailPage() { {/* Card List - Grouped by Note */} {cards.length > 0 && ( <div className="space-y-4"> - {displayItems.map((item, index) => - item.type === "note" ? ( - <NoteGroupCard - key={item.noteId} - noteId={item.noteId} - cards={item.cards} - index={index} - onEditNote={() => setEditingNoteId(item.noteId)} - onDeleteNote={() => setDeletingNoteId(item.noteId)} - /> - ) : ( - <LegacyCardItem - key={item.card.id} - card={item.card} - index={index} - onEdit={() => setEditingCard(item.card)} - onDelete={() => setDeletingCard(item.card)} - /> - ), - )} + {displayItems.map((item, index) => ( + <NoteGroupCard + key={item.noteId} + noteId={item.noteId} + cards={item.cards} + index={index} + onEditNote={() => setEditingNoteId(item.noteId)} + onDeleteNote={() => setDeletingNoteId(item.noteId)} + /> + ))} </div> )} </div> diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index bdaf7e3..0eb5118 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -13,8 +13,8 @@ import { renderCard } from "../utils/templateRenderer"; interface Card { id: string; deckId: string; - noteId: string | null; - isReversed: boolean | null; + noteId: string; + isReversed: boolean; front: string; back: string; state: number; @@ -23,11 +23,11 @@ interface Card { difficulty: number; reps: number; lapses: number; - /** Note type templates for rendering (null for legacy cards) */ + /** Note type templates for rendering */ noteType: { frontTemplate: string; backTemplate: string; - } | null; + }; /** Field values as a name-value map for template rendering */ fieldValuesMap: Record<string, string>; } diff --git a/src/client/sync/conflict.test.ts b/src/client/sync/conflict.test.ts index 6a10c4f..52362ff 100644 --- a/src/client/sync/conflict.test.ts +++ b/src/client/sync/conflict.test.ts @@ -271,6 +271,8 @@ describe("ConflictResolver", () => { const localCard = await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Local Question", back: "Local Answer", }); @@ -278,6 +280,8 @@ describe("ConflictResolver", () => { const serverCard = { id: localCard.id, deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Server Question", back: "Server Answer", state: CardState.Review, @@ -316,6 +320,8 @@ describe("ConflictResolver", () => { const localCard = await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Local Question", back: "Local Answer", }); @@ -323,6 +329,8 @@ describe("ConflictResolver", () => { const serverCard = { id: localCard.id, deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Server Question", back: "Server Answer", state: CardState.New, @@ -435,6 +443,8 @@ describe("ConflictResolver", () => { const card = await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Local Question", back: "Local Answer", }); @@ -456,6 +466,8 @@ describe("ConflictResolver", () => { { id: card.id, deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Server Question", back: "Server Answer", state: CardState.New, diff --git a/src/client/sync/conflict.ts b/src/client/sync/conflict.ts index d9c3f55..2451920 100644 --- a/src/client/sync/conflict.ts +++ b/src/client/sync/conflict.ts @@ -91,8 +91,8 @@ function serverCardToLocal(card: ServerCard): LocalCard { return { id: card.id, deckId: card.deckId, - noteId: card.noteId ?? null, - isReversed: card.isReversed ?? null, + noteId: card.noteId, + isReversed: card.isReversed, front: card.front, back: card.back, state: card.state as LocalCard["state"], diff --git a/src/client/sync/pull.test.ts b/src/client/sync/pull.test.ts index 9ba678e..dd562a0 100644 --- a/src/client/sync/pull.test.ts +++ b/src/client/sync/pull.test.ts @@ -101,6 +101,8 @@ describe("pullResultToLocalData", () => { { id: "card-1", deckId: "deck-1", + noteId: "test-note-id", + isReversed: false, front: "Question", back: "Answer", state: CardState.Review, @@ -130,8 +132,8 @@ describe("pullResultToLocalData", () => { expect(result.cards[0]).toEqual({ id: "card-1", deckId: "deck-1", - noteId: null, - isReversed: null, + noteId: "test-note-id", + isReversed: false, front: "Question", back: "Answer", state: CardState.Review, @@ -156,6 +158,8 @@ describe("pullResultToLocalData", () => { { id: "card-1", deckId: "deck-1", + noteId: "test-note-id", + isReversed: false, front: "New Card", back: "Answer", state: CardState.New, @@ -568,8 +572,8 @@ describe("PullService", () => { { id: "server-card-1", deckId: deck.id, - noteId: null, - isReversed: null, + noteId: "test-note-id", + isReversed: false, front: "Server Question", back: "Server Answer", state: CardState.New, @@ -715,8 +719,8 @@ describe("PullService", () => { { id: "card-1", deckId: "deck-1", - noteId: null, - isReversed: null, + noteId: "test-note-id", + isReversed: false, front: "Q", back: "A", state: CardState.New, diff --git a/src/client/sync/pull.ts b/src/client/sync/pull.ts index 55c859c..8b55a9b 100644 --- a/src/client/sync/pull.ts +++ b/src/client/sync/pull.ts @@ -33,8 +33,8 @@ export interface ServerDeck { export interface ServerCard { id: string; deckId: string; - noteId?: string | null; - isReversed?: boolean | null; + noteId: string; + isReversed: boolean; front: string; back: string; state: number; @@ -172,8 +172,8 @@ function serverCardToLocal(card: ServerCard): LocalCard { return { id: card.id, deckId: card.deckId, - noteId: card.noteId ?? null, - isReversed: card.isReversed ?? null, + noteId: card.noteId, + isReversed: card.isReversed, front: card.front, back: card.back, state: card.state as CardStateType, diff --git a/src/client/sync/push.test.ts b/src/client/sync/push.test.ts index 9a42eff..16198c1 100644 --- a/src/client/sync/push.test.ts +++ b/src/client/sync/push.test.ts @@ -131,8 +131,8 @@ describe("pendingChangesToPushData", () => { { id: "card-1", deckId: "deck-1", - noteId: null, - isReversed: null, + noteId: "test-note-id", + isReversed: false, front: "Question", back: "Answer", state: CardState.Review, @@ -163,8 +163,8 @@ describe("pendingChangesToPushData", () => { expect(result.cards[0]).toEqual({ id: "card-1", deckId: "deck-1", - noteId: null, - isReversed: null, + noteId: "test-note-id", + isReversed: false, front: "Question", back: "Answer", state: CardState.Review, @@ -187,8 +187,8 @@ describe("pendingChangesToPushData", () => { { id: "card-1", deckId: "deck-1", - noteId: null, - isReversed: null, + noteId: "test-note-id", + isReversed: false, front: "New Card", back: "Answer", state: CardState.New, @@ -566,6 +566,8 @@ describe("PushService", () => { const card = await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Question", back: "Answer", }); @@ -611,6 +613,8 @@ describe("PushService", () => { const card = await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Q", back: "A", }); @@ -740,6 +744,8 @@ describe("PushService", () => { const card = await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Q", back: "A", }); diff --git a/src/client/sync/push.ts b/src/client/sync/push.ts index f5c9275..b83136e 100644 --- a/src/client/sync/push.ts +++ b/src/client/sync/push.ts @@ -35,8 +35,8 @@ export interface SyncDeckData { export interface SyncCardData { id: string; deckId: string; - noteId: string | null; - isReversed: boolean | null; + noteId: string; + isReversed: boolean; front: string; back: string; state: number; diff --git a/src/client/sync/queue.test.ts b/src/client/sync/queue.test.ts index 2038e0d..e815282 100644 --- a/src/client/sync/queue.test.ts +++ b/src/client/sync/queue.test.ts @@ -85,6 +85,8 @@ describe("SyncQueue", () => { }); await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Question", back: "Answer", }); @@ -103,6 +105,8 @@ describe("SyncQueue", () => { }); const card = await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Question", back: "Answer", }); @@ -145,11 +149,15 @@ describe("SyncQueue", () => { }); await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id-1", + isReversed: false, front: "Q1", back: "A1", }); await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id-2", + isReversed: false, front: "Q2", back: "A2", }); @@ -323,6 +331,8 @@ describe("SyncQueue", () => { }); const card = await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Q", back: "A", }); @@ -351,6 +361,8 @@ describe("SyncQueue", () => { }); const card = await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Q", back: "A", }); @@ -440,8 +452,8 @@ describe("SyncQueue", () => { const serverCard = { id: "server-card-1", deckId: deck.id, - noteId: null, - isReversed: null, + noteId: "test-note-id", + isReversed: false, front: "Server Question", back: "Server Answer", state: CardState.New, @@ -484,6 +496,8 @@ describe("SyncQueue", () => { }); const card = await localCardRepository.create({ deckId: deck.id, + noteId: "test-note-id", + isReversed: false, front: "Q", back: "A", }); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index bd3d396..0471a92 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -153,8 +153,10 @@ export const cards = pgTable("cards", { deckId: uuid("deck_id") .notNull() .references(() => decks.id), - noteId: uuid("note_id").references(() => notes.id), - isReversed: boolean("is_reversed"), + noteId: uuid("note_id") + .notNull() + .references(() => notes.id), + isReversed: boolean("is_reversed").notNull(), front: text("front").notNull(), back: text("back").notNull(), diff --git a/src/server/repositories/card.test.ts b/src/server/repositories/card.test.ts index 9d7ffa6..98913e9 100644 --- a/src/server/repositories/card.test.ts +++ b/src/server/repositories/card.test.ts @@ -12,8 +12,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card { return { id: "card-uuid-123", deckId: "deck-uuid-123", - noteId: null, - isReversed: null, + noteId: "note-uuid-123", + isReversed: false, front: "Front text", back: "Back text", state: 0, @@ -89,15 +89,14 @@ function createMockCardWithNoteData( function createMockCardForStudy( overrides: Partial<CardForStudy> = {}, ): CardForStudy { - const card = createMockCard({ - noteId: overrides.noteType ? "note-uuid-123" : null, - isReversed: overrides.noteType ? false : null, - ...overrides, - }); + const card = createMockCard(overrides); return { ...card, - noteType: overrides.noteType ?? null, - fieldValuesMap: overrides.fieldValuesMap ?? {}, + noteType: overrides.noteType ?? { + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }, + fieldValuesMap: overrides.fieldValuesMap ?? { Front: "Q", Back: "A" }, }; } @@ -125,8 +124,8 @@ describe("CardRepository mock factory", () => { expect(card.id).toBe("card-uuid-123"); expect(card.deckId).toBe("deck-uuid-123"); - expect(card.noteId).toBeNull(); - expect(card.isReversed).toBeNull(); + expect(card.noteId).toBe("note-uuid-123"); + expect(card.isReversed).toBe(false); expect(card.front).toBe("Front text"); expect(card.back).toBe("Back text"); expect(card.state).toBe(0); @@ -205,17 +204,13 @@ describe("CardRepository mock factory", () => { 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); + it("card always has note association", () => { + // All cards now require note association + const cardWithNote = createMockCardWithNoteData(); + + expect(cardWithNote.noteId).toBe("note-uuid-123"); + expect(cardWithNote.note).not.toBeNull(); + expect(cardWithNote.fieldValues).toHaveLength(2); }); }); @@ -392,41 +387,39 @@ describe("Card interface contracts", () => { expect(cardForStudy.fieldValuesMap.Front).toBe("Question"); }); - it("CardForStudy can represent legacy card with null noteType", () => { - const legacyCard = createMockCardForStudy({ - front: "Legacy Question", - back: "Legacy Answer", + it("CardForStudy has required note data", () => { + const cardForStudy = createMockCardForStudy({ + front: "Question", + back: "Answer", }); - expect(legacyCard.noteId).toBeNull(); - expect(legacyCard.noteType).toBeNull(); - expect(legacyCard.fieldValuesMap).toEqual({}); - expect(legacyCard.front).toBe("Legacy Question"); - expect(legacyCard.back).toBe("Legacy Answer"); + expect(cardForStudy.noteId).toBe("note-uuid-123"); + expect(cardForStudy.noteType).not.toBeNull(); + expect(cardForStudy.noteType.frontTemplate).toBe("{{Front}}"); + expect(cardForStudy.fieldValuesMap).toEqual({ Front: "Q", Back: "A" }); }); }); describe("Card and Note relationship", () => { - it("legacy card has null noteId and isReversed", () => { + it("card has required noteId and isReversed", () => { const card = createMockCard(); - expect(card.noteId).toBeNull(); - expect(card.isReversed).toBeNull(); + expect(card.noteId).toBe("note-uuid-123"); + expect(card.isReversed).toBe(false); }); - it("note-based card has noteId and isReversed set", () => { + it("card with explicit note data", () => { const card = createMockCard({ - noteId: "note-uuid-123", + noteId: "different-note-id", isReversed: false, }); - expect(card.noteId).toBe("note-uuid-123"); + expect(card.noteId).toBe("different-note-id"); expect(card.isReversed).toBe(false); }); it("reversed card has isReversed true", () => { const card = createMockCard({ - noteId: "note-uuid-123", isReversed: true, }); diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts index 7116642..4c4fc81 100644 --- a/src/server/repositories/card.ts +++ b/src/server/repositories/card.ts @@ -47,20 +47,15 @@ export const cardRepository: CardRepository = { 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 note = noteResult[0]; + if (!note) { + return undefined; + } const fieldValuesResult = await db .select() @@ -85,6 +80,8 @@ export const cardRepository: CardRepository = { async create( deckId: string, data: { + noteId: string; + isReversed: boolean; front: string; back: string; }, @@ -93,6 +90,8 @@ export const cardRepository: CardRepository = { .insert(cards) .values({ deckId, + noteId: data.noteId, + isReversed: data.isReversed, front: data.front, back: data.back, state: CardState.New, @@ -200,21 +199,16 @@ export const cardRepository: CardRepository = { 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 note = noteResult[0]; + if (!note) { + // Note was deleted, skip this card + continue; + } const fieldValuesResult = await db .select() @@ -241,16 +235,6 @@ export const cardRepository: CardRepository = { const cardsForStudy: CardForStudy[] = []; for (const card of dueCards) { - // Legacy card (no note association) - if (!card.noteId) { - cardsForStudy.push({ - ...card, - noteType: null, - fieldValuesMap: {}, - }); - continue; - } - // Fetch note to get noteTypeId const noteResult = await db .select() @@ -259,12 +243,7 @@ export const cardRepository: CardRepository = { const note = noteResult[0]; if (!note) { - // Note was deleted, treat as legacy card - cardsForStudy.push({ - ...card, - noteType: null, - fieldValuesMap: {}, - }); + // Note was deleted, skip this card continue; } @@ -281,12 +260,7 @@ export const cardRepository: CardRepository = { const noteType = noteTypeResult[0]; if (!noteType) { - // Note type was deleted, treat as legacy card - cardsForStudy.push({ - ...card, - noteType: null, - fieldValuesMap: {}, - }); + // Note type was deleted, skip this card continue; } diff --git a/src/server/repositories/note.test.ts b/src/server/repositories/note.test.ts index 70dcb4a..cc1a9ae 100644 --- a/src/server/repositories/note.test.ts +++ b/src/server/repositories/note.test.ts @@ -40,8 +40,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card { return { id: "card-uuid-123", deckId: "deck-uuid-123", - noteId: null, - isReversed: null, + noteId: "note-uuid-123", + isReversed: false, front: "Front text", back: "Back text", state: 0, diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts index 8c4fd25..59a195a 100644 --- a/src/server/repositories/sync.ts +++ b/src/server/repositories/sync.ts @@ -45,8 +45,8 @@ export interface SyncDeckData { export interface SyncCardData { id: string; deckId: string; - noteId: string | null; - isReversed: boolean | null; + noteId: string; + isReversed: boolean; front: string; back: string; state: number; diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index c864be0..a986cad 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -81,8 +81,8 @@ export interface DeckRepository { export interface Card { id: string; deckId: string; - noteId: string | null; - isReversed: boolean | null; + noteId: string; + isReversed: boolean; front: string; back: string; @@ -104,22 +104,20 @@ export interface Card { } export interface CardWithNoteData extends Card { - note: Note | null; + note: Note; fieldValues: NoteFieldValue[]; } /** * Card data prepared for study, including all necessary template rendering info. - * For note-based cards, includes templates and field values as a name-value map. - * For legacy cards, note and templates are null. */ export interface CardForStudy extends Card { - /** Note type templates for rendering (null for legacy cards) */ + /** Note type templates for rendering */ noteType: { frontTemplate: string; backTemplate: string; - } | null; - /** Field values as a name-value map for template rendering (empty for legacy cards) */ + }; + /** Field values as a name-value map for template rendering */ fieldValuesMap: Record<string, string>; } diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts index 780ea44..66ba601 100644 --- a/src/server/routes/cards.test.ts +++ b/src/server/routes/cards.test.ts @@ -74,8 +74,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card { return { id: "card-uuid-123", deckId: "deck-uuid-123", - noteId: null, - isReversed: null, + noteId: "note-uuid-123", + isReversed: false, front: "Question", back: "Answer", state: CardState.New, @@ -110,7 +110,7 @@ function createMockCardWithNoteData( ): CardWithNoteData { return { ...createMockCard(overrides), - note: overrides.note ?? null, + note: overrides.note ?? createMockNote(), fieldValues: overrides.fieldValues ?? [], }; } @@ -408,7 +408,6 @@ describe("GET /api/decks/:deckId/cards/:cardId", () => { const mockCardWithNote = createMockCardWithNoteData({ id: CARD_ID, deckId: DECK_ID, - note: null, fieldValues: [], }); vi.mocked(mockDeckRepo.findById).mockResolvedValue( diff --git a/src/server/routes/study.test.ts b/src/server/routes/study.test.ts index 41abecd..e2fb457 100644 --- a/src/server/routes/study.test.ts +++ b/src/server/routes/study.test.ts @@ -80,8 +80,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card { return { id: "card-uuid-123", deckId: "deck-uuid-123", - noteId: null, - isReversed: null, + noteId: "note-uuid-123", + isReversed: false, front: "Question", back: "Answer", state: CardState.New, @@ -122,7 +122,10 @@ function createMockCardForStudy( ): CardForStudy { return { ...createMockCard(overrides), - noteType: overrides.noteType ?? null, + noteType: overrides.noteType ?? { + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }, fieldValuesMap: overrides.fieldValuesMap ?? {}, }; } @@ -187,20 +190,18 @@ describe("GET /api/decks/:deckId/study", () => { ); }); - it("returns due cards (legacy cards without note)", async () => { + it("returns due cards", async () => { const mockCards = [ createMockCardForStudy({ id: "card-1", front: "Q1", back: "A1", - noteType: null, fieldValuesMap: {}, }), createMockCardForStudy({ id: "card-2", front: "Q2", back: "A2", - noteType: null, fieldValuesMap: {}, }), ]; @@ -217,7 +218,7 @@ describe("GET /api/decks/:deckId/study", () => { expect(res.status).toBe(200); const body = (await res.json()) as StudyResponse; expect(body.cards).toHaveLength(2); - expect(body.cards?.[0]?.noteType).toBeNull(); + expect(body.cards?.[0]?.noteType).toBeDefined(); }); it("returns due cards with note type and field values when available", async () => { diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts index 1107acd..f340af7 100644 --- a/src/server/routes/sync.test.ts +++ b/src/server/routes/sync.test.ts @@ -186,8 +186,8 @@ describe("POST /api/sync/push", () => { const cardData = { id: "550e8400-e29b-41d4-a716-446655440001", deckId: "550e8400-e29b-41d4-a716-446655440000", - noteId: null, - isReversed: null, + noteId: "550e8400-e29b-41d4-a716-446655440020", + isReversed: false, front: "Question", back: "Answer", state: 0, @@ -435,8 +435,8 @@ describe("POST /api/sync/push", () => { const cardData = { id: "550e8400-e29b-41d4-a716-446655440005", deckId: "550e8400-e29b-41d4-a716-446655440004", - noteId: null, - isReversed: null, + noteId: "550e8400-e29b-41d4-a716-446655440020", + isReversed: false, front: "Q", back: "A", state: 0, @@ -823,8 +823,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, + noteId: "550e8400-e29b-41d4-a716-446655440020", + isReversed: false, front: "Question", back: "Answer", state: 2, @@ -928,8 +928,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, + noteId: "550e8400-e29b-41d4-a716-446655440020", + isReversed: false, front: "Q", back: "A", state: 0, diff --git a/src/server/routes/sync.ts b/src/server/routes/sync.ts index f05a7ba..fca099b 100644 --- a/src/server/routes/sync.ts +++ b/src/server/routes/sync.ts @@ -26,8 +26,8 @@ const syncDeckSchema = z.object({ const syncCardSchema = z.object({ id: z.uuid(), deckId: z.uuid(), - noteId: z.uuid().nullable(), - isReversed: z.boolean().nullable(), + noteId: z.uuid(), + isReversed: z.boolean(), front: z.string().min(1), back: z.string().min(1), state: z.number().int().min(0).max(3), |
