Files
paliad/internal/db/migrations/018_projekte_v2.up.sql
m 5fcaa7471b feat(schema): data model v2 — migration 018 (projekte tree + teams + dezernate) [t-paliad-024 phase 1]
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.
2026-04-20 14:34:07 +02:00

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))
);