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.
657 lines
29 KiB
PL/PgSQL
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))
|
|
);
|