Files
alpenwerk-hr/lib/entwuerfe.ts
Maximilian Stubhan 99e4357c02
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m18s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m45s
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>
2026-09-10 17:43:43 +02:00

106 lines
4.7 KiB
TypeScript

import { sql } from "kysely";
import { zeitstempel } from "./db/json";
import { istHinzugewaehlt } from "./kollegen";
import type { OrgEb } from "./org";
// Die angefangenen Neueinstellungen auf der Übersicht.
//
// Bis September 2026 sah jede Person nur die eigenen. Seit die Auswahl in der
// Glocke auch hierfür gilt (lib/kollegen.ts), kommen die Entwürfe der
// hinzugewählten Kolleg:innen dazu — damit eine angefangene Einstellung nicht
// liegen bleibt, weil die eine Person im Urlaub ist und die andere nicht weiss,
// dass es sie gibt.
//
// **Nur ansehen.** Fortsetzen und Löschen bleiben bei der Person, von der der
// Entwurf stammt; die Regeln hire_drafts_update und hire_drafts_delete lassen
// nichts anderes zu. Ein Entwurf ist unfertige Arbeit ohne Sperre und ohne
// Historie — zwei Personen, die abwechselnd hineinschreiben, überschreiben
// einander lautlos.
export type EntwurfZeile = {
id: string;
step: number;
payload: Record<string, unknown>;
updated_at: string;
created_by: string | null;
author_name: string | null;
author_email: string | null;
/** Wahr, solange jemand **anderes** den Entwurf offen hat. Aus der Datenbank, nicht gerechnet. */
gesperrt: boolean;
sperrer_name: string | null;
sperrer_email: string | null;
};
export type Entwurf = {
id: string;
step: number;
payload: Record<string, unknown>;
updated_at: string;
/** Ob der Entwurf von der angemeldeten Person stammt — nur dann darf sie ihn anfassen. */
vonMir: boolean;
/** Wer ihn angefangen hat — steht an jeder Zeile, auch an den eigenen. */
autor: string;
/**
* Wer ihn gerade offen hat, wenn es jemand anderes ist — sonst leer.
*
* Nicht dasselbe wie „nicht meiner": ein fremder Entwurf ist bearbeitbar,
* ein gesperrter nicht. Die Karte hängt daran, ob „Fortsetzen" wählbar ist.
*/
gesperrtVon: string | null;
};
/**
* Die Entwürfe als *Teilabfrage* — zum Einhängen in die eine Abfrage, die die
* Übersicht ohnehin stellt (lib/db/json.ts).
*
* Die Bedingung wiederholt, was `hire_drafts_select` ohnehin durchlässt. Das
* ist Absicht: die Regel in der Datenbank ist die Grenze, aber sie steht in
* einer Migration und lässt sich von hier aus nicht lesen. Steht sie auch in
* der Abfrage, sagt der Quelltext, was die Liste zeigt — und ein Test kann es
* festhalten. Fällt eine der beiden aus, bleibt die andere.
*
* `created_by` kann leer sein (die Person wurde gelöscht). Solche Zeilen zeigt
* niemand: anders als bei einer Notiz steht in einem Entwurf nichts, was ohne
* die Person, die ihn angefangen hat, noch weiterginge.
*/
export function entwuerfeAbfrage(eb: OrgEb, userId: string) {
return eb
.selectFrom("hire_drafts as d")
.leftJoin("profiles as p", "p.id", "d.created_by")
.leftJoin("profiles as sp", "sp.id", "d.locked_by")
.select(["d.id", "d.step", "d.payload", "d.created_by"])
.select(["p.full_name as author_name", "p.email as author_email"])
.select(["sp.full_name as sperrer_name", "sp.email as sperrer_email"])
.select((x) => zeitstempel(x.ref("d.updated_at")).as("updated_at"))
// Ob die Sperre noch gilt, rechnet die Datenbank — mit **ihrer** Uhr und
// derselben Frist, die auch die Schreibregeln anwenden. Hier gerechnet
// wäre es die Uhr des Servers, der gerade rendert, und eine zweite
// Fassung der Frist.
.select(sql<boolean>`not app_entwurf_frei("d"."locked_by", "d"."locked_at")`.as("gesperrt"))
.where((e) => e.or([e("d.created_by", "=", userId), istHinzugewaehlt(userId, "d.created_by")]))
// Die eigenen zuerst. Vor der Freigabe standen dort nur sie; wer seinen
// halbfertigen Entwurf sucht, soll ihn nicht zwischen fremden suchen.
.orderBy(sql`case when "d"."created_by" = ${userId} then 0 else 1 end`)
.orderBy("d.updated_at", "desc");
}
/** Der reine Teil: aus den Zeilen die Entwürfe mit lesbarem Verfasser. */
export function baueEntwuerfe(rows: EntwurfZeile[], userId: string | null): Entwurf[] {
return rows.map((row) => {
return {
id: row.id,
step: row.step,
payload: row.payload,
updated_at: row.updated_at,
vonMir: userId !== null && row.created_by === userId,
// Ohne Namen die E-Mail. „Jemand" als letzter Ausweg: dass der Entwurf
// belegt ist, muss auch dann herauskommen, wenn nicht zu sagen ist,
// von wem — sonst wirkte „Fortsetzen" grundlos abgeschaltet.
gesperrtVon: row.gesperrt ? row.sperrer_name?.trim() || row.sperrer_email || "jemandem" : null,
// Ohne Namen die E-Mail — sonst stünde an einem Entwurf nichts ausser
// dem Hinweis, dass er von jemandem ist.
autor: row.author_name?.trim() || row.author_email || "Unbekannt",
};
});
}