Files
paliad/internal/db/migrations/120_submission_drafts_project_optional.up.sql
mAi a911a2d0ee feat(submissions): t-paliad-243 — global Schriftsätze drafts without project
Adds an end-to-end project-optional path for Schriftsatz drafts:

- Migration 120 drops NOT NULL on paliad.submission_drafts.project_id
  and rewrites the four RLS policies to gate purely on user_id when
  project_id IS NULL, otherwise on paliad.can_see_project. Down
  refuses to run if project-less rows exist (safer than silent
  data corruption).

- SubmissionDraft.ProjectID becomes *uuid.UUID end-to-end. Service
  layer skips project/parties/deadline lookups when nil and exposes
  DraftPatch.ProjectID for the "Projekt zuweisen" affordance.
  ListAllForUser LEFT JOINs paliad.projects so project-less drafts
  surface in the global index next to project-scoped ones.

- New HTTP surface:
    GET  /submissions/new                 (picker page)
    GET  /submissions/draft/{draft_id}    (editor for any draft)
    GET  /api/submissions/catalog         (catalog without project)
    POST /api/submission-drafts           (project-less or attached)
    GET/PATCH/DELETE /api/submission-drafts/{draft_id}
    POST /api/submission-drafts/{draft_id}/export
  Existing /api/projects/{id}/submissions/... routes remain bit-
  identical so the project-scoped flow keeps working unchanged.

- Frontend: /submissions/new lists the full cross-proceeding catalog
  grouped by proceeding, filterable by text + chip. Each row offers
  "Ohne Projekt" (instant draft) or "Mit Projekt…" (modal picker
  with autocomplete over visible projects). /submissions index gains
  a prominent "Neuer Entwurf" CTA and an empty-state CTA pointing at
  the picker. The editor renders a banner + "Projekt zuweisen"
  action when project_id is null; assigning persists project_id and
  redirects to the project-scoped URL.

Audit + project-event writes detect d.ProjectID == nil; the audit
row's scope flips to 'user' (scope_root = user_id) and the
project_events row is skipped entirely.
2026-05-23 02:19:55 +02:00

71 lines
2.5 KiB
SQL

-- t-paliad-243: drafts may exist without a project attached.
--
-- The global /submissions/new picker lets a lawyer start a Schriftsatz
-- draft straight from the top-level Schriftsätze sidebar, with or
-- without binding it to a project. project_id therefore becomes
-- optional. Existing rows are unaffected; new rows may insert NULL.
--
-- RLS rewrite: every policy splits on (project_id IS NULL):
--
-- project_id IS NOT NULL → gate on paliad.can_see_project (existing
-- inheritance-aware visibility).
-- project_id IS NULL → owner-only (user_id = auth.uid()). A
-- project-less draft is a personal scratch
-- space — never shared, never visible to
-- other team members.
--
-- INSERT enforces the same shape via WITH CHECK: a project-less insert
-- only writes user_id = auth.uid(); a project-scoped insert additionally
-- requires can_see_project.
ALTER TABLE paliad.submission_drafts
ALTER COLUMN project_id DROP NOT NULL;
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
CREATE POLICY submission_drafts_select
ON paliad.submission_drafts FOR SELECT TO authenticated
USING (
(project_id IS NULL AND user_id = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
CREATE POLICY submission_drafts_insert
ON paliad.submission_drafts FOR INSERT TO authenticated
WITH CHECK (
user_id = auth.uid()
AND (
project_id IS NULL
OR paliad.can_see_project(project_id)
)
);
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
CREATE POLICY submission_drafts_update
ON paliad.submission_drafts FOR UPDATE TO authenticated
USING (
user_id = auth.uid()
AND (
project_id IS NULL
OR paliad.can_see_project(project_id)
)
)
WITH CHECK (
user_id = auth.uid()
AND (
project_id IS NULL
OR paliad.can_see_project(project_id)
)
);
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
CREATE POLICY submission_drafts_delete
ON paliad.submission_drafts FOR DELETE TO authenticated
USING (
user_id = auth.uid()
AND (
project_id IS NULL
OR paliad.can_see_project(project_id)
)
);