# Database Schema ## SOP Governance Tool — Stage 1 (Supabase Postgres) Run order: `schema.sql` → `policies.sql` → `seed.sql`. All tables have `org_id` (AD-1). Timestamps are `timestamptz default now()`. ## 1. Entity Overview ``` orgs ─┬─ departments ─┬─ profiles (← auth.users) │ └─ sop_assignments ├─ sops ─┬─ sop_versions ─┬─ acknowledgements │ │ └─ sop_translations │ └─ approvals ├─ incidents (→ sops, sop_versions optional) ├─ audit_log └─ ai_log ``` ## 2. Tables (DDL) ```sql create type user_role as enum ('admin','approver','editor','staff'); create type sop_status as enum ('draft','submitted','approved','published','archived'); create type lang_code as enum ('en','ms','zh'); create table orgs ( id uuid primary key default gen_random_uuid(), name text not null, created_at timestamptz default now() ); create table departments ( id uuid primary key default gen_random_uuid(), org_id uuid not null references orgs(id), name text not null, unique (org_id, name) ); create table profiles ( id uuid primary key references auth.users(id) on delete cascade, org_id uuid not null references orgs(id), full_name text not null, role user_role not null default 'staff', department_id uuid references departments(id), preferred_language lang_code not null default 'en', active boolean not null default true, created_at timestamptz default now() ); create table sops ( id uuid primary key default gen_random_uuid(), org_id uuid not null references orgs(id), code text not null, -- e.g. WH-PICK-001 title text not null, department_id uuid references departments(id), category text, owner_id uuid references profiles(id), status sop_status not null default 'draft', review_months int not null default 12 check (review_months in (3,6,12,24)), draft_content jsonb not null default '{}'::jsonb, -- working copy (sections, steps) current_version_id uuid, -- FK added after sop_versions exists published_at timestamptz, created_by uuid references profiles(id), created_at timestamptz default now(), updated_at timestamptz default now(), unique (org_id, code) ); create table sop_versions ( id uuid primary key default gen_random_uuid(), org_id uuid not null references orgs(id), sop_id uuid not null references sops(id) on delete cascade, version_label text not null, -- '1.0', '1.1', '2.0' content jsonb not null, -- FROZEN snapshot of sections at publish change_note text, is_major boolean not null default false, published_by uuid references profiles(id), published_at timestamptz default now(), unique (sop_id, version_label) ); alter table sops add constraint fk_current_version foreign key (current_version_id) references sop_versions(id); create table approvals ( id uuid primary key default gen_random_uuid(), org_id uuid not null references orgs(id), sop_id uuid not null references sops(id) on delete cascade, decision text not null check (decision in ('approved','rejected')), comment text, decided_by uuid not null references profiles(id), decided_at timestamptz default now() ); create table sop_assignments ( id uuid primary key default gen_random_uuid(), org_id uuid not null references orgs(id), sop_id uuid not null references sops(id) on delete cascade, department_id uuid not null references departments(id), assigned_by uuid references profiles(id), assigned_at timestamptz default now(), unique (sop_id, department_id) ); create table acknowledgements ( id uuid primary key default gen_random_uuid(), org_id uuid not null references orgs(id), sop_version_id uuid not null references sop_versions(id), user_id uuid not null references profiles(id), language_viewed lang_code not null, typed_name text not null, -- the "signature" acknowledged_at timestamptz default now(), unique (sop_version_id, user_id) -- one ack per user per version ); create table sop_translations ( id uuid primary key default gen_random_uuid(), org_id uuid not null references orgs(id), sop_version_id uuid not null references sop_versions(id) on delete cascade, language lang_code not null, content jsonb not null, machine boolean not null default true, reviewed_by uuid references profiles(id), reviewed_at timestamptz, created_at timestamptz default now(), unique (sop_version_id, language) ); create table audit_log ( id bigint generated always as identity primary key, org_id uuid not null references orgs(id), actor_id uuid references profiles(id), action text not null, -- 'sop.published', 'ack.recorded', ... entity_type text not null, -- 'sop','user','acknowledgement' entity_id uuid, detail jsonb, created_at timestamptz default now() ); -- Append-only: grant INSERT/SELECT; never UPDATE/DELETE (enforced by grants + no policy). create table ai_log ( id bigint generated always as identity primary key, org_id uuid not null references orgs(id), user_id uuid references profiles(id), kind text not null check (kind in ('draft','translate')), input_chars int, output_chars int, input_tokens int, output_tokens int, model text, success boolean, error text, created_at timestamptz default now() ); create type incident_severity as enum ('low','medium','high'); create type incident_status as enum ('open','reviewed','closed'); create table incidents ( id uuid primary key default gen_random_uuid(), org_id uuid not null references orgs(id), reporter_id uuid not null references profiles(id), department_id uuid references departments(id), -- copied from reporter at insert sop_id uuid references sops(id), -- optional link sop_version_id uuid references sop_versions(id), -- optional, set when reported from viewer description text not null, severity incident_severity not null default 'medium', photo_path text, -- Storage path, optional status incident_status not null default 'open', resolution_note text, reviewed_by uuid references profiles(id), closed_at timestamptz, created_at timestamptz default now() ); create index idx_incidents_status on incidents(org_id, status, severity); create index idx_incidents_sop on incidents(sop_id); create index idx_sops_org_status on sops(org_id, status); create index idx_ack_version on acknowledgements(sop_version_id); create index idx_audit_entity on audit_log(entity_type, entity_id); ``` ### draft_content / content JSON shape ```json { "purpose": "…", "scope": "…", "roles": "…", "steps": [ { "order": 1, "text": "…", "photo_path": "sop-photos/…" } ], "exceptions": "…", "safety": "…", "records": "…" } ``` ## 3. RLS Policy Sketch (`policies.sql`) Enable RLS on every table. Helper: JWT carries `org_id` and `role` claims (custom access token hook reading `profiles`). | Table | staff | editor | approver | admin | |---|---|---|---|---| | sops (read) | published + assigned to their dept only | all in org | all in org | all | | sops (write) | — | insert/update drafts | — | all | | sop_versions | read if assigned | read | read | all | | acknowledgements | insert own; read own | read | read | read | | profiles | read own + names in org | read org | read org | all | | audit_log | — | — | read | read | | approvals | — | read | insert/read | all | | translations | read | read/write | read | all | | incidents | insert own; read own | read org | read/update org | all | Representative policy: ```sql alter table acknowledgements enable row level security; create policy ack_insert_own on acknowledgements for insert with check ( user_id = auth.uid() and org_id = (auth.jwt() ->> 'org_id')::uuid ); create policy ack_read on acknowledgements for select using ( org_id = (auth.jwt() ->> 'org_id')::uuid and ( user_id = auth.uid() or (auth.jwt() ->> 'role') in ('admin','approver','editor') ) ); ``` Workflow mutations (publish, approve, assign) run via the **service-role client inside /api routes** so drafts can be snapshotted and audit rows written in one transaction; RLS remains the safety net for direct reads. ## 4. Seed (`seed.sql`) One org (""), 3 departments (Inbound, Outbound, Admin), the founder as admin, 2 test staff, 2 sample SOPs (one draft, one published v1.0 with BM translation), a few acknowledgements — enough for the dashboard to show real numbers on day one.