paliad.projekte — single self-referential tree (types: client/litigation/patent/case/project). Materialised path (text, '.'-joined UUIDs, inclusive of self) + trigger maintenance. ClientMatter numbers (client_number + matter_number, 7-digit CHECK each) and netdocuments_url. paliad.projekt_teams — team membership with inherited flag (writes = false; services annotate inherited rows on read by walking up path). Unique (projekt_id, user_id). paliad.dezernate + paliad.dezernat_mitglieder — structural partner units (orthogonal to project teams; informational office). paliad.users — adds additional_offices text[] for partners across multiple offices. Visibility simplified to team-based only: can_see_projekt() = admin OR direct/ancestor team membership (path @> ancestor). owning_office GONE from every projekt — location is no longer an access gate. Per head (2026-04-20): cases associate with lead partners, not offices. Data migration: akten → projekte (same UUIDs, type='case', parent NULL orphans). Creator → projekt_teams(role='lead'); collaborators → projekt_teams(role='associate'). Orphan akten with no creator + no collaborators become admin-only until reassigned. Child FK rename: akte_id → projekt_id on parteien, fristen, termine, dokumente, akten_events, notizen, checklist_instances. No data move (same UUIDs). akten_events renamed to projekt_events. notizen keeps its polymorphic 4-FK shape. paliad.akten dropped. can_see_akte() and notiz_is_visible(akte) replaced. Down-migration restores v1 schema best-effort: only type='case' projekte come back as akten; non-case tree rows are lost (documented). owning_office backfilled from creator's primary office. Followups (Phase 2): replace AkteService with ProjektService + TeamService + DezernatService, wire creator-auto-lead into Create path, update all child services to use projekt_id. No code changes in this commit — server will fail to build/start until Phase 2 lands.
618 lines
26 KiB
PL/PgSQL
618 lines
26 KiB
PL/PgSQL
-- Data model v2 (t-paliad-024): hierarchical projekte + teams with inheritance.
|
|
--
|
|
-- Replaces paliad.akten with a single self-referential paliad.projekte tree.
|
|
-- 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, rewrites child FKs in place
|
|
-- (same UUIDs as akten.id), drops paliad.akten, replaces can_see_akte() with
|
|
-- can_see_projekt(). All existing akten rows survive as projekte 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.projekte — 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.projekte (
|
|
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.projekte(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 Projekt 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 projekte_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 projekte_path_prefix_idx ON paliad.projekte (path text_pattern_ops);
|
|
CREATE INDEX projekte_parent_idx ON paliad.projekte (parent_id);
|
|
CREATE INDEX projekte_type_status_idx ON paliad.projekte (type, status);
|
|
CREATE INDEX projekte_reference_idx ON paliad.projekte (reference) WHERE reference IS NOT NULL;
|
|
-- ClientMatter search/filter indexes.
|
|
CREATE INDEX projekte_client_number_idx ON paliad.projekte (client_number) WHERE client_number IS NOT NULL;
|
|
CREATE INDEX projekte_matter_number_idx ON paliad.projekte (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.projekte_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.projekte
|
|
WHERE id = NEW.parent_id;
|
|
IF parent_path IS NULL THEN
|
|
RAISE EXCEPTION 'parent projekt % 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 projekte_sync_path_before
|
|
BEFORE INSERT OR UPDATE OF parent_id ON paliad.projekte
|
|
FOR EACH ROW EXECUTE FUNCTION paliad.projekte_sync_path();
|
|
|
|
CREATE OR REPLACE FUNCTION paliad.projekte_rewrite_subtree()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
IF OLD.path IS DISTINCT FROM NEW.path THEN
|
|
UPDATE paliad.projekte
|
|
SET path = NEW.path || substring(path FROM length(OLD.path) + 1)
|
|
WHERE path LIKE OLD.path || '.%';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER projekte_rewrite_subtree_after
|
|
AFTER UPDATE OF path ON paliad.projekte
|
|
FOR EACH ROW EXECUTE FUNCTION paliad.projekte_rewrite_subtree();
|
|
|
|
-- ============================================================================
|
|
-- 4. paliad.projekt_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.projekt_teams (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
projekt_id uuid NOT NULL REFERENCES paliad.projekte(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 (projekt_id, user_id)
|
|
);
|
|
|
|
CREATE INDEX projekt_teams_projekt_idx ON paliad.projekt_teams (projekt_id);
|
|
CREATE INDEX projekt_teams_user_idx ON paliad.projekt_teams (user_id);
|
|
|
|
-- ============================================================================
|
|
-- 5. paliad.dezernate — structural partner units (distinct from project teams).
|
|
-- A user's Dezernat membership is orthogonal to their project-team roles.
|
|
-- ============================================================================
|
|
CREATE TABLE paliad.dezernate (
|
|
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 dezernate_office_idx ON paliad.dezernate (office);
|
|
CREATE INDEX dezernate_lead_idx ON paliad.dezernate (lead_user_id) WHERE lead_user_id IS NOT NULL;
|
|
|
|
CREATE TABLE paliad.dezernat_mitglieder (
|
|
dezernat_id uuid NOT NULL REFERENCES paliad.dezernate(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 (dezernat_id, user_id)
|
|
);
|
|
|
|
CREATE INDEX dezernat_mitglieder_user_idx ON paliad.dezernat_mitglieder (user_id);
|
|
|
|
-- ============================================================================
|
|
-- 6. Data migration: akten → projekte (same UUIDs), collaborators+created_by
|
|
-- → projekt_teams.
|
|
-- All existing akten become type='case' orphans (parent_id NULL). Admins
|
|
-- reparent under real clients via the new UI.
|
|
-- ============================================================================
|
|
INSERT INTO paliad.projekte (
|
|
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.projekt_teams (projekt_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 (projekt_id, user_id) DO NOTHING;
|
|
|
|
-- Collaborators → team associates (dedup against the creator row).
|
|
INSERT INTO paliad.projekt_teams (projekt_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 (projekt_id, user_id) DO NOTHING;
|
|
|
|
-- ============================================================================
|
|
-- 7. Child-table FK rename: akte_id → projekt_id. Same UUIDs, so no data
|
|
-- move; just DDL churn. Drop old policies that reference akte_id first
|
|
-- so ALTER can proceed.
|
|
-- ============================================================================
|
|
|
|
-- Drop dependent RLS policies on children (rebuilt below against projekt_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
|
|
ALTER TABLE paliad.parteien
|
|
ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE;
|
|
UPDATE paliad.parteien SET projekt_id = akte_id;
|
|
ALTER TABLE paliad.parteien ALTER COLUMN projekt_id SET NOT NULL;
|
|
ALTER TABLE paliad.parteien DROP COLUMN akte_id;
|
|
DROP INDEX IF EXISTS paliad.parteien_akte_idx;
|
|
CREATE INDEX parteien_projekt_idx ON paliad.parteien (projekt_id);
|
|
|
|
-- fristen
|
|
ALTER TABLE paliad.fristen
|
|
ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE;
|
|
UPDATE paliad.fristen SET projekt_id = akte_id;
|
|
ALTER TABLE paliad.fristen ALTER COLUMN projekt_id SET NOT NULL;
|
|
ALTER TABLE paliad.fristen DROP COLUMN akte_id;
|
|
DROP INDEX IF EXISTS paliad.fristen_akte_idx;
|
|
CREATE INDEX fristen_projekt_idx ON paliad.fristen (projekt_id);
|
|
|
|
-- termine (akte_id was nullable)
|
|
ALTER TABLE paliad.termine
|
|
ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE;
|
|
UPDATE paliad.termine SET projekt_id = akte_id WHERE akte_id IS NOT NULL;
|
|
ALTER TABLE paliad.termine DROP COLUMN akte_id;
|
|
DROP INDEX IF EXISTS paliad.termine_akte_idx;
|
|
CREATE INDEX termine_projekt_idx ON paliad.termine (projekt_id) WHERE projekt_id IS NOT NULL;
|
|
|
|
-- dokumente
|
|
ALTER TABLE paliad.dokumente
|
|
ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE;
|
|
UPDATE paliad.dokumente SET projekt_id = akte_id;
|
|
ALTER TABLE paliad.dokumente ALTER COLUMN projekt_id SET NOT NULL;
|
|
ALTER TABLE paliad.dokumente DROP COLUMN akte_id;
|
|
DROP INDEX IF EXISTS paliad.dokumente_akte_idx;
|
|
CREATE INDEX dokumente_projekt_idx ON paliad.dokumente (projekt_id);
|
|
|
|
-- akten_events — rename column, keep table name (per design §10, the table
|
|
-- name stays 'akten_events' as a historical artefact; Go struct continues
|
|
-- to be AkteEvent / ProjektEvent). Hmm — task asks to rename table too.
|
|
-- Comply: rename to projekt_events for consistency.
|
|
ALTER TABLE paliad.akten_events
|
|
ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE;
|
|
UPDATE paliad.akten_events SET projekt_id = akte_id;
|
|
ALTER TABLE paliad.akten_events ALTER COLUMN projekt_id SET NOT NULL;
|
|
ALTER TABLE paliad.akten_events DROP COLUMN akte_id;
|
|
DROP INDEX IF EXISTS paliad.akten_events_akte_created_idx;
|
|
ALTER TABLE paliad.akten_events RENAME TO projekt_events;
|
|
CREATE INDEX projekt_events_projekt_created_idx ON paliad.projekt_events (projekt_id, created_at DESC);
|
|
|
|
-- notizen — polymorphic stays, akte_id → projekt_id (keep other FKs).
|
|
-- Also: akten_event_id FK's target table has been renamed.
|
|
ALTER TABLE paliad.notizen
|
|
DROP CONSTRAINT IF EXISTS notizen_exactly_one_parent;
|
|
ALTER TABLE paliad.notizen
|
|
ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE;
|
|
UPDATE paliad.notizen SET projekt_id = akte_id WHERE akte_id IS NOT NULL;
|
|
ALTER TABLE paliad.notizen DROP COLUMN akte_id;
|
|
DROP INDEX IF EXISTS paliad.notizen_akte_idx;
|
|
CREATE INDEX notizen_projekt_idx ON paliad.notizen (projekt_id) WHERE projekt_id IS NOT NULL;
|
|
ALTER TABLE paliad.notizen
|
|
ADD CONSTRAINT notizen_exactly_one_parent CHECK (
|
|
(CASE WHEN projekt_id IS NOT NULL THEN 1 ELSE 0 END) +
|
|
(CASE WHEN frist_id IS NOT NULL THEN 1 ELSE 0 END) +
|
|
(CASE WHEN termin_id IS NOT NULL THEN 1 ELSE 0 END) +
|
|
(CASE WHEN akten_event_id IS NOT NULL THEN 1 ELSE 0 END) = 1
|
|
);
|
|
|
|
-- checklist_instances
|
|
ALTER TABLE paliad.checklist_instances
|
|
ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE SET NULL;
|
|
UPDATE paliad.checklist_instances SET projekt_id = akte_id WHERE akte_id IS NOT NULL;
|
|
ALTER TABLE paliad.checklist_instances DROP COLUMN akte_id;
|
|
DROP INDEX IF EXISTS paliad.checklist_instances_akte_idx;
|
|
CREATE INDEX checklist_instances_projekt_idx
|
|
ON paliad.checklist_instances (projekt_id) WHERE projekt_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_projekt(id) — team-based only.
|
|
-- A user sees a projekt iff:
|
|
-- - admin, or
|
|
-- - direct team member (projekt_teams.projekt_id = target), or
|
|
-- - inherited team member on any ancestor of target (walk UP path).
|
|
-- ============================================================================
|
|
CREATE OR REPLACE FUNCTION paliad.can_see_projekt(_projekt_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.projekte target
|
|
JOIN paliad.projekt_teams pt
|
|
ON pt.user_id = auth.uid()
|
|
AND pt.projekt_id = ANY(string_to_array(target.path, '.')::uuid[])
|
|
WHERE target.id = _projekt_id
|
|
);
|
|
$$;
|
|
|
|
COMMENT ON FUNCTION paliad.can_see_projekt(uuid) IS
|
|
'Team-based visibility predicate for paliad.projekte. Direct or inherited '
|
|
'(ancestor) membership grants access. Admins see all.';
|
|
|
|
-- Helper: notiz visibility — dispatches by whichever parent FK is set.
|
|
CREATE OR REPLACE FUNCTION paliad.notiz_is_visible(
|
|
_projekt_id uuid,
|
|
_frist_id uuid,
|
|
_termin_id uuid,
|
|
_projekt_event_id uuid
|
|
) RETURNS boolean
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = paliad, public
|
|
AS $$
|
|
SELECT CASE
|
|
WHEN _projekt_id IS NOT NULL THEN paliad.can_see_projekt(_projekt_id)
|
|
WHEN _frist_id IS NOT NULL THEN paliad.can_see_projekt(
|
|
(SELECT projekt_id FROM paliad.fristen WHERE id = _frist_id))
|
|
WHEN _termin_id IS NOT NULL THEN
|
|
CASE
|
|
WHEN (SELECT projekt_id FROM paliad.termine WHERE id = _termin_id) IS NULL
|
|
THEN (SELECT created_by FROM paliad.termine WHERE id = _termin_id) = auth.uid()
|
|
ELSE paliad.can_see_projekt(
|
|
(SELECT projekt_id FROM paliad.termine WHERE id = _termin_id))
|
|
END
|
|
WHEN _projekt_event_id IS NOT NULL THEN paliad.can_see_projekt(
|
|
(SELECT projekt_id FROM paliad.projekt_events WHERE id = _projekt_event_id))
|
|
ELSE false
|
|
END;
|
|
$$;
|
|
|
|
-- ============================================================================
|
|
-- 10. RLS: enable + policies on the new tables.
|
|
-- ============================================================================
|
|
ALTER TABLE paliad.projekte ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE paliad.projekt_teams ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE paliad.dezernate ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE paliad.dezernat_mitglieder ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- projekte
|
|
CREATE POLICY projekte_select ON paliad.projekte
|
|
FOR SELECT TO authenticated
|
|
USING (paliad.can_see_projekt(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 projekt_teams (role='lead') in the same transaction
|
|
-- so the creator can see the row afterwards.
|
|
CREATE POLICY projekte_insert ON paliad.projekte
|
|
FOR INSERT TO authenticated
|
|
WITH CHECK (
|
|
parent_id IS NULL
|
|
OR paliad.can_see_projekt(parent_id)
|
|
);
|
|
|
|
CREATE POLICY projekte_update ON paliad.projekte
|
|
FOR UPDATE TO authenticated
|
|
USING (paliad.can_see_projekt(id))
|
|
WITH CHECK (paliad.can_see_projekt(id));
|
|
|
|
-- Delete: team visibility + admin/lead role. Cascade walks down the tree.
|
|
CREATE POLICY projekte_delete ON paliad.projekte
|
|
FOR DELETE TO authenticated
|
|
USING (
|
|
paliad.can_see_projekt(id)
|
|
AND EXISTS (
|
|
SELECT 1 FROM paliad.users u
|
|
WHERE u.id = auth.uid() AND u.role IN ('partner','admin')
|
|
)
|
|
);
|
|
|
|
-- projekt_teams: everyone on the team (including ancestor-inherited members)
|
|
-- can list and modify the team. Anyone with visibility on the projekt can
|
|
-- insert themselves as the initial member (used by the creator-add-self
|
|
-- flow). Partner/admin can remove.
|
|
CREATE POLICY projekt_teams_select ON paliad.projekt_teams
|
|
FOR SELECT TO authenticated
|
|
USING (paliad.can_see_projekt(projekt_id));
|
|
|
|
CREATE POLICY projekt_teams_insert ON paliad.projekt_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_projekt(projekt_id)
|
|
);
|
|
|
|
CREATE POLICY projekt_teams_update ON paliad.projekt_teams
|
|
FOR UPDATE TO authenticated
|
|
USING (paliad.can_see_projekt(projekt_id))
|
|
WITH CHECK (paliad.can_see_projekt(projekt_id));
|
|
|
|
CREATE POLICY projekt_teams_delete ON paliad.projekt_teams
|
|
FOR DELETE TO authenticated
|
|
USING (
|
|
paliad.can_see_projekt(projekt_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')
|
|
)
|
|
)
|
|
);
|
|
|
|
-- dezernate: any authenticated user can read. Only admins write.
|
|
CREATE POLICY dezernate_select ON paliad.dezernate
|
|
FOR SELECT TO authenticated
|
|
USING (true);
|
|
|
|
CREATE POLICY dezernate_write ON paliad.dezernate
|
|
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 dezernat_mitglieder_select ON paliad.dezernat_mitglieder
|
|
FOR SELECT TO authenticated
|
|
USING (true);
|
|
|
|
CREATE POLICY dezernat_mitglieder_write ON paliad.dezernat_mitglieder
|
|
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 projekt_id / can_see_projekt.
|
|
-- ============================================================================
|
|
CREATE POLICY parteien_all ON paliad.parteien
|
|
FOR ALL TO authenticated
|
|
USING (paliad.can_see_projekt(projekt_id))
|
|
WITH CHECK (paliad.can_see_projekt(projekt_id));
|
|
|
|
CREATE POLICY fristen_all ON paliad.fristen
|
|
FOR ALL TO authenticated
|
|
USING (paliad.can_see_projekt(projekt_id))
|
|
WITH CHECK (paliad.can_see_projekt(projekt_id));
|
|
|
|
-- termine — projekt_id nullable; personal (NULL) = creator-only.
|
|
CREATE POLICY termine_select ON paliad.termine
|
|
FOR SELECT TO authenticated
|
|
USING (
|
|
(projekt_id IS NULL AND created_by = auth.uid())
|
|
OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id))
|
|
);
|
|
CREATE POLICY termine_insert ON paliad.termine
|
|
FOR INSERT TO authenticated
|
|
WITH CHECK (
|
|
(projekt_id IS NULL AND created_by = auth.uid())
|
|
OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id))
|
|
);
|
|
CREATE POLICY termine_update ON paliad.termine
|
|
FOR UPDATE TO authenticated
|
|
USING (
|
|
(projekt_id IS NULL AND created_by = auth.uid())
|
|
OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id))
|
|
)
|
|
WITH CHECK (
|
|
(projekt_id IS NULL AND created_by = auth.uid())
|
|
OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id))
|
|
);
|
|
CREATE POLICY termine_delete ON paliad.termine
|
|
FOR DELETE TO authenticated
|
|
USING (
|
|
(projekt_id IS NULL AND created_by = auth.uid())
|
|
OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id))
|
|
);
|
|
|
|
CREATE POLICY dokumente_all ON paliad.dokumente
|
|
FOR ALL TO authenticated
|
|
USING (paliad.can_see_projekt(projekt_id))
|
|
WITH CHECK (paliad.can_see_projekt(projekt_id));
|
|
|
|
ALTER TABLE paliad.projekt_events ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY projekt_events_all ON paliad.projekt_events
|
|
FOR ALL TO authenticated
|
|
USING (paliad.can_see_projekt(projekt_id))
|
|
WITH CHECK (paliad.can_see_projekt(projekt_id));
|
|
|
|
-- notizen — polymorphic parent dispatch.
|
|
CREATE POLICY notizen_all ON paliad.notizen
|
|
FOR ALL TO authenticated
|
|
USING (paliad.notiz_is_visible(projekt_id, frist_id, termin_id, akten_event_id))
|
|
WITH CHECK (paliad.notiz_is_visible(projekt_id, frist_id, termin_id, akten_event_id));
|
|
|
|
-- checklist_instances — mirrors termine: personal (NULL) = creator-only.
|
|
CREATE POLICY checklist_instances_select ON paliad.checklist_instances
|
|
FOR SELECT TO authenticated
|
|
USING (
|
|
(projekt_id IS NULL AND created_by = auth.uid())
|
|
OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id))
|
|
);
|
|
CREATE POLICY checklist_instances_insert ON paliad.checklist_instances
|
|
FOR INSERT TO authenticated
|
|
WITH CHECK (
|
|
created_by = auth.uid()
|
|
AND (projekt_id IS NULL OR paliad.can_see_projekt(projekt_id))
|
|
);
|
|
CREATE POLICY checklist_instances_update ON paliad.checklist_instances
|
|
FOR UPDATE TO authenticated
|
|
USING (
|
|
(projekt_id IS NULL AND created_by = auth.uid())
|
|
OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id))
|
|
)
|
|
WITH CHECK (
|
|
(projekt_id IS NULL AND created_by = auth.uid())
|
|
OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id))
|
|
);
|
|
CREATE POLICY checklist_instances_delete ON paliad.checklist_instances
|
|
FOR DELETE TO authenticated
|
|
USING (
|
|
(projekt_id IS NULL AND created_by = auth.uid())
|
|
OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id))
|
|
);
|