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:
@@ -25,6 +25,10 @@ export type EntwurfZeile = {
|
||||
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 = {
|
||||
@@ -36,6 +40,13 @@ export type Entwurf = {
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,9 +67,16 @@ 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.
|
||||
@@ -75,6 +93,10 @@ export function baueEntwuerfe(rows: EntwurfZeile[], userId: string | null): Entw
|
||||
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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql } from "kysely";
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "./db/json";
|
||||
import { baueEntwuerfe, entwuerfeAbfrage, type Entwurf, type EntwurfZeile } from "./entwuerfe";
|
||||
import { todayIso } from "./format";
|
||||
import { baueOffeneNotizen, offeneNotizenAbfrage, type NotizZeile, type OpenNote } from "./notes";
|
||||
import { buildOrgMaps, orgMapsAbfragen, type Location } from "./org";
|
||||
@@ -26,7 +27,7 @@ export type ShellData = {
|
||||
profile: { full_name: string | null; email: string | null; role: string | null; is_active: boolean | null };
|
||||
openPositions: OpenPositionResolved[];
|
||||
locations: Location[];
|
||||
drafts: { id: string; step: number; payload: Record<string, unknown>; updated_at: string }[];
|
||||
drafts: Entwurf[];
|
||||
openNotes: OpenNote[];
|
||||
/**
|
||||
* Die HR-Kolleg:innen für die Sichtbarkeitseinstellung der Glocke.
|
||||
@@ -90,20 +91,16 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebn
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom("colleague_subscriptions").select("author_user_id").where("user_id", "=", userId)
|
||||
).as("abos"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload"])
|
||||
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
||||
// Hier bewusst **nur** die eigenen, obwohl die Übersicht seit
|
||||
// September 2026 auch fremde zeigt: diese Liste füttert den
|
||||
// Einstellungsassistenten (HireWizardProvider), und was er darin
|
||||
// findet, lässt sich fortsetzen. Ein fremder Entwurf gehört nicht
|
||||
// hinein — die Regel hire_drafts_update wiese das Speichern ab,
|
||||
// und die Person hätte den Assistenten umsonst durchlaufen.
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("updated_at", "desc")
|
||||
).as("drafts"),
|
||||
// Dieselbe Liste wie auf der Übersicht, aus derselben Abfrage: die
|
||||
// eigenen und die der hinzugewählten Kolleg:innen.
|
||||
//
|
||||
// Sie füttert den Einstellungsassistenten (HireWizardProvider), und was
|
||||
// er darin findet, lässt sich fortsetzen. Bis zur Sperre standen hier
|
||||
// nur die eigenen — ein fremder Entwurf wäre umsonst durchlaufen
|
||||
// worden, weil das Speichern an der Regel gescheitert wäre. Seit es
|
||||
// die Sperre gibt (20260910160000), ist Fortsetzen erlaubt, solange
|
||||
// niemand anderes drin ist.
|
||||
jsonArrayFrom(entwuerfeAbfrage(eb, userId)).as("drafts"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
@@ -128,7 +125,7 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebn
|
||||
// offen sind — das lässt sich nicht in die erste ziehen.
|
||||
openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf),
|
||||
locations: gelesen.locations as Location[],
|
||||
drafts: gelesen.drafts as ShellData["drafts"],
|
||||
drafts: baueEntwuerfe(gelesen.drafts as EntwurfZeile[], userId),
|
||||
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[], userId),
|
||||
kollegen: baueKollegen(
|
||||
gelesen.hrLeute as { id: string; full_name: string | null; email: string }[],
|
||||
|
||||
24
lib/types.ts
24
lib/types.ts
@@ -338,9 +338,29 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>;
|
||||
};
|
||||
// locked_by/locked_at: wer den Entwurf gerade offen hat. Die Regeln
|
||||
// hire_drafts_update und _delete lassen nur den Halter schreiben, und
|
||||
// die Sperre läuft ab (Migration 20260910160000) — sonst nähme ein
|
||||
// geschlossener Reiter den Entwurf für immer mit.
|
||||
hire_drafts: {
|
||||
Row: { id: string; created_by: string | null; step: number; payload: Record<string, unknown>; updated_at: string };
|
||||
Insert: { id?: string; created_by?: string | null; step?: number; payload: Record<string, unknown>; updated_at?: string };
|
||||
Row: {
|
||||
id: string;
|
||||
created_by: string | null;
|
||||
step: number;
|
||||
payload: Record<string, unknown>;
|
||||
updated_at: string;
|
||||
locked_by: string | null;
|
||||
locked_at: string | null;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
created_by?: string | null;
|
||||
step?: number;
|
||||
payload: Record<string, unknown>;
|
||||
updated_at?: string;
|
||||
locked_by?: string | null;
|
||||
locked_at?: string | null;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["hire_drafts"]["Insert"]>;
|
||||
};
|
||||
saved_reports: {
|
||||
|
||||
Reference in New Issue
Block a user