Files
OG/docs/04-database-schema.md
T
2026-07-30 14:35:52 +08:00

8.4 KiB

Database Schema

SOP Governance Tool — Stage 1 (Supabase Postgres)

Run order: schema.sqlpolicies.sqlseed.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)

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

{
  "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:

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.