diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 2d7040a3..0a8daad6 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -11,6 +11,12 @@ type ProfilePatchPayload = { name?: unknown; birth_date?: unknown; birth_time?: unknown; + reported_birth_time?: unknown; + birth_time_source?: unknown; + birth_time_period?: unknown; + birth_time_clue?: unknown; + uncertainty_before_minutes?: unknown; + uncertainty_after_minutes?: unknown; country_code?: unknown; province_code?: unknown; city_code?: unknown; @@ -20,6 +26,9 @@ type ProfilePatchPayload = { timezone_offset?: unknown; }; +const birthTimeSources = ["hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"] as const; +const birthTimePeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const; + function nullableString(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : null; } @@ -28,6 +37,14 @@ function nullableNumber(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? value : null; } +function nullableInteger(value: unknown) { + return typeof value === "number" && Number.isInteger(value) ? value : null; +} + +function nullableChoice(value: unknown, choices: readonly string[]) { + return typeof value === "string" && choices.includes(value) ? value : null; +} + function isMissingProfileColumn(error: { code?: string; message?: string } | null) { const message = error?.message?.toLowerCase() ?? ""; return error?.code === "PGRST204" @@ -88,6 +105,12 @@ export async function PATCH(request: Request) { name: nullableString(payload.name), birth_date: nullableString(payload.birth_date), birth_time: nullableString(payload.birth_time), + reported_birth_time: nullableString(payload.reported_birth_time), + birth_time_source: nullableChoice(payload.birth_time_source, birthTimeSources), + birth_time_period: nullableChoice(payload.birth_time_period, birthTimePeriods), + birth_time_clue: nullableString(payload.birth_time_clue), + uncertainty_before_minutes: nullableInteger(payload.uncertainty_before_minutes), + uncertainty_after_minutes: nullableInteger(payload.uncertainty_after_minutes), country_code: nullableString(payload.country_code), province_code: nullableString(payload.province_code), city_code: nullableString(payload.city_code), diff --git a/frontend/supabase/migrations/20260718103000_profile_birth_time_declaration_grants.sql b/frontend/supabase/migrations/20260718103000_profile_birth_time_declaration_grants.sql new file mode 100644 index 00000000..efb82d22 --- /dev/null +++ b/frontend/supabase/migrations/20260718103000_profile_birth_time_declaration_grants.sql @@ -0,0 +1,21 @@ +begin; + +grant insert ( + reported_birth_time, + birth_time_source, + birth_time_period, + birth_time_clue, + uncertainty_before_minutes, + uncertainty_after_minutes +) on table public.profiles to service_role; + +grant select ( + reported_birth_time, + birth_time_source, + birth_time_period, + birth_time_clue, + uncertainty_before_minutes, + uncertainty_after_minutes +) on table public.profiles to service_role; + +commit; diff --git a/frontend/tests/profile-persistence.test.ts b/frontend/tests/profile-persistence.test.ts index 8fbd5007..7f69eda7 100644 --- a/frontend/tests/profile-persistence.test.ts +++ b/frontend/tests/profile-persistence.test.ts @@ -20,6 +20,42 @@ test("account route can fall back when coordinate columns are not deployed", () assert.match(source, /PGRST204|42703|schema cache|column/i); }); +test("account route persists the birth-time declaration before assessment", () => { + // Given: the birthday step submits a reported time and its source metadata. + const source = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); + const declarationColumns = [ + "reported_birth_time", + "birth_time_source", + "birth_time_period", + "birth_time_clue", + "uncertainty_before_minutes", + "uncertainty_after_minutes", + ] as const; + + // When: /api/account builds the profile upsert. + // Then: every declaration field must cross the route boundary instead of being dropped. + for (const column of declarationColumns) { + assert.match(source, new RegExp(`${column}:\\s*nullable`), `${column} is not persisted`); + } + assert.match(source, /birthTimeSources\s*=\s*\[[\s\S]*"legacy_import"/); +}); + +test("service role can insert and read birth-time declaration columns during profile upsert", () => { + // Given: onboarding creates the profile before the birthday declaration is known. + const migration = readFileSync( + new URL( + "../supabase/migrations/20260718103000_profile_birth_time_declaration_grants.sql", + import.meta.url, + ), + "utf8", + ); + + // When: the existing profile is upserted after the birthday step. + // Then: PostgREST may insert and read all declaration columns used by that upsert. + assert.match(migration, /grant\s+insert\s*\([\s\S]*reported_birth_time[\s\S]*birth_time_source[\s\S]*uncertainty_after_minutes[\s\S]*\)\s*on\s+table\s+public\.profiles\s+to\s+service_role/i); + assert.match(migration, /grant\s+select\s*\([\s\S]*reported_birth_time[\s\S]*birth_time_source[\s\S]*uncertainty_after_minutes[\s\S]*\)\s*on\s+table\s+public\.profiles\s+to\s+service_role/i); +}); + test("service role can read every column used by account profile upserts", () => { // Given: the least-privilege grant omitted two columns submitted by /api/account. const migration = readFileSync(