Files
Jyotisha/frontend/db/migrations/20260806000000_personal_reports.sql

105 lines
5.1 KiB
SQL

-- Personal report persistence for self-hosted PostgreSQL (staging).
-- Mirrors supabase/migrations/20260806010000_personal_reports.sql
-- one-to-one in table shape, constraints, RLS and grants.
--
-- Ownership model: rows are owned by auth.users(id) (the business-auth
-- mirror kept in sync by identity.sync_user_to_business_auth). Normal
-- application sessions connect as app_runtime (member of authenticated) and
-- set local role authenticated; they may select/delete only their own rows
-- through RLS plus the explicit owner grants below, and can never
-- insert/update (generation and status writes are performed exclusively
-- through service_role, which has BYPASSRLS and full table privileges).
-- admin_runtime has no direct access to report bodies (least privilege);
-- server-side generation runs through service_role, which admin_runtime may
-- SET ROLE to.
--
-- Idempotency: unique (user_id, request_id) is the primary lock; replay of a
-- known requestId requires the same request_fingerprint (a sha256 of the
-- caller's request intent), so a different payload under the same requestId
-- surfaces as request_conflict instead of silently overwriting. Failed
-- retries are not implicitly upserted here; callers either reuse the failed
-- record via a new requestId or surface the stable failure.
--
-- No birth details, report bodies, model prompts or exception stacks are ever
-- written to logs, index columns or audit events; failure_code is a stable
-- enum shared with frontend/src/lib/personal-report-service.ts and
-- scripts/personal_report_contract.py.
create table if not exists public.personal_reports (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
session_id uuid,
chart_profile_id uuid,
request_id uuid not null,
request_fingerprint text not null check (request_fingerprint ~ '^[0-9a-f]{64}$'),
report_type text not null check (report_type in ('personal_full', 'personal_thematic')),
status text not null check (status in ('generating', 'ready', 'failed')),
schema_version text not null check (schema_version = 'report_document.v1'),
presentation_mode text not null check (presentation_mode in ('default', 'research')),
requested_themes text[] not null default '{}'::text[],
report_document jsonb,
calculation_hash text check (calculation_hash is null or calculation_hash ~ '^[0-9a-f]{64}$'),
evidence_hash text check (evidence_hash is null or evidence_hash ~ '^[0-9a-f]{64}$'),
skill_source_commit text check (skill_source_commit is null or skill_source_commit ~ '^[0-9a-f]{40}$'),
skill_snapshot_sha256 text not null check (skill_snapshot_sha256 ~ '^[0-9a-f]{64}$'),
failure_code text check (failure_code in (
'profile_incomplete',
'birth_time_not_usable',
'report_generation_in_progress',
'report_rate_limited',
'calculation_unavailable',
'model_unavailable',
'report_schema_invalid',
'report_guard_rejected',
'report_not_found'
)),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
completed_at timestamptz,
check ((status = 'ready') = (report_document is not null)),
check ((status = 'ready') = (completed_at is not null)),
check ((status = 'ready') = (calculation_hash is not null)),
check ((status = 'ready') = (evidence_hash is not null)),
check ((status = 'failed') = (failure_code is not null)),
unique (user_id, request_id)
);
create index if not exists personal_reports_user_created_idx
on public.personal_reports (user_id, created_at desc);
-- One in-flight generation per user, enforced by the database so a second
-- request cannot start while the first is still generating.
create unique index if not exists personal_reports_one_generating_per_user
on public.personal_reports (user_id)
where status = 'generating';
alter table public.personal_reports enable row level security;
revoke all on table public.personal_reports from public, anon, authenticated, service_role;
revoke all on table public.personal_reports from app_runtime, admin_runtime, migration_runner, backup_reader;
drop policy if exists personal_reports_select_own on public.personal_reports;
create policy personal_reports_select_own
on public.personal_reports
for select
to authenticated
using (auth.uid() = user_id);
drop policy if exists personal_reports_delete_own on public.personal_reports;
create policy personal_reports_delete_own
on public.personal_reports
for delete
to authenticated
using (auth.uid() = user_id);
-- Normal users can never insert or update rows: creating a generating record
-- and moving it to ready/failed are server-side operations only. RLS
-- policies alone do not grant table privileges, so the owner read/delete
-- grants below are required for the policies to be reachable.
grant select, delete on table public.personal_reports to authenticated;
-- admin_runtime intentionally has no direct access to report bodies (least
-- privilege); server-side generation runs through service_role, which
-- admin_runtime may SET ROLE to.
grant select, insert, update, delete on table public.personal_reports to service_role;