diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx index 23c7b6082..482a14e9a 100644 --- a/client/src/components/Todo/TodoListPanel.tsx +++ b/client/src/components/Todo/TodoListPanel.tsx @@ -350,7 +350,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: { name: name.trim(), description: desc || null, due_date: dueDate || null, category: category || null, assigned_user_id: assignedUserId, priority, - } as any) + }) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) } setSaving(false) } @@ -566,7 +566,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated, name: name.trim(), description: desc || null, priority, due_date: dueDate || null, category: trimmedCategory || null, assigned_user_id: assignedUserId, - } as any) + }) if (item?.id) onCreated(item.id) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) } setSaving(false) diff --git a/client/src/mobile/screens/trip/tabs/MTaskSheet.tsx b/client/src/mobile/screens/trip/tabs/MTaskSheet.tsx index a6e552d03..1f5e24b8b 100644 --- a/client/src/mobile/screens/trip/tabs/MTaskSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MTaskSheet.tsx @@ -20,19 +20,6 @@ export interface MTaskSheetProps { onClose: () => void } -/** Payload shape covering both create and update — the update schema allows - * clearing a field with `null`, which `TodoUpdateItemRequest` itself (typed - * against the create request) doesn't express; the store call below is cast - * to it rather than widened to `any`. */ -interface TodoSavePayload { - name: string - description: string | null - due_date: string | null - category: string | null - assigned_user_id: number | null - priority: number -} - /** * Add/Edit task sheet (spec 03 §4.7 `openTask` / progress-card "New task"): * name, description, list (category), due date, assignee and priority. @@ -99,7 +86,7 @@ export default function MTaskSheet({ planner, open, itemId, categories, members, setSaving(true) try { if (item) { - const payload: TodoSavePayload = { + const payload: TodoUpdateItemRequest = { name: trimmedName, description: description || null, due_date: dueDate || null, @@ -107,7 +94,7 @@ export default function MTaskSheet({ planner, open, itemId, categories, members, assigned_user_id: assignedUserId, priority, } - await tripActions.updateTodoItem(tripId, item.id, payload as unknown as TodoUpdateItemRequest) + await tripActions.updateTodoItem(tripId, item.id, payload) } else { const payload: TodoCreateItemRequest = { name: trimmedName, diff --git a/shared/src/todo/todo.schema.spec.ts b/shared/src/todo/todo.schema.spec.ts index ffb401d22..86143cf3a 100644 --- a/shared/src/todo/todo.schema.spec.ts +++ b/shared/src/todo/todo.schema.spec.ts @@ -17,6 +17,18 @@ describe('todoCreateItemRequestSchema', () => { // priority is numeric (matches the service), not a string expect(todoCreateItemRequestSchema.safeParse({ name: 'X', priority: 'high' }).success).toBe(false); }); + + it('accepts explicit null on the optional metadata fields (client clears with null)', () => { + expect( + todoCreateItemRequestSchema.safeParse({ + name: 'X', + category: null, + due_date: null, + description: null, + assigned_user_id: null, + }).success, + ).toBe(true); + }); }); describe('todoUpdateItemRequestSchema', () => { @@ -24,6 +36,25 @@ describe('todoUpdateItemRequestSchema', () => { expect(todoUpdateItemRequestSchema.safeParse({}).success).toBe(true); expect(todoUpdateItemRequestSchema.safeParse({ checked: true }).success).toBe(true); }); + + it('accepts checked as 0/1 (legacy numeric form) but rejects other numbers', () => { + expect(todoUpdateItemRequestSchema.safeParse({ checked: 1 }).success).toBe(true); + expect(todoUpdateItemRequestSchema.safeParse({ checked: 0 }).success).toBe(true); + expect(todoUpdateItemRequestSchema.safeParse({ checked: 2 }).success).toBe(false); + }); + + it('accepts explicit null on the nullable fields (bodyKeys clear protocol)', () => { + expect( + todoUpdateItemRequestSchema.safeParse({ + category: null, + due_date: null, + description: null, + assigned_user_id: null, + priority: null, + }).success, + ).toBe(true); + expect(todoUpdateItemRequestSchema.safeParse({ name: null }).success).toBe(false); + }); }); describe('todoReorderRequestSchema', () => { diff --git a/shared/src/todo/todo.schema.ts b/shared/src/todo/todo.schema.ts index 2519adb75..e8e68bad0 100644 --- a/shared/src/todo/todo.schema.ts +++ b/shared/src/todo/todo.schema.ts @@ -12,22 +12,27 @@ import { z } from 'zod'; export const todoCreateItemRequestSchema = z.object({ name: z.string().min(1), - category: z.string().optional(), - due_date: z.string().optional(), - description: z.string().optional(), - assigned_user_id: z.number().optional(), + // The client clears optional fields by sending explicit null (the service + // coerces falsy to its defaults), so every optional metadata field is nullable. + category: z.string().nullable().optional(), + due_date: z.string().nullable().optional(), + description: z.string().nullable().optional(), + assigned_user_id: z.number().nullable().optional(), priority: z.number().optional(), }); export type TodoCreateItemRequest = z.infer; export const todoUpdateItemRequestSchema = z.object({ name: z.string().optional(), - checked: z.boolean().optional(), - category: z.string().optional(), - due_date: z.string().optional(), - description: z.string().optional(), - assigned_user_id: z.number().optional(), - priority: z.number().optional(), + // The legacy route accepted both boolean and 0/1 for checked — both stay valid. + checked: z.union([z.boolean(), z.number().int().min(0).max(1)]).optional(), + // Nullable fields follow the bodyKeys protocol: a key present with null + // clears the field, an omitted key leaves it unchanged. + category: z.string().nullable().optional(), + due_date: z.string().nullable().optional(), + description: z.string().nullable().optional(), + assigned_user_id: z.number().nullable().optional(), + priority: z.number().nullable().optional(), }); export type TodoUpdateItemRequest = z.infer;