feat: persist birth time rectification cases

This commit is contained in:
Jesse_Chen
2026-07-17 15:48:18 +08:00
parent 516c389183
commit e88d7a11b6
2 changed files with 305 additions and 0 deletions
@@ -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;
+94
View File
@@ -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