Files
paliad/internal/db/migrations/018_projects_v2.up.sql
m 3faec6c526 refactor(rename): German→English for backend (tables, types, services, handler files)
t-paliad-025 — Phase 1: backend rename.

Migrations 018+019 rewritten from scratch with English table/column
names throughout. Since v2 schema (018/019) has never been applied to
youpc prod DB, this is a clean replacement — not an ALTER RENAME chain.
Pre-existing German tables (parteien, fristen, termine, dokumente,
akten_events, notizen) are renamed inline in 018 via ALTER TABLE … RENAME
TO alongside the akte_id → project_id column rewrite.

Renames applied:
  projekte            → projects
  projekt_teams       → project_teams
  projekt_events      → project_events (via akten_events → project_events)
  fristen             → deadlines
  termine             → appointments
  parteien            → parties
  notizen             → notes
  dezernate           → departments
  dezernat_mitglieder → department_members
  dokumente           → documents
  can_see_projekt     → can_see_project
  notiz_is_visible    → note_is_visible
  akte_id  / frist_id / termin_id / akten_event_id → project_id /
    deadline_id / appointment_id / project_event_id
  termin_type → appointment_type

Go types + services renamed:
  Projekt / ProjektService / ProjektEvent / ProjektTeamMember
  Frist / FristService / FristWithProjekt
  Termin / TerminService / TerminWithProjekt / TerminType
  Notiz / NotizService / ChecklistInstanceWithProjekt
  Dezernat / DezernatService / DezernatMitglied
  Partei / Parteien / ParteienService

Files renamed (git mv):
  internal/services/{projekt,frist,termin,notiz,dezernat,parteien}_service.go
    → {project,deadline,appointment,note,department,party}_service.go
  internal/handlers/{projekte,fristen,fristen_pages,termine,termine_pages,
    notizen,dezernate,akten_pages,gerichte,glossar,checklisten}.go
    → {projects,deadlines,deadlines_pages,appointments,appointments_pages,
       notes,departments,projects_pages,courts,glossary,checklists}.go
  internal/checklisten/ → internal/checklists/
  internal/db/migrations/018_projekte_v2.* → 018_projects_v2.*
  internal/db/migrations/019_seed_dezernate_from_user_text.*
    → 019_seed_departments_from_user_text.*

User-facing i18n strings (DE/EN labels) stay untouched. Product names
Fristenrechner / Kostenrechner / Gebührentabellen stay German.

Build + vet + tests clean.
2026-04-20 17:35:38 +02:00

657 lines
29 KiB
PL/PgSQL

-- Data model v2 (t-paliad-024) + full English rename (t-paliad-025).
--
-- Replaces paliad.akten with a single self-referential paliad.projects tree,
-- and renames every remaining German table/column in paliad.* to English.
-- Only user-facing i18n strings stay bilingual (owned by the frontend).
--
-- Visibility is purely team-based (direct + inherited up the path) + admin.
-- Office becomes an informational attribute on users only — no project-level
-- office gate anymore. Cases are associated with lead partners, not offices.
--
-- Migration is one-shot: creates the new schema, renames child tables in
-- place and rewrites their FKs (same UUIDs as akten.id), drops paliad.akten,
-- replaces can_see_akte() with can_see_project(). All existing akten rows
-- survive as projects rows of type='case' with parent_id=NULL (orphan cases —
-- admin/partners reparent them under real clients through the new UI).
-- ============================================================================
-- 1. users: primary office stays; add additional_offices for partners
-- who work across offices.
-- ============================================================================
ALTER TABLE paliad.users
ADD COLUMN IF NOT EXISTS additional_offices text[] NOT NULL DEFAULT '{}';
-- ============================================================================
-- 2. paliad.projects — the single hierarchical tree.
-- type in (client, litigation, patent, case, project).
-- Roots (parent_id NULL) are typically type='client' but not enforced —
-- a generic 'project' root is also valid (e.g., internal knowledge project).
-- ============================================================================
CREATE TABLE paliad.projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
type text NOT NULL CHECK (type IN (
'client','litigation','patent','case','project'
)),
parent_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
-- Materialised path of UUID labels joined by '.'; always includes self
-- as the last label. Root: path = id::text. Child: path = parent.path
-- || '.' || id::text. Maintained by the trigger below; never write
-- directly from the service layer.
path text NOT NULL,
title text NOT NULL,
reference text,
description text,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived','closed')),
-- created_by is nullable (matches akten; users can be deleted from
-- auth.users). A NULL creator does NOT grant visibility — the row is
-- only visible via explicit team membership (or admin).
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
-- Client-specific (type='client'). Nullable for other types.
industry text,
country text,
billing_reference text,
-- ClientMatter numbers — external HLC billing/DMS identifiers, not
-- generated by Paliad. Format: CCCCCCC.MMMMMMM (7+7 digits).
-- * client_number lives on the Client-level Project and is inherited
-- (by convention in the UI, not enforced) down the tree.
-- * matter_number is assigned independently at any sub-level
-- (litigation / patent / case). Children may override the inherited
-- client_number — rare but allowed.
-- Both are nullable; search/filter is across the tree.
client_number text CHECK (client_number IS NULL OR client_number ~ '^[0-9]{7}$'),
matter_number text CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{7}$'),
-- netDocuments: HLC's DMS. We can't integrate via API, so we store a
-- bookmark URL per project. UI renders as an external-link button.
netdocuments_url text,
-- Patent-specific (type='patent'). Nullable for other types.
patent_number text,
filing_date date,
grant_date date,
-- Case-specific (type='case'). Nullable for other types.
court text,
case_number text,
proceeding_type_id integer REFERENCES paliad.proceeding_types(id) ON DELETE SET NULL,
metadata jsonb NOT NULL DEFAULT '{}',
ai_summary text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT projects_parent_self_differs CHECK (parent_id IS NULL OR parent_id <> id)
);
-- text_pattern_ops index supports LIKE 'prefix.%' for fast descendant lookup.
CREATE INDEX projects_path_prefix_idx ON paliad.projects (path text_pattern_ops);
CREATE INDEX projects_parent_idx ON paliad.projects (parent_id);
CREATE INDEX projects_type_status_idx ON paliad.projects (type, status);
CREATE INDEX projects_reference_idx ON paliad.projects (reference) WHERE reference IS NOT NULL;
CREATE INDEX projects_client_number_idx ON paliad.projects (client_number) WHERE client_number IS NOT NULL;
CREATE INDEX projects_matter_number_idx ON paliad.projects (matter_number) WHERE matter_number IS NOT NULL;
-- ============================================================================
-- 3. Path maintenance triggers.
-- BEFORE INSERT / BEFORE UPDATE OF parent_id: recompute this row's path.
-- AFTER UPDATE OF path: propagate the new prefix to every descendant.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.projects_sync_path()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
parent_path text;
BEGIN
IF NEW.parent_id IS NULL THEN
NEW.path := NEW.id::text;
ELSE
SELECT path INTO parent_path
FROM paliad.projects
WHERE id = NEW.parent_id;
IF parent_path IS NULL THEN
RAISE EXCEPTION 'parent project % not found', NEW.parent_id;
END IF;
-- Reject cycles: parent's path cannot contain this row's id.
IF parent_path = NEW.id::text
OR parent_path LIKE '%.' || NEW.id::text
OR parent_path LIKE NEW.id::text || '.%'
OR parent_path LIKE '%.' || NEW.id::text || '.%'
THEN
RAISE EXCEPTION 'cannot set parent to own descendant';
END IF;
NEW.path := parent_path || '.' || NEW.id::text;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER projects_sync_path_before
BEFORE INSERT OR UPDATE OF parent_id ON paliad.projects
FOR EACH ROW EXECUTE FUNCTION paliad.projects_sync_path();
CREATE OR REPLACE FUNCTION paliad.projects_rewrite_subtree()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.path IS DISTINCT FROM NEW.path THEN
UPDATE paliad.projects
SET path = NEW.path || substring(path FROM length(OLD.path) + 1)
WHERE path LIKE OLD.path || '.%';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER projects_rewrite_subtree_after
AFTER UPDATE OF path ON paliad.projects
FOR EACH ROW EXECUTE FUNCTION paliad.projects_rewrite_subtree();
-- ============================================================================
-- 4. paliad.project_teams — membership. inherited=false rows are writes;
-- inherited=true is a flag the service layer may set on read to annotate
-- rows derived from an ancestor's team. Writes always use inherited=false.
-- ============================================================================
CREATE TABLE paliad.project_teams (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN (
'lead','associate','pa','of_counsel',
'local_counsel','expert','observer'
)),
inherited boolean NOT NULL DEFAULT false,
added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (project_id, user_id)
);
CREATE INDEX project_teams_project_idx ON paliad.project_teams (project_id);
CREATE INDEX project_teams_user_idx ON paliad.project_teams (user_id);
-- ============================================================================
-- 5. paliad.departments — structural partner units (distinct from project teams).
-- A user's Department membership is orthogonal to their project-team roles.
-- ============================================================================
CREATE TABLE paliad.departments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
lead_user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
office text NOT NULL CHECK (office IN (
'munich','duesseldorf','hamburg',
'amsterdam','london','paris','milan'
)),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX departments_office_idx ON paliad.departments (office);
CREATE INDEX departments_lead_idx ON paliad.departments (lead_user_id) WHERE lead_user_id IS NOT NULL;
CREATE TABLE paliad.department_members (
department_id uuid NOT NULL REFERENCES paliad.departments(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (department_id, user_id)
);
CREATE INDEX department_members_user_idx ON paliad.department_members (user_id);
-- ============================================================================
-- 6. Data migration: akten → projects (same UUIDs), collaborators+created_by
-- → project_teams.
-- All existing akten become type='case' orphans (parent_id NULL). Admins
-- reparent under real clients via the new UI.
-- ============================================================================
INSERT INTO paliad.projects (
id, type, parent_id, path,
title, reference, status,
court, case_number,
created_by, metadata, ai_summary,
created_at, updated_at
)
SELECT
a.id,
'case'::text,
NULL::uuid,
a.id::text, -- root path = id (no parent)
a.title,
NULLIF(a.aktenzeichen, ''), -- aktenzeichen → reference
a.status,
a.court,
a.court_ref,
a.created_by,
a.metadata,
a.ai_summary,
a.created_at,
a.updated_at
FROM paliad.akten a;
-- Creator → team lead (skip NULL creators; they remain admin-only orphans).
INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
SELECT a.id, a.created_by, 'lead', false, a.created_by
FROM paliad.akten a
WHERE a.created_by IS NOT NULL
ON CONFLICT (project_id, user_id) DO NOTHING;
-- Collaborators → team associates (dedup against the creator row).
INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
SELECT a.id, collab_id::uuid, 'associate', false, a.created_by
FROM paliad.akten a
CROSS JOIN LATERAL unnest(a.collaborators) AS collab_id
WHERE collab_id IS NOT NULL
ON CONFLICT (project_id, user_id) DO NOTHING;
-- ============================================================================
-- 7. Rename child tables to English + rewrite FK columns (akte_id → project_id).
-- Tables: parteien→parties, fristen→deadlines, termine→appointments,
-- dokumente→documents, akten_events→project_events, notizen→notes.
-- Column renames: termin_type→appointment_type, akten_event_id→
-- project_event_id, frist_id→deadline_id, termin_id→appointment_id.
-- ============================================================================
-- Drop dependent RLS policies on children (rebuilt below against project_id).
DROP POLICY IF EXISTS parteien_all ON paliad.parteien;
DROP POLICY IF EXISTS fristen_all ON paliad.fristen;
DROP POLICY IF EXISTS termine_select ON paliad.termine;
DROP POLICY IF EXISTS termine_insert ON paliad.termine;
DROP POLICY IF EXISTS termine_update ON paliad.termine;
DROP POLICY IF EXISTS termine_delete ON paliad.termine;
DROP POLICY IF EXISTS dokumente_all ON paliad.dokumente;
DROP POLICY IF EXISTS akten_events_all ON paliad.akten_events;
DROP POLICY IF EXISTS notizen_all ON paliad.notizen;
DROP POLICY IF EXISTS checklist_instances_select ON paliad.checklist_instances;
DROP POLICY IF EXISTS checklist_instances_insert ON paliad.checklist_instances;
DROP POLICY IF EXISTS checklist_instances_update ON paliad.checklist_instances;
DROP POLICY IF EXISTS checklist_instances_delete ON paliad.checklist_instances;
-- akten itself — drop policies before we drop the table.
DROP POLICY IF EXISTS akten_select ON paliad.akten;
DROP POLICY IF EXISTS akten_insert ON paliad.akten;
DROP POLICY IF EXISTS akten_update ON paliad.akten;
DROP POLICY IF EXISTS akten_delete ON paliad.akten;
-- parteien → parties
ALTER TABLE paliad.parteien DROP CONSTRAINT IF EXISTS parteien_akte_id_fkey;
ALTER TABLE paliad.parteien RENAME TO parties;
ALTER TABLE paliad.parties RENAME COLUMN akte_id TO project_id;
ALTER TABLE paliad.parties
ADD CONSTRAINT parties_project_id_fkey FOREIGN KEY (project_id)
REFERENCES paliad.projects(id) ON DELETE CASCADE;
DROP INDEX IF EXISTS paliad.parteien_akte_idx;
CREATE INDEX parties_project_idx ON paliad.parties (project_id);
-- fristen → deadlines
ALTER TABLE paliad.fristen DROP CONSTRAINT IF EXISTS fristen_akte_id_fkey;
ALTER TABLE paliad.fristen RENAME TO deadlines;
ALTER TABLE paliad.deadlines RENAME COLUMN akte_id TO project_id;
ALTER TABLE paliad.deadlines
ADD CONSTRAINT deadlines_project_id_fkey FOREIGN KEY (project_id)
REFERENCES paliad.projects(id) ON DELETE CASCADE;
DROP INDEX IF EXISTS paliad.fristen_akte_idx;
DROP INDEX IF EXISTS paliad.fristen_status_due_date_idx;
DROP INDEX IF EXISTS paliad.fristen_due_date_idx;
CREATE INDEX deadlines_project_idx ON paliad.deadlines (project_id);
CREATE INDEX deadlines_status_due_date_idx ON paliad.deadlines (status, due_date);
CREATE INDEX deadlines_due_date_idx ON paliad.deadlines (due_date);
-- termine → appointments (akte_id was nullable)
ALTER TABLE paliad.termine DROP CONSTRAINT IF EXISTS termine_akte_id_fkey;
ALTER TABLE paliad.termine RENAME TO appointments;
ALTER TABLE paliad.appointments RENAME COLUMN akte_id TO project_id;
ALTER TABLE paliad.appointments RENAME COLUMN termin_type TO appointment_type;
ALTER TABLE paliad.appointments DROP CONSTRAINT IF EXISTS termine_termin_type_check;
ALTER TABLE paliad.appointments
ADD CONSTRAINT appointments_appointment_type_check
CHECK (appointment_type IS NULL OR appointment_type IN (
'hearing', 'meeting', 'consultation', 'deadline_hearing'
));
ALTER TABLE paliad.appointments
ADD CONSTRAINT appointments_project_id_fkey FOREIGN KEY (project_id)
REFERENCES paliad.projects(id) ON DELETE CASCADE;
DROP INDEX IF EXISTS paliad.termine_akte_idx;
DROP INDEX IF EXISTS paliad.termine_start_at_idx;
CREATE INDEX appointments_project_idx ON paliad.appointments (project_id) WHERE project_id IS NOT NULL;
CREATE INDEX appointments_start_at_idx ON paliad.appointments (start_at);
-- dokumente → documents
ALTER TABLE paliad.dokumente DROP CONSTRAINT IF EXISTS dokumente_akte_id_fkey;
ALTER TABLE paliad.dokumente RENAME TO documents;
ALTER TABLE paliad.documents RENAME COLUMN akte_id TO project_id;
ALTER TABLE paliad.documents
ADD CONSTRAINT documents_project_id_fkey FOREIGN KEY (project_id)
REFERENCES paliad.projects(id) ON DELETE CASCADE;
DROP INDEX IF EXISTS paliad.dokumente_akte_idx;
CREATE INDEX documents_project_idx ON paliad.documents (project_id);
-- akten_events → project_events
ALTER TABLE paliad.akten_events DROP CONSTRAINT IF EXISTS akten_events_akte_id_fkey;
ALTER TABLE paliad.akten_events RENAME TO project_events;
ALTER TABLE paliad.project_events RENAME COLUMN akte_id TO project_id;
ALTER TABLE paliad.project_events
ADD CONSTRAINT project_events_project_id_fkey FOREIGN KEY (project_id)
REFERENCES paliad.projects(id) ON DELETE CASCADE;
DROP INDEX IF EXISTS paliad.akten_events_akte_created_idx;
CREATE INDEX project_events_project_created_idx
ON paliad.project_events (project_id, created_at DESC);
-- notizen → notes. Polymorphic parent stays; all FK columns get English names.
ALTER TABLE paliad.notizen DROP CONSTRAINT IF EXISTS notizen_exactly_one_parent;
ALTER TABLE paliad.notizen DROP CONSTRAINT IF EXISTS notizen_akte_id_fkey;
ALTER TABLE paliad.notizen DROP CONSTRAINT IF EXISTS notizen_frist_id_fkey;
ALTER TABLE paliad.notizen DROP CONSTRAINT IF EXISTS notizen_termin_id_fkey;
ALTER TABLE paliad.notizen DROP CONSTRAINT IF EXISTS notizen_akten_event_id_fkey;
ALTER TABLE paliad.notizen RENAME TO notes;
ALTER TABLE paliad.notes RENAME COLUMN akte_id TO project_id;
ALTER TABLE paliad.notes RENAME COLUMN frist_id TO deadline_id;
ALTER TABLE paliad.notes RENAME COLUMN termin_id TO appointment_id;
ALTER TABLE paliad.notes RENAME COLUMN akten_event_id TO project_event_id;
ALTER TABLE paliad.notes
ADD CONSTRAINT notes_project_id_fkey FOREIGN KEY (project_id)
REFERENCES paliad.projects(id) ON DELETE CASCADE,
ADD CONSTRAINT notes_deadline_id_fkey FOREIGN KEY (deadline_id)
REFERENCES paliad.deadlines(id) ON DELETE CASCADE,
ADD CONSTRAINT notes_appointment_id_fkey FOREIGN KEY (appointment_id)
REFERENCES paliad.appointments(id) ON DELETE CASCADE,
ADD CONSTRAINT notes_project_event_id_fkey FOREIGN KEY (project_event_id)
REFERENCES paliad.project_events(id) ON DELETE CASCADE;
DROP INDEX IF EXISTS paliad.notizen_akte_idx;
DROP INDEX IF EXISTS paliad.notizen_frist_idx;
DROP INDEX IF EXISTS paliad.notizen_termin_idx;
DROP INDEX IF EXISTS paliad.notizen_akten_event_idx;
CREATE INDEX notes_project_idx ON paliad.notes (project_id) WHERE project_id IS NOT NULL;
CREATE INDEX notes_deadline_idx ON paliad.notes (deadline_id) WHERE deadline_id IS NOT NULL;
CREATE INDEX notes_appointment_idx ON paliad.notes (appointment_id) WHERE appointment_id IS NOT NULL;
CREATE INDEX notes_project_event_idx ON paliad.notes (project_event_id) WHERE project_event_id IS NOT NULL;
ALTER TABLE paliad.notes
ADD CONSTRAINT notes_exactly_one_parent CHECK (
(CASE WHEN project_id IS NOT NULL THEN 1 ELSE 0 END) +
(CASE WHEN deadline_id IS NOT NULL THEN 1 ELSE 0 END) +
(CASE WHEN appointment_id IS NOT NULL THEN 1 ELSE 0 END) +
(CASE WHEN project_event_id IS NOT NULL THEN 1 ELSE 0 END) = 1
);
-- checklist_instances — akte_id → project_id (table name already English).
ALTER TABLE paliad.checklist_instances DROP CONSTRAINT IF EXISTS checklist_instances_akte_id_fkey;
ALTER TABLE paliad.checklist_instances RENAME COLUMN akte_id TO project_id;
ALTER TABLE paliad.checklist_instances
ADD CONSTRAINT checklist_instances_project_id_fkey FOREIGN KEY (project_id)
REFERENCES paliad.projects(id) ON DELETE SET NULL;
DROP INDEX IF EXISTS paliad.checklist_instances_akte_idx;
CREATE INDEX checklist_instances_project_idx
ON paliad.checklist_instances (project_id) WHERE project_id IS NOT NULL;
-- ============================================================================
-- 8. Drop paliad.akten (and its visibility helpers).
-- ============================================================================
DROP TABLE paliad.akten;
DROP FUNCTION IF EXISTS paliad.can_see_akte(uuid);
DROP FUNCTION IF EXISTS paliad.notiz_is_visible(uuid, uuid, uuid, uuid);
-- ============================================================================
-- 9. Visibility: can_see_project(id) — team-based only.
-- A user sees a project iff:
-- - admin, or
-- - direct team member (project_teams.project_id = target), or
-- - inherited team member on any ancestor of target (walk UP path).
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.role = 'admin'
)
OR EXISTS (
-- Any team membership (direct or ancestor). `path` always includes
-- the target's own id as the last label, so this handles direct too.
SELECT 1
FROM paliad.projects target
JOIN paliad.project_teams pt
ON pt.user_id = auth.uid()
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = _project_id
);
$$;
COMMENT ON FUNCTION paliad.can_see_project(uuid) IS
'Team-based visibility predicate for paliad.projects. Direct or inherited '
'(ancestor) membership grants access. Admins see all.';
-- Helper: note visibility — dispatches by whichever parent FK is set.
CREATE OR REPLACE FUNCTION paliad.note_is_visible(
_project_id uuid,
_deadline_id uuid,
_appointment_id uuid,
_project_event_id uuid
) RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT CASE
WHEN _project_id IS NOT NULL THEN paliad.can_see_project(_project_id)
WHEN _deadline_id IS NOT NULL THEN paliad.can_see_project(
(SELECT project_id FROM paliad.deadlines WHERE id = _deadline_id))
WHEN _appointment_id IS NOT NULL THEN
CASE
WHEN (SELECT project_id FROM paliad.appointments WHERE id = _appointment_id) IS NULL
THEN (SELECT created_by FROM paliad.appointments WHERE id = _appointment_id) = auth.uid()
ELSE paliad.can_see_project(
(SELECT project_id FROM paliad.appointments WHERE id = _appointment_id))
END
WHEN _project_event_id IS NOT NULL THEN paliad.can_see_project(
(SELECT project_id FROM paliad.project_events WHERE id = _project_event_id))
ELSE false
END;
$$;
-- ============================================================================
-- 10. RLS: enable + policies on the new tables.
-- ============================================================================
ALTER TABLE paliad.projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.project_teams ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.departments ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.department_members ENABLE ROW LEVEL SECURITY;
-- projects
CREATE POLICY projects_select ON paliad.projects
FOR SELECT TO authenticated
USING (paliad.can_see_project(id));
-- INSERT: creating a root project is open to any authenticated user.
-- Creating a child requires visibility on the parent. The service must
-- add the creator to project_teams (role='lead') in the same transaction
-- so the creator can see the row afterwards.
CREATE POLICY projects_insert ON paliad.projects
FOR INSERT TO authenticated
WITH CHECK (
parent_id IS NULL
OR paliad.can_see_project(parent_id)
);
CREATE POLICY projects_update ON paliad.projects
FOR UPDATE TO authenticated
USING (paliad.can_see_project(id))
WITH CHECK (paliad.can_see_project(id));
-- Delete: team visibility + admin/lead role. Cascade walks down the tree.
CREATE POLICY projects_delete ON paliad.projects
FOR DELETE TO authenticated
USING (
paliad.can_see_project(id)
AND EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.role IN ('partner','admin')
)
);
-- project_teams: everyone on the team (including ancestor-inherited members)
-- can list and modify the team. Anyone with visibility on the project can
-- insert themselves as the initial member (used by the creator-add-self
-- flow). Partner/admin can remove.
CREATE POLICY project_teams_select ON paliad.project_teams
FOR SELECT TO authenticated
USING (paliad.can_see_project(project_id));
CREATE POLICY project_teams_insert ON paliad.project_teams
FOR INSERT TO authenticated
WITH CHECK (
-- creator adding self, or already-a-member adding someone else
user_id = auth.uid()
OR paliad.can_see_project(project_id)
);
CREATE POLICY project_teams_update ON paliad.project_teams
FOR UPDATE TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY project_teams_delete ON paliad.project_teams
FOR DELETE TO authenticated
USING (
paliad.can_see_project(project_id)
AND (
user_id = auth.uid() -- remove self
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.role IN ('partner','admin')
)
)
);
-- departments: any authenticated user can read. Only admins write.
CREATE POLICY departments_select ON paliad.departments
FOR SELECT TO authenticated
USING (true);
CREATE POLICY departments_write ON paliad.departments
FOR ALL TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.role = 'admin')
)
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.role = 'admin')
);
CREATE POLICY department_members_select ON paliad.department_members
FOR SELECT TO authenticated
USING (true);
CREATE POLICY department_members_write ON paliad.department_members
FOR ALL TO authenticated
USING (
user_id = auth.uid()
OR EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.role = 'admin')
)
WITH CHECK (
user_id = auth.uid()
OR EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.role = 'admin')
);
-- ============================================================================
-- 11. RLS: rebuild child-table policies against project_id / can_see_project.
-- ============================================================================
CREATE POLICY parties_all ON paliad.parties
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY deadlines_all ON paliad.deadlines
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
-- appointments — project_id nullable; personal (NULL) = creator-only.
CREATE POLICY appointments_select ON paliad.appointments
FOR SELECT TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY appointments_insert ON paliad.appointments
FOR INSERT TO authenticated
WITH CHECK (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY appointments_update ON paliad.appointments
FOR UPDATE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
)
WITH CHECK (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY appointments_delete ON paliad.appointments
FOR DELETE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY documents_all ON paliad.documents
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
ALTER TABLE paliad.project_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_events_all ON paliad.project_events
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
-- notes — polymorphic parent dispatch.
CREATE POLICY notes_all ON paliad.notes
FOR ALL TO authenticated
USING (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id))
WITH CHECK (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id));
-- checklist_instances — mirrors appointments: personal (NULL) = creator-only.
CREATE POLICY checklist_instances_select ON paliad.checklist_instances
FOR SELECT TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY checklist_instances_insert ON paliad.checklist_instances
FOR INSERT TO authenticated
WITH CHECK (
created_by = auth.uid()
AND (project_id IS NULL OR paliad.can_see_project(project_id))
);
CREATE POLICY checklist_instances_update ON paliad.checklist_instances
FOR UPDATE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
)
WITH CHECK (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY checklist_instances_delete ON paliad.checklist_instances
FOR DELETE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);