-- Fremde Entwürfe fortsetzen — einer nach dem anderen. -- -- Seit 20260910120000 sieht man die Entwürfe der hinzugewählten -- Kolleg:innen. Sehen genügt nicht: wer im Urlaub vertreten wird, soll die -- angefangene Einstellung zu Ende bringen können. Also darf ab jetzt auch -- geschrieben werden — aber nicht von zweien gleichzeitig. -- -- ═══ Warum überhaupt eine Sperre ═══════════════════════════════════ -- -- Ein Entwurf ist ein einziges JSONB-Feld. Wer speichert, schreibt den -- **ganzen** Stand, nicht das geänderte Feld. Zwei Personen, die den -- Assistenten offen haben, überschreiben einander also vollständig, und die -- Zweite merkt nichts davon: sie sieht ihren eigenen Stand. Genau das war -- der Grund, das Schreiben bisher beim Eigentümer zu lassen. -- -- Die Sperre ist die Bedingung dafür, das aufzugeben. -- -- ═══ Warum sie ablaufen muss ═══════════════════════════════════════ -- -- Freigegeben wird beim Schliessen des Assistenten. Ein zugeklappter Laptop, -- ein geschlossener Reiter, ein Absturz — dann kommt kein Schliessen mehr. -- Ohne Ablauf bliebe der Entwurf für immer gesperrt, und niemand käme je -- wieder heran; das wäre schlimmer als das Problem, das die Sperre löst. -- -- Die Frist steht in einer Funktion und nicht als Zahl an drei Stellen: -- die Regeln hier und die Abfrage in lib/entwuerfe.ts fragen dieselbe. -- Der Assistent frischt die Sperre auf, solange er offen ist, damit ein -- langes Ausfüllen sie nicht verliert. alter table hire_drafts add column if not exists locked_by uuid references app_users(id) on delete set null, add column if not exists locked_at timestamptz; comment on column hire_drafts.locked_by is 'Wer den Entwurf gerade offen hat. Zusammen mit locked_at und app_entwurf_sperrfrist() entscheidet es, wer schreiben darf.'; comment on column hire_drafts.locked_at is 'Wann die Sperre gesetzt oder zuletzt aufgefrischt wurde. Älter als die Frist heisst: der Entwurf ist wieder frei.'; create or replace function app_entwurf_sperrfrist() returns interval language sql immutable as $$ select interval '15 minutes' $$; comment on function app_entwurf_sperrfrist() is 'Wie lange eine Entwurfssperre ohne Auffrischen gilt. Eine Quelle für die Regeln und für die Anzeige.'; -- Ist der Entwurf für mich beschreibbar? Frei, meiner, oder abgelaufen. create or replace function app_entwurf_frei(sperre uuid, seit timestamptz) returns boolean language sql stable as $$ select sperre is null or sperre = app_current_user_id() or seit is null or seit < now() - app_entwurf_sperrfrist() $$; -- Darf ich diesen Entwurf überhaupt anfassen? Meiner, oder von jemandem, -- den ich hinzugewählt habe. Dieselbe Frage wie beim Lesen. create or replace function app_entwurf_zugriff(besitzer uuid) returns boolean language sql stable as $$ select besitzer = app_current_user_id() or exists ( select 1 from colleague_subscriptions s where s.user_id = app_current_user_id() and s.author_user_id = besitzer) $$; -- ═══ Die Regeln ═══════════════════════════════════════════════════ -- -- Lesen bleibt, wie es war. Ändern und Löschen reichen jetzt so weit wie -- das Lesen — **und** verlangen zusätzlich die Sperre. -- -- Löschen muss mitgehen, obwohl das nach mehr klingt, als gewollt war: der -- Assistent löscht den Entwurf, sobald die Person angelegt ist -- (HireWizard.tsx). Ohne Löschrecht liefe die Einstellung durch und der -- Entwurf bliebe als Karteileiche stehen. -- -- Anlegen bleibt beim eigenen Namen: einen Entwurf auf fremden Namen zu -- eröffnen, ergibt keinen Fall. drop policy if exists "hire_drafts_select" on hire_drafts; create policy "hire_drafts_select" on hire_drafts for select using (is_hr_user() and app_entwurf_zugriff(created_by)); drop policy if exists "hire_drafts_update" on hire_drafts; create policy "hire_drafts_update" on hire_drafts for update using (is_hr_user() and app_entwurf_zugriff(created_by) and app_entwurf_frei(locked_by, locked_at)) with check (is_hr_user() and app_entwurf_zugriff(created_by)); drop policy if exists "hire_drafts_delete" on hire_drafts; create policy "hire_drafts_delete" on hire_drafts for delete using (is_hr_user() and app_entwurf_zugriff(created_by) and app_entwurf_frei(locked_by, locked_at)); -- ═══ Was das der Anwendung abverlangt ══════════════════════════════ -- -- Eine Regel weist ein UPDATE nicht mit einem Fehler ab, sie lässt es ins -- Leere laufen. Ein Speichern gegen eine fremde Sperre trifft also keine -- Zeile und meldet nichts. actions/hireDrafts.ts liest deshalb die Zahl der -- betroffenen Zeilen — sonst stünde „Entwurf gespeichert" über einer -- Änderung, die niemand hat. -- ═══ Gegenprobe ═══════════════════════════════════════════════════ do $$ declare anzahl int; begin if not exists ( select 1 from information_schema.columns where table_name = 'hire_drafts' and column_name in ('locked_by', 'locked_at') having count(*) = 2 ) then raise exception 'hire_drafts fehlen die Sperrspalten.'; end if; -- Ohne Frist wäre die Sperre endgültig: ein abgestürzter Reiter nähme den -- Entwurf für immer mit. if app_entwurf_sperrfrist() <= interval '0' then raise exception 'Die Sperrfrist ist nicht positiv — eine Sperre ohne Ablauf.'; end if; select count(*) into anzahl from pg_policies where tablename = 'hire_drafts'; if anzahl <> 4 then raise exception 'hire_drafts hat % Regeln, erwartet werden 4.', anzahl; end if; -- Die alte Sammelregel darf nicht zurückkommen: Policies addieren sich, -- eine mit `for all` hebelte die Sperre aus. if exists (select 1 from pg_policies where tablename = 'hire_drafts' and cmd = 'ALL') then raise exception 'Auf hire_drafts steht wieder eine Sammelregel — sie umginge die Sperre.'; end if; -- Schreiben muss die Sperre prüfen, Lesen darf es nicht: wer zusieht, -- sperrt nichts, und ein Entwurf, den man nicht mehr sähe, sobald ihn -- jemand offen hat, wäre aus der Liste verschwunden. if exists ( select 1 from pg_policies where tablename = 'hire_drafts' and cmd in ('UPDATE', 'DELETE') and coalesce(qual, '') not like '%app_entwurf_frei%' ) then raise exception 'Eine Schreibregel auf hire_drafts prüft die Sperre nicht.'; end if; if exists ( select 1 from pg_policies where tablename = 'hire_drafts' and cmd = 'SELECT' and coalesce(qual, '') like '%app_entwurf_frei%' ) then raise exception 'Die Leseregel prüft die Sperre — ein gesperrter Entwurf verschwände aus der Liste.'; end if; -- Anlegen bleibt eigentuemergebunden. if exists ( select 1 from pg_policies where tablename = 'hire_drafts' and cmd = 'INSERT' and coalesce(with_check, '') like '%colleague_subscriptions%' ) then raise exception 'Die Anlegeregel kennt die Abos — ein Entwurf auf fremden Namen waere moeglich.'; end if; end $$;