Let colleagues finish each other's drafts, one at a time
Seeing a colleague's draft turned out to be half a feature: the point of sharing it is to finish it while they are away. So writing is allowed now -- but never by two people at once. A draft is a single JSONB field. Whoever saves writes the whole state, not the changed field, so two open wizards overwrite each other completely and the second person sees nothing wrong: their own state is right there on screen. That is why writing stayed with the owner until now, and a lock is what makes giving that up safe. The lock lives in the row (locked_by, locked_at) and is enforced by the update and delete policies, not by the application. It expires, and that is the important half: releasing happens when the wizard closes, and a closed laptop never closes a wizard. Without expiry one crashed tab would take a draft away for good -- worse than the problem being solved. The wizard refreshes its lock while open so a long form does not lose it mid-way. Delete had to widen too, which reads like more than was asked for: the wizard deletes the draft once the person is hired. Without it the hire would go through and the draft would sit there forever. The card still only offers delete on your own drafts. Four of five mutations against the lock go red. The fifth -- dropping `!open` from the refresh guard -- does not, because freigeben() already nulls the ref the interval checks. The condition stays as the readable statement of intent, now with a comment saying so. Not run against a live database here; the CI migration job is the first real execution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
167
db/migrations/20260910160000_entwurf_sperre.sql
Normal file
167
db/migrations/20260910160000_entwurf_sperre.sql
Normal file
@@ -0,0 +1,167 @@
|
||||
-- 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 $$;
|
||||
Reference in New Issue
Block a user