M0: DB schema, RLS policies, JWT claims hook, seed, Supabase clients
Adds db/schema.sql (12 tables), db/policies.sql (RLS on all 12,
audit_log append-only), db/auth-hook.sql (role/org_id into JWT per
AD-2), db/seed.sql (org + 3 departments, part 2 deferred to M1 auth).
Wires lib/supabase/{client,server,service}.ts per AD-3 and adds
/db-check page confirming DB connectivity and RLS deny-by-default.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcktbLXSSXzx23GCue813e
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
680b6a0194
commit
f539d43136
@@ -0,0 +1,79 @@
|
|||||||
|
-- Custom Access Token Hook
|
||||||
|
-- Implements AD-2 from docs/03-architecture.md:
|
||||||
|
-- "A Postgres function copies role/org_id into JWT claims (custom
|
||||||
|
-- access token hook) so RLS can check them cheaply."
|
||||||
|
--
|
||||||
|
-- Run this AFTER schema.sql (needs the profiles table to exist).
|
||||||
|
-- After running, enable the hook in the Supabase dashboard (see bottom
|
||||||
|
-- of this file for the exact steps) — running the SQL alone does not
|
||||||
|
-- activate it.
|
||||||
|
|
||||||
|
create or replace function public.custom_access_token_hook(event jsonb)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
claims jsonb;
|
||||||
|
user_role public.user_role;
|
||||||
|
user_org_id uuid;
|
||||||
|
begin
|
||||||
|
-- Look up this user's role and org_id from profiles.
|
||||||
|
select role, org_id
|
||||||
|
into user_role, user_org_id
|
||||||
|
from public.profiles
|
||||||
|
where id = (event->>'user_id')::uuid;
|
||||||
|
|
||||||
|
claims := event->'claims';
|
||||||
|
|
||||||
|
if user_role is not null then
|
||||||
|
claims := jsonb_set(claims, '{role}', to_jsonb(user_role));
|
||||||
|
claims := jsonb_set(claims, '{org_id}', to_jsonb(user_org_id));
|
||||||
|
else
|
||||||
|
claims := jsonb_set(claims, '{role}', 'null');
|
||||||
|
claims := jsonb_set(claims, '{org_id}', 'null');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
event := jsonb_set(event, '{claims}', claims);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- The hook runs as the supabase_auth_admin role, not as the logged-in
|
||||||
|
-- user, so it needs explicit permission to call the function and read
|
||||||
|
-- profiles. Everyone else is explicitly blocked from calling it directly.
|
||||||
|
|
||||||
|
grant usage on schema public to supabase_auth_admin;
|
||||||
|
|
||||||
|
grant execute
|
||||||
|
on function public.custom_access_token_hook
|
||||||
|
to supabase_auth_admin;
|
||||||
|
|
||||||
|
revoke execute
|
||||||
|
on function public.custom_access_token_hook
|
||||||
|
from authenticated, anon, public;
|
||||||
|
|
||||||
|
grant select
|
||||||
|
on table public.profiles
|
||||||
|
to supabase_auth_admin;
|
||||||
|
|
||||||
|
create policy "Allow auth admin to read profiles for JWT hook"
|
||||||
|
on public.profiles
|
||||||
|
as permissive
|
||||||
|
for select
|
||||||
|
to supabase_auth_admin
|
||||||
|
using (true);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Dashboard steps to enable this hook (run the SQL above first):
|
||||||
|
--
|
||||||
|
-- 1. Supabase dashboard → Authentication → Hooks (left sidebar,
|
||||||
|
-- under "Configuration").
|
||||||
|
-- 2. Find "Customize Access Token (JWT) Claims hook".
|
||||||
|
-- 3. Choose "Postgres function" as the hook type.
|
||||||
|
-- 4. Select public.custom_access_token_hook from the dropdown.
|
||||||
|
-- 5. Enable the hook (toggle on) and save.
|
||||||
|
-- 6. Existing logged-in sessions keep their OLD token until they
|
||||||
|
-- refresh/re-login — log out and back in to see new claims.
|
||||||
|
-- ============================================================
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
-- RLS Policies — all 12 tables
|
||||||
|
-- Source: docs/04-database-schema.md section 3 (role matrix below), extended
|
||||||
|
-- consistently to 4 tables the matrix doesn't cover (orgs, departments,
|
||||||
|
-- sop_assignments, ai_log) — marked "(not in matrix)" at each one.
|
||||||
|
--
|
||||||
|
-- Coverage table (docs/04-database-schema.md section 3):
|
||||||
|
--
|
||||||
|
-- | Table | staff | editor | approver | admin |
|
||||||
|
-- |--------------------|--------------------------------|--------------------|---------------------|-------|
|
||||||
|
-- | sops (read) | published + assigned 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 |
|
||||||
|
--
|
||||||
|
-- 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.
|
||||||
|
-- Default pattern in this file: RLS grants READ access per role; WRITE
|
||||||
|
-- access via RLS is only granted where the matrix explicitly says so
|
||||||
|
-- (insert own, read/write, etc). Everything else is mutated exclusively
|
||||||
|
-- by service-role /api routes, which bypass RLS entirely.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- orgs (not in matrix — read own org only, no client write)
|
||||||
|
-- ============================================================
|
||||||
|
alter table orgs enable row level security;
|
||||||
|
|
||||||
|
create policy orgs_read on orgs
|
||||||
|
for select using (
|
||||||
|
id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
);
|
||||||
|
-- no insert/update/delete policy: single org, managed manually in Stage 1.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- departments (not in matrix — read all in org, admin writes, FR-1.3)
|
||||||
|
-- ============================================================
|
||||||
|
alter table departments enable row level security;
|
||||||
|
|
||||||
|
create policy departments_read on departments
|
||||||
|
for select using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy departments_insert_admin on departments
|
||||||
|
for insert with check (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') = 'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy departments_update_admin on departments
|
||||||
|
for update using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') = 'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy departments_delete_admin on departments
|
||||||
|
for delete using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') = 'admin'
|
||||||
|
);
|
||||||
|
-- "delete blocked if department has users" (FR-1.3) is enforced by the
|
||||||
|
-- profiles.department_id foreign key, not by RLS.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- profiles — own row always; org-wide read (table has no sensitive
|
||||||
|
-- fields beyond role/department, so org-wide read is a low-risk
|
||||||
|
-- simplification); admin writes via RLS as a safety net (actual
|
||||||
|
-- invites/role-changes go through the service-role /api/users route).
|
||||||
|
-- ============================================================
|
||||||
|
alter table profiles enable row level security;
|
||||||
|
|
||||||
|
create policy profiles_read on profiles
|
||||||
|
for select using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy profiles_update_admin on profiles
|
||||||
|
for update using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') = 'admin'
|
||||||
|
);
|
||||||
|
-- no insert policy: profile rows are created by the signup flow
|
||||||
|
-- (service-role), not inserted directly by a client.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- sops
|
||||||
|
-- ============================================================
|
||||||
|
alter table sops enable row level security;
|
||||||
|
|
||||||
|
create policy sops_read on sops
|
||||||
|
for select using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (
|
||||||
|
(auth.jwt() ->> 'role') in ('admin','approver','editor')
|
||||||
|
or (
|
||||||
|
(auth.jwt() ->> 'role') = 'staff'
|
||||||
|
and status = 'published'
|
||||||
|
and exists (
|
||||||
|
select 1
|
||||||
|
from sop_assignments sa
|
||||||
|
join profiles p on p.department_id = sa.department_id
|
||||||
|
where sa.sop_id = sops.id
|
||||||
|
and p.id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy sops_insert_editor on sops
|
||||||
|
for insert with check (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') in ('editor','admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy sops_update_editor on sops
|
||||||
|
for update using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (
|
||||||
|
(auth.jwt() ->> 'role') = 'admin'
|
||||||
|
or ( (auth.jwt() ->> 'role') = 'editor' and status = 'draft' )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- sop_versions — read only via RLS; every version is written by the
|
||||||
|
-- service-role publish route (AD-4), never directly, not even by admin.
|
||||||
|
-- ============================================================
|
||||||
|
alter table sop_versions enable row level security;
|
||||||
|
|
||||||
|
create policy sop_versions_read on sop_versions
|
||||||
|
for select using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (
|
||||||
|
(auth.jwt() ->> 'role') in ('admin','approver','editor')
|
||||||
|
or (
|
||||||
|
(auth.jwt() ->> 'role') = 'staff'
|
||||||
|
and exists (
|
||||||
|
select 1
|
||||||
|
from sop_assignments sa
|
||||||
|
join profiles p on p.department_id = sa.department_id
|
||||||
|
where sa.sop_id = sop_versions.sop_id
|
||||||
|
and p.id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- approvals — a decision, once made, is never editable or deletable.
|
||||||
|
-- ============================================================
|
||||||
|
alter table approvals enable row level security;
|
||||||
|
|
||||||
|
create policy approvals_read on approvals
|
||||||
|
for select using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') in ('editor','approver','admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy approvals_insert on approvals
|
||||||
|
for insert with check (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') in ('approver','admin')
|
||||||
|
and decided_by = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- sop_assignments (not in matrix — read all in org, no client write;
|
||||||
|
-- set only via the service-role /api/sops/:id/assign route)
|
||||||
|
-- ============================================================
|
||||||
|
alter table sop_assignments enable row level security;
|
||||||
|
|
||||||
|
create policy sop_assignments_read on sop_assignments
|
||||||
|
for select using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- acknowledgements — immutable once written (FR-4.3): insert only,
|
||||||
|
-- never update or delete.
|
||||||
|
-- ============================================================
|
||||||
|
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') )
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- sop_translations
|
||||||
|
-- ============================================================
|
||||||
|
alter table sop_translations enable row level security;
|
||||||
|
|
||||||
|
create policy translations_read on sop_translations
|
||||||
|
for select using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy translations_insert_editor on sop_translations
|
||||||
|
for insert with check (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') in ('editor','admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy translations_update_editor on sop_translations
|
||||||
|
for update using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') in ('editor','admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- audit_log — append-only. INSERT + SELECT policies only.
|
||||||
|
-- No UPDATE policy. No DELETE policy. Ever. (FR-3.4)
|
||||||
|
-- ============================================================
|
||||||
|
alter table audit_log enable row level security;
|
||||||
|
|
||||||
|
create policy audit_read on audit_log
|
||||||
|
for select using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') in ('approver','admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy audit_insert on audit_log
|
||||||
|
for insert with check (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- ai_log (not in matrix — admin-only read for cost tracking, no FRD
|
||||||
|
-- screen for it; no client write, only the service-role AI routes write)
|
||||||
|
-- ============================================================
|
||||||
|
alter table ai_log enable row level security;
|
||||||
|
|
||||||
|
create policy ai_log_read_admin on ai_log
|
||||||
|
for select using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') = 'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- incidents — FR-7.1 says "staff+" (any role) can report, so insert-own
|
||||||
|
-- is granted to every role, not just staff.
|
||||||
|
-- ============================================================
|
||||||
|
alter table incidents enable row level security;
|
||||||
|
|
||||||
|
create policy incidents_insert_own on incidents
|
||||||
|
for insert with check (
|
||||||
|
reporter_id = auth.uid()
|
||||||
|
and org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy incidents_read on incidents
|
||||||
|
for select using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and ( reporter_id = auth.uid()
|
||||||
|
or (auth.jwt() ->> 'role') in ('editor','approver','admin') )
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy incidents_update on incidents
|
||||||
|
for update using (
|
||||||
|
org_id = (auth.jwt() ->> 'org_id')::uuid
|
||||||
|
and (auth.jwt() ->> 'role') in ('approver','admin')
|
||||||
|
);
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
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);
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
-- Seed data
|
||||||
|
-- Source: docs/04-database-schema.md section 4:
|
||||||
|
-- "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."
|
||||||
|
--
|
||||||
|
-- NOTE: profiles.id references auth.users(id). Auth users don't exist yet
|
||||||
|
-- (M1 builds login/signup). Part 1 below is safe to run now (M0). Part 2
|
||||||
|
-- is provided for later — after M1 creates real auth users via Supabase
|
||||||
|
-- Auth, replace the placeholder UUIDs with their real auth.users ids and
|
||||||
|
-- run it then. Do not run Part 2 yet.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Part 1 — safe to run now (no auth dependency)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
insert into orgs (id, name)
|
||||||
|
values ('00000000-0000-0000-0000-000000000001', 'Demo Warehouse Co');
|
||||||
|
|
||||||
|
insert into departments (id, org_id, name) values
|
||||||
|
('00000000-0000-0000-0000-000000000011', '00000000-0000-0000-0000-000000000001', 'Inbound'),
|
||||||
|
('00000000-0000-0000-0000-000000000012', '00000000-0000-0000-0000-000000000001', 'Outbound'),
|
||||||
|
('00000000-0000-0000-0000-000000000013', '00000000-0000-0000-0000-000000000001', 'Admin');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Part 2 — run after M1 (auth users exist). Replace the id values
|
||||||
|
-- below with the real auth.users.id for each account.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- insert into profiles (id, org_id, full_name, role, department_id, preferred_language) values
|
||||||
|
-- ('<founder-auth-user-id>', '00000000-0000-0000-0000-000000000001', 'Founder Name', 'admin', '00000000-0000-0000-0000-000000000013', 'en'),
|
||||||
|
-- ('<staff-1-auth-user-id>', '00000000-0000-0000-0000-000000000001', 'Staff One', 'staff', '00000000-0000-0000-0000-000000000011', 'ms'),
|
||||||
|
-- ('<staff-2-auth-user-id>', '00000000-0000-0000-0000-000000000001', 'Staff Two', 'staff', '00000000-0000-0000-0000-000000000012', 'en');
|
||||||
|
|
||||||
|
-- insert into sops (id, org_id, code, title, department_id, category, owner_id, status, review_months, draft_content, created_by) values
|
||||||
|
-- ('00000000-0000-0000-0000-000000000021', '00000000-0000-0000-0000-000000000001', 'WH-PICK-001', 'Order Picking', '00000000-0000-0000-0000-000000000012', 'Warehouse', '<founder-auth-user-id>', 'draft', 12, '{}'::jsonb, '<founder-auth-user-id>'),
|
||||||
|
-- ('00000000-0000-0000-0000-000000000022', '00000000-0000-0000-0000-000000000001', 'WH-INB-001', 'Inbound Receiving', '00000000-0000-0000-0000-000000000011', 'Warehouse', '<founder-auth-user-id>', 'published', 12, '{}'::jsonb, '<founder-auth-user-id>');
|
||||||
|
|
||||||
|
-- insert into sop_versions (id, org_id, sop_id, version_label, content, published_by) values
|
||||||
|
-- ('00000000-0000-0000-0000-000000000031', '00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000022', '1.0', '{"purpose":"...","steps":[]}'::jsonb, '<founder-auth-user-id>');
|
||||||
|
|
||||||
|
-- update sops set current_version_id = '00000000-0000-0000-0000-000000000031', published_at = now()
|
||||||
|
-- where id = '00000000-0000-0000-0000-000000000022';
|
||||||
|
|
||||||
|
-- insert into sop_translations (org_id, sop_version_id, language, content, machine) values
|
||||||
|
-- ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000031', 'ms', '{"purpose":"...","steps":[]}'::jsonb, true);
|
||||||
|
|
||||||
|
-- insert into acknowledgements (org_id, sop_version_id, user_id, language_viewed, typed_name) values
|
||||||
|
-- ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000031', '<staff-1-auth-user-id>', 'ms', 'Staff One');
|
||||||
Generated
+120
@@ -9,6 +9,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@base-ui/react": "^1.6.0",
|
||||||
|
"@supabase/ssr": "^0.12.4",
|
||||||
|
"@supabase/supabase-js": "^2.111.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
@@ -1913,6 +1915,115 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.111.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.111.0.tgz",
|
||||||
|
"integrity": "sha512-hbRLgyQZEX0SDyF4LYXpv94qOIQyFATfpT5SIs2V0SisHnisVRkYgiPQWzv80l4S2mO3qof78rmoH9j5zoFsYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.111.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.111.0.tgz",
|
||||||
|
"integrity": "sha512-RW/OCsd6MO592zU8ifzP8/f8XzxxIdpb+Up5XaOtE26Fw+3zTp475WX7+GuuktiD1WF8pFUDe6khUPbMp77RCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/phoenix": {
|
||||||
|
"version": "0.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
|
||||||
|
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.111.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.111.0.tgz",
|
||||||
|
"integrity": "sha512-pcqeDsnWP0lx9GawduYxNZJHeuTm53O7L0SC8RF8tniV3GWIPY6me6OTdnwzdwNUmNy1dzUVtSyIfE6+OflzPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.111.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.111.0.tgz",
|
||||||
|
"integrity": "sha512-6oRf/vZyRwg8f8GbFSJkrD2w4HAu/yTvyMViHXHS+H5hNJzdXCrUR7cP5oW7daT3YlRnzRPY9LcGSJKZAmfMSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/phoenix": "0.4.5",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/ssr": {
|
||||||
|
"version": "0.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.4.tgz",
|
||||||
|
"integrity": "sha512-xHzcgI8cC1TpBKSwJcR5Yd8CCwfIq0SBc5yb4yz/YFw5tbCrEQ0QT3a+2jymCxHgQWLfzwN93HZ6eRbcoMkOlA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.111.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/ssr/node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.111.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.111.0.tgz",
|
||||||
|
"integrity": "sha512-UEViNmTzVOxE8dqUA81wls+n9xgmlvSFfhfwo6QxrO4kQOytCYyw3ciYFoi4XoD4Jl95NJ3jnndHN5iIudWzqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.111.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.111.0.tgz",
|
||||||
|
"integrity": "sha512-9q0/AULthQnWeiDh1vGyjoJZbSY04bu6qHcWit70pqEYn5Kv/dkCPY62Ja1123jEnJbB9Vd2pjY7Kvk/lK3peA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.111.0",
|
||||||
|
"@supabase/functions-js": "2.111.0",
|
||||||
|
"@supabase/postgrest-js": "2.111.0",
|
||||||
|
"@supabase/realtime-js": "2.111.0",
|
||||||
|
"@supabase/storage-js": "2.111.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -5623,6 +5734,15 @@
|
|||||||
"node": ">=18.18.0"
|
"node": ">=18.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@base-ui/react": "^1.6.0",
|
||||||
|
"@supabase/ssr": "^0.12.4",
|
||||||
|
"@supabase/supabase-js": "^2.111.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export default async function DbCheckPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: org, error } = await supabase
|
||||||
|
.from("orgs")
|
||||||
|
.select("name")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ padding: 24, fontFamily: "sans-serif" }}>
|
||||||
|
<h1>Supabase connection check</h1>
|
||||||
|
{org ? (
|
||||||
|
<p>Org name: {org.name}</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
No org row returned (expected — no login yet, RLS is correctly
|
||||||
|
denying an anonymous read). Error detail:{" "}
|
||||||
|
{error?.message ?? "none"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
|
||||||
|
export function createClient() {
|
||||||
|
return createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export async function createClient() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// setAll was called from a Server Component (cookies are read-only there).
|
||||||
|
// Safe to ignore as long as session refresh also runs in middleware.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
// Service-role client. Bypasses RLS entirely.
|
||||||
|
// Only ever import this inside /app/api/* route handlers, after the
|
||||||
|
// route has manually verified the caller's session and role (AD-3).
|
||||||
|
// Never import this in a client component or a plain read path.
|
||||||
|
export function createServiceClient() {
|
||||||
|
return createSupabaseClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
persistSession: false,
|
||||||
|
autoRefreshToken: false,
|
||||||
|
detectSessionInUrl: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user