From a94aeb6c35a97410ddcb14bb589ce2ab1e3fc125 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 06:57:15 +0000 Subject: feat: allow deleting card type fields with values via two-step confirmation Previously, fields with existing values could not be deleted at all. Now, when a user tries to delete such a field, a two-step confirmation dialog is shown. The first step warns the user with the number of cards using that card type. The second step asks for final confirmation since the action cannot be undone. The backend now accepts a `force=true` query parameter to bypass the field-has-values check, and returns the card count in the 409 response for the frontend to display. https://claude.ai/code/session_017S5QP6SnC5GFNJwQFyT4as --- src/server/repositories/noteType.test.ts | 1 + src/server/repositories/noteType.ts | 18 ++++++++++++++++ src/server/repositories/types.ts | 1 + src/server/routes/noteTypes.test.ts | 37 ++++++++++++++++++++++++++++++-- src/server/routes/noteTypes.ts | 16 ++++++++++---- 5 files changed, 67 insertions(+), 6 deletions(-) (limited to 'src/server') diff --git a/src/server/repositories/noteType.test.ts b/src/server/repositories/noteType.test.ts index fdb9d5c..3fd9e7d 100644 --- a/src/server/repositories/noteType.test.ts +++ b/src/server/repositories/noteType.test.ts @@ -66,6 +66,7 @@ function createMockNoteTypeRepo(): NoteTypeRepository { update: vi.fn(), softDelete: vi.fn(), hasNotes: vi.fn(), + countCards: vi.fn(), }; } diff --git a/src/server/repositories/noteType.ts b/src/server/repositories/noteType.ts index 06c8834..89cf6fd 100644 --- a/src/server/repositories/noteType.ts +++ b/src/server/repositories/noteType.ts @@ -143,6 +143,24 @@ export const noteTypeRepository: NoteTypeRepository = { return result.length > 0; }, + + async countCards(noteTypeId: string): Promise { + const { cards } = await import("../db/schema.js"); + + const result = await db + .select({ count: sql`cast(count(*) as int)` }) + .from(cards) + .innerJoin(notes, eq(cards.noteId, notes.id)) + .where( + and( + eq(notes.noteTypeId, noteTypeId), + isNull(notes.deletedAt), + isNull(cards.deletedAt), + ), + ); + + return Number(result[0]?.count ?? 0); + }, }; export const noteFieldTypeRepository: NoteFieldTypeRepository = { diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index 7e0819a..27a12b4 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -250,6 +250,7 @@ export interface NoteTypeRepository { ): Promise; softDelete(id: string, userId: string): Promise; hasNotes(id: string, userId: string): Promise; + countCards(noteTypeId: string): Promise; } export interface NoteFieldTypeRepository { diff --git a/src/server/routes/noteTypes.test.ts b/src/server/routes/noteTypes.test.ts index ccc29af..edfce59 100644 --- a/src/server/routes/noteTypes.test.ts +++ b/src/server/routes/noteTypes.test.ts @@ -20,6 +20,7 @@ function createMockNoteTypeRepo(): NoteTypeRepository { update: vi.fn(), softDelete: vi.fn(), hasNotes: vi.fn(), + countCards: vi.fn(), }; } @@ -835,13 +836,14 @@ describe("DELETE /api/note-types/:id/fields/:fieldId", () => { ); }); - it("returns 409 when field has values", async () => { + it("returns 409 with card count when field has values", async () => { const noteTypeId = "a0000000-0000-4000-8000-000000000001"; const fieldId = "b0000000-0000-4000-8000-000000000002"; const mockNoteType = createMockNoteType({ id: noteTypeId }); vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType); vi.mocked(mockNoteFieldTypeRepo.hasNoteFieldValues).mockResolvedValue(true); + vi.mocked(mockNoteTypeRepo.countCards).mockResolvedValue(5); const res = await app.request( `/api/note-types/${noteTypeId}/fields/${fieldId}`, @@ -852,8 +854,39 @@ describe("DELETE /api/note-types/:id/fields/:fieldId", () => { ); expect(res.status).toBe(409); - const body = (await res.json()) as NoteTypeResponse; + const body = (await res.json()) as NoteTypeResponse & { + cardCount?: number; + }; expect(body.error?.code).toBe("FIELD_HAS_VALUES"); + expect(body.cardCount).toBe(5); + expect(mockNoteTypeRepo.countCards).toHaveBeenCalledWith(noteTypeId); + }); + + it("deletes field with values when force=true", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId = "b0000000-0000-4000-8000-000000000002"; + const mockNoteType = createMockNoteType({ id: noteTypeId }); + + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType); + vi.mocked(mockNoteFieldTypeRepo.hasNoteFieldValues).mockResolvedValue(true); + vi.mocked(mockNoteFieldTypeRepo.softDelete).mockResolvedValue(true); + + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/${fieldId}?force=true`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as NoteTypeResponse; + expect(body.success).toBe(true); + expect(mockNoteFieldTypeRepo.softDelete).toHaveBeenCalledWith( + fieldId, + noteTypeId, + ); + expect(mockNoteTypeRepo.countCards).not.toHaveBeenCalled(); }); it("returns 404 when note type not found", async () => { diff --git a/src/server/routes/noteTypes.ts b/src/server/routes/noteTypes.ts index 7ab5aa0..ce051d4 100644 --- a/src/server/routes/noteTypes.ts +++ b/src/server/routes/noteTypes.ts @@ -188,6 +188,7 @@ export function createNoteTypesRouter(deps: NoteTypeDependencies) { async (c) => { const user = getAuthUser(c); const { id, fieldId } = c.req.valid("param"); + const force = c.req.query("force") === "true"; // Verify note type exists and belongs to user const noteType = await noteTypeRepo.findById(id, user.id); @@ -197,10 +198,17 @@ export function createNoteTypesRouter(deps: NoteTypeDependencies) { // Check if there are note field values referencing this field const hasValues = await noteFieldTypeRepo.hasNoteFieldValues(fieldId); - if (hasValues) { - throw Errors.conflict( - "Cannot delete field with existing values", - "FIELD_HAS_VALUES", + if (hasValues && !force) { + const cardCount = await noteTypeRepo.countCards(id); + return c.json( + { + error: { + message: "Cannot delete field with existing values", + code: "FIELD_HAS_VALUES", + }, + cardCount, + }, + 409, ); } -- cgit v1.3-1-g0d28