diff --git a/frontend/supabase/migrations/20260717020000_birth_time_journey.sql b/frontend/supabase/migrations/20260717020000_birth_time_journey.sql new file mode 100644 index 00000000..495cce2d --- /dev/null +++ b/frontend/supabase/migrations/20260717020000_birth_time_journey.sql @@ -0,0 +1,211 @@ +begin; + +alter table public.profiles + add column if not exists reported_birth_time time without time zone, + add column if not exists active_birth_time time without time zone, + add column if not exists birth_time_source text, + add column if not exists birth_time_period text, + add column if not exists birth_time_clue text, + add column if not exists uncertainty_before_minutes integer, + add column if not exists uncertainty_after_minutes integer, + add column if not exists birth_time_status text, + add column if not exists rectification_confidence numeric(5, 2), + add column if not exists rectification_case_id uuid; + +update public.profiles +set + reported_birth_time = coalesce(reported_birth_time, birth_time), + active_birth_time = coalesce(active_birth_time, birth_time), + birth_time_source = coalesce(birth_time_source, 'legacy_import'), + birth_time_status = coalesce(birth_time_status, 'confirmed') +where birth_time is not null; + +alter table public.profiles + drop constraint if exists profiles_birth_time_source_check, + drop constraint if exists profiles_birth_time_period_check, + drop constraint if exists profiles_birth_time_status_check, + drop constraint if exists profiles_uncertainty_before_range, + drop constraint if exists profiles_uncertainty_after_range, + drop constraint if exists profiles_rectification_confidence_range; + +alter table public.profiles + add constraint profiles_birth_time_source_check check ( + birth_time_source is null or birth_time_source in ( + 'hospital_record', + 'family_exact', + 'approximate', + 'period_only', + 'unknown', + 'legacy_import' + ) + ), + add constraint profiles_birth_time_period_check check ( + birth_time_period is null or birth_time_period in ( + 'early_morning', + 'morning', + 'afternoon', + 'evening', + 'late_night' + ) + ), + add constraint profiles_birth_time_status_check check ( + birth_time_status is null or birth_time_status in ( + 'reported', + 'assessing', + 'rectifying', + 'candidate', + 'confirmed' + ) + ), + add constraint profiles_uncertainty_before_range check ( + uncertainty_before_minutes is null or uncertainty_before_minutes between 0 and 720 + ), + add constraint profiles_uncertainty_after_range check ( + uncertainty_after_minutes is null or uncertainty_after_minutes between 0 and 720 + ), + add constraint profiles_rectification_confidence_range check ( + rectification_confidence is null or rectification_confidence between 0 and 100 + ); + +create table if not exists public.birth_time_rectification_cases ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + status text not null default 'assessing' check ( + status in ('assessing', 'rectifying', 'candidate', 'confirmed') + ), + reported_date date not null, + reported_time time without time zone, + reported_period text, + source text not null, + uncertainty_before_minutes integer, + uncertainty_after_minutes integer, + questionnaire jsonb not null default '{}'::jsonb check (jsonb_typeof(questionnaire) = 'object'), + journey_snapshot jsonb not null default '{}'::jsonb check (jsonb_typeof(journey_snapshot) = 'object'), + answers jsonb not null default '{}'::jsonb check (jsonb_typeof(answers) = 'object'), + candidate_scan jsonb not null default '{}'::jsonb check (jsonb_typeof(candidate_scan) = 'object'), + scoring_result jsonb not null default '{}'::jsonb check (jsonb_typeof(scoring_result) = 'object'), + candidate_start time without time zone, + candidate_end time without time zone, + algorithm_version text not null default 'birth-time-journey-v1', + ayanamsa text not null default 'lahiri', + node_mode text not null default 'true', + confirmed_time time without time zone, + confirmed_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table public.profiles + drop constraint if exists profiles_rectification_case_id_fkey, + add constraint profiles_rectification_case_id_fkey + foreign key (rectification_case_id) + references public.birth_time_rectification_cases(id) + on delete set null; + +create or replace function public.guard_birth_time_journey() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if old.reported_birth_time is not null + and new.reported_birth_time is distinct from old.reported_birth_time then + raise exception 'reported_birth_time_is_immutable'; + end if; + + if new.active_birth_time is distinct from old.active_birth_time then + new.birth_time := new.active_birth_time; + elsif new.birth_time is distinct from old.birth_time then + new.active_birth_time := new.birth_time; + end if; + + if new.reported_birth_time is null and new.birth_time is not null then + new.reported_birth_time := new.birth_time; + end if; + if new.birth_time_source is null and new.birth_time is not null then + new.birth_time_source := 'legacy_import'; + end if; + if new.birth_time_status is null and new.active_birth_time is not null then + new.birth_time_status := 'confirmed'; + end if; + + return new; +end; +$$; + +drop trigger if exists profiles_guard_birth_time_journey on public.profiles; +create trigger profiles_guard_birth_time_journey +before update of birth_time, reported_birth_time, active_birth_time on public.profiles +for each row execute function public.guard_birth_time_journey(); + +alter table public.birth_time_rectification_cases enable row level security; + +drop policy if exists birth_time_cases_select_own on public.birth_time_rectification_cases; +create policy birth_time_cases_select_own +on public.birth_time_rectification_cases +for select to authenticated +using ((select auth.uid()) = user_id); + +drop policy if exists birth_time_cases_insert_own on public.birth_time_rectification_cases; +create policy birth_time_cases_insert_own +on public.birth_time_rectification_cases +for insert to authenticated +with check ((select auth.uid()) = user_id); + +drop policy if exists birth_time_cases_update_own on public.birth_time_rectification_cases; +create policy birth_time_cases_update_own +on public.birth_time_rectification_cases +for update to authenticated +using ((select auth.uid()) = user_id) +with check ((select auth.uid()) = user_id); + +revoke all on table public.birth_time_rectification_cases from anon, authenticated, service_role; +grant all on table public.birth_time_rectification_cases to service_role; +grant select on table public.birth_time_rectification_cases to authenticated; +grant insert ( + id, + user_id, + status, + reported_date, + reported_time, + reported_period, + source, + uncertainty_before_minutes, + uncertainty_after_minutes, + questionnaire, + journey_snapshot, + answers, + candidate_scan, + scoring_result, + candidate_start, + candidate_end, + updated_at +) on table public.birth_time_rectification_cases to authenticated; +grant update ( + status, + questionnaire, + journey_snapshot, + answers, + candidate_scan, + scoring_result, + candidate_start, + candidate_end, + confirmed_time, + confirmed_at, + updated_at +) on table public.birth_time_rectification_cases to authenticated; + +grant update ( + reported_birth_time, + active_birth_time, + birth_time_source, + birth_time_period, + birth_time_clue, + uncertainty_before_minutes, + uncertainty_after_minutes, + birth_time_status, + rectification_confidence, + rectification_case_id +) on table public.profiles to authenticated; + +commit; diff --git a/tests/test_birth_time_journey_contract.py b/tests/test_birth_time_journey_contract.py new file mode 100644 index 00000000..6afef082 --- /dev/null +++ b/tests/test_birth_time_journey_contract.py @@ -0,0 +1,94 @@ +import re +from pathlib import Path + + +MIGRATION = ( + Path(__file__).resolve().parents[1] + / "frontend" + / "supabase" + / "migrations" + / "20260717020000_birth_time_journey.sql" +) + + +def _sql() -> str: + return re.sub(r"\s+", " ", MIGRATION.read_text(encoding="utf-8").lower()).strip() + + +def test_birth_time_profile_contract_separates_reported_and_active_times() -> None: + sql = _sql() + + for definition in ( + "reported_birth_time time without time zone", + "active_birth_time time without time zone", + "birth_time_source text", + "birth_time_period text", + "birth_time_clue text", + "uncertainty_before_minutes integer", + "uncertainty_after_minutes integer", + "birth_time_status text", + "rectification_confidence numeric(5, 2)", + "rectification_case_id uuid", + ): + assert f"add column if not exists {definition}" in sql + + assert "reported_birth_time = coalesce(reported_birth_time, birth_time)" in sql + assert "active_birth_time = coalesce(active_birth_time, birth_time)" in sql + assert "birth_time_source = coalesce(birth_time_source, 'legacy_import')" in sql + assert "birth_time_status = coalesce(birth_time_status, 'confirmed')" in sql + assert "old.reported_birth_time is not null" in sql + assert "new.reported_birth_time is distinct from old.reported_birth_time" in sql + assert "raise exception 'reported_birth_time_is_immutable'" in sql + assert "new.birth_time := new.active_birth_time" in sql + + +def test_birth_time_profile_contract_constrains_deterministic_states() -> None: + sql = _sql() + + for value in ( + "hospital_record", + "family_exact", + "approximate", + "period_only", + "unknown", + "legacy_import", + ): + assert f"'{value}'" in sql + + for value in ("reported", "assessing", "rectifying", "candidate", "confirmed"): + assert f"'{value}'" in sql + + assert "uncertainty_before_minutes between 0 and 720" in sql + assert "uncertainty_after_minutes between 0 and 720" in sql + assert "rectification_confidence between 0 and 100" in sql + + +def test_rectification_cases_are_owner_scoped_and_auditable() -> None: + sql = _sql() + + assert "create table if not exists public.birth_time_rectification_cases" in sql + for definition in ( + "id uuid primary key default gen_random_uuid()", + "user_id uuid not null references auth.users(id) on delete cascade", + "questionnaire jsonb not null default '{}'::jsonb", + "journey_snapshot jsonb not null default '{}'::jsonb", + "answers jsonb not null default '{}'::jsonb", + "candidate_scan jsonb not null default '{}'::jsonb", + "scoring_result jsonb not null default '{}'::jsonb", + "algorithm_version text not null default 'birth-time-journey-v1'", + "ayanamsa text not null default 'lahiri'", + "node_mode text not null default 'true'", + "confirmed_at timestamptz", + ): + assert definition in sql + + assert "alter table public.birth_time_rectification_cases enable row level security" in sql + assert "birth_time_cases_select_own" in sql + assert "birth_time_cases_insert_own" in sql + assert "birth_time_cases_update_own" in sql + assert "using ((select auth.uid()) = user_id)" in sql + assert "with check ((select auth.uid()) = user_id)" in sql + assert "grant select on table public.birth_time_rectification_cases to authenticated" in sql + assert "grant insert" in sql + assert "grant update" in sql + assert "grant delete" not in sql