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

232 lines
8.4 KiB
Markdown

# 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 ("<Your Company>"), 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.