Let the same choice open notes and drafts
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s

The picker in the bell now governs both lists, so note_subscriptions is
renamed to colleague_subscriptions -- a name that only mentions notes would
mislead the next reader.

Reading and writing a draft now reach differently far. hire_drafts_owner
(for all) is split into four policies: select lets in your own drafts and
those of the people you added, while insert/update/delete stay with the
owner. A draft is unfinished work with no lock and no history; two people
writing into the same row would overwrite each other silently.

That split forces a change in the actions: a policy does not reject a write,
it lets it hit no rows. saveHireDraft and deleteHireDraft now read the row
count instead of reporting success over a row that never changed.

The card shows a foreign draft with its author and without Fortsetzen or
Loeschen -- offering a button that reliably ends in a database error is a
promise without cover.

check-schema-types.mjs learns `alter table ... rename to`; without it the
drift check reports one rename as two errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 16:05:40 +02:00
parent e8e675fd07
commit eeaf210e78
17 changed files with 650 additions and 54 deletions

View File

@@ -1,7 +1,8 @@
import type { Tx } from "./db";
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
import { jsonArrayFrom, jsonObjectFrom } from "./db/json";
import { besetzungenAbfrage, pickPlacements } from "./placement";
import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org";
import { baueEntwuerfe, entwuerfeAbfrage, type EntwurfZeile } from "./entwuerfe";
import { sichtbareNotizen } from "./notes";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
import type { HistoryEventType } from "./types";
@@ -13,6 +14,9 @@ import type { AnstehendArt } from "./dashboard-filter";
// das Ergebnis gegen den alten Weg halten lässt, ohne eine React-Komponente
// aufzubauen.
/** Eine gültige uuid, die keiner Zeile gehört — für den abgemeldeten Fall. */
const KEINE_KENNUNG = "00000000-0000-0000-0000-000000000000";
export type DashboardParams = {
userId: string | null;
today: string;
@@ -42,14 +46,14 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
const g = await tx
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom("hire_drafts")
.select(["id", "step", "payload"])
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
.where("created_by", "=", userId ?? "")
.orderBy("updated_at", "desc")
).as("drafts"),
// Die eigenen Entwürfe und die der hinzugewählten Kolleg:innen.
//
// Ohne Anmeldung geht eine Kennung mit, die es nicht gibt: die Spalte
// ist vom Typ uuid, und der leere Text, der hier stand, hätte sich
// nicht dorthin umwandeln lassen — die Abfrage wäre nicht leer
// ausgegangen, sondern gescheitert. Das Ergebnis wird unten ohnehin
// verworfen.
jsonArrayFrom(entwuerfeAbfrage(eb, userId ?? KEINE_KENNUNG)).as("drafts"),
jsonArrayFrom(
eb
@@ -148,7 +152,7 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
return {
drafts: userId ? g.drafts : [],
drafts: userId ? baueEntwuerfe(g.drafts as EntwurfZeile[], userId) : [],
staffRows: g.staffRows,
hiresYtd: Number(g.hiresYtd?.anzahl ?? 0),
exitsYtd: Number(g.exitsYtd?.anzahl ?? 0),

84
lib/entwuerfe.ts Normal file
View File

@@ -0,0 +1,84 @@
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;
};
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. Leer bei den eigenen: der Name sagte dort nichts. */
autor: 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")
.select(["d.id", "d.step", "d.payload", "d.created_by"])
.select(["p.full_name as author_name", "p.email as author_email"])
.select((x) => zeitstempel(x.ref("d.updated_at")).as("updated_at"))
.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) => {
const vonMir = userId !== null && row.created_by === userId;
return {
id: row.id,
step: row.step,
payload: row.payload,
updated_at: row.updated_at,
vonMir,
// Ohne Namen die E-Mail — sonst stünde an einem fremden Entwurf nichts
// ausser dem Hinweis, dass er fremd ist.
autor: vonMir ? null : row.author_name?.trim() || row.author_email || "Unbekannt",
};
});
}

28
lib/kollegen.ts Normal file
View File

@@ -0,0 +1,28 @@
import { sql, type Expression, type SqlBool } from "kysely";
// Wen jemand hinzugewählt hat — die eine Regel hinter zwei Listen.
//
// Bis September 2026 galt die Auswahl nur für die Notizen in der Glocke; sie
// hiess deshalb `note_subscriptions` und die Bedingung stand in lib/notes.ts.
// Seit die Entwürfe derselben Auswahl folgen, steht sie hier: zwei Fassungen
// derselben Regel driften auseinander, und dann zeigte die eine Liste
// jemanden, den die andere nicht kennt.
/**
* Ob `spalte` auf eine hinzugewählte Person zeigt.
*
* `spalte` ist der Name der Verfasserspalte in der umgebenden Abfrage
* (`n.author_user_id`, `d.created_by`, …) — als Bezeichner eingesetzt, nicht
* als Wert. Er kommt aus dem Quelltext, nie aus einer Eingabe; die Kennung
* dagegen kommt aus der Sitzung und geht als Parameter mit.
*
* Die eigene Person steht bewusst **nicht** darin. Wer sich selbst sieht, ist
* keine Frage der Auswahl, sondern die Vorgabe — und die Prüfbedingung
* `chk_kollegen_abos_nicht_selbst` lässt ein Abo auf sich selbst gar nicht zu.
* Die aufrufende Abfrage stellt „die eigenen" davor, mit ODER.
*/
export function istHinzugewaehlt(userId: string, spalte: string): Expression<SqlBool> {
return sql<SqlBool>`exists (
select 1 from colleague_subscriptions s
where s.user_id = ${userId} and s.author_user_id = ${sql.ref(spalte)})`;
}

View File

@@ -2,6 +2,7 @@ import { sql, type Expression, type SqlBool } from "kysely";
import type { Tx } from "./db";
import { jsonArrayFrom, zeitstempel } from "./db/json";
import { fmtName } from "./format";
import { istHinzugewaehlt } from "./kollegen";
import type { OrgEb } from "./org";
import type { Database } from "./types";
@@ -29,7 +30,8 @@ export type NotizZeile = Omit<Database["public"]["Tables"]["employee_notes"]["Ro
// mitarbeiterübergreifend, unabhängig davon, zu welcher Person sie gehören.
//
// **Wessen** Notizen erscheinen, entscheidet jede Person selbst: die eigenen
// immer, dazu die hinzugewählten Kolleg:innen (note_subscriptions).
// immer, dazu die hinzugewählten Kolleg:innen (colleague_subscriptions) —
// dieselbe Auswahl, die auch die Entwürfe steuert.
//
// Bis September 2026 war es ein gemeinsamer Topf — jede HR-Person sah jede
// Notiz. Der Wunsch, sich standardmässig nur selbst zu sehen, kam aus dem
@@ -48,7 +50,7 @@ export type NotizZeile = Omit<Database["public"]["Tables"]["employee_notes"]["Ro
* Drei Fälle kommen durch:
*
* 1. **Die eigenen, immer.** Sie sind der Grund, warum es die Glocke gibt.
* `note_subscriptions` lässt einen Selbstbezug nicht zu; die Bedingung
* `colleague_subscriptions` lässt einen Selbstbezug nicht zu; die Bedingung
* steht hier trotzdem, damit die Zusage nicht davon abhängt, dass eine
* Prüfbedingung an anderer Stelle bestehen bleibt.
*
@@ -73,9 +75,7 @@ export function sichtbareNotizen(userId: string, spalte = "n.author_user_id"): E
return sql<SqlBool>`(
${verfasser} is null
or ${verfasser} = ${userId}
or exists (
select 1 from note_subscriptions s
where s.user_id = ${userId} and s.author_user_id = ${verfasser}))`;
or ${istHinzugewaehlt(userId, spalte)})`;
}
/**

View File

@@ -33,7 +33,7 @@ export type ShellData = {
*
* `sichtbar` ist der Stand des Hakens: gesetzt, wenn die Person
* hinzugewählt ist. Die eigene Person steht nicht in der Liste — die
* eigenen Notizen sind immer dabei.
* eigenen Notizen und Entwürfe sind immer dabei.
*/
kollegen: { id: string; name: string; sichtbar: boolean }[];
};
@@ -75,7 +75,7 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebn
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
jsonArrayFrom(offeneNotizenAbfrage(eb, userId)).as("notes"),
// Alle freigeschalteten HR-Personen ausser der eigenen, dazu die
// eigenen Ausnahmen. Beides in derselben Rundreise wie der Rest der
// eigene Auswahl. Beides in derselben Rundreise wie der Rest der
// Hülle — die Einstellung steckt in der Glocke, also muss sie beim
// ersten Aufschlagen da sein.
jsonArrayFrom(
@@ -88,13 +88,19 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebn
.orderBy("full_name")
).as("hrLeute"),
jsonArrayFrom(
eb.selectFrom("note_subscriptions").select("author_user_id").where("user_id", "=", userId)
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"),

View File

@@ -577,8 +577,8 @@ export type Database = {
// Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage.
};
// Hinzugewählte Kolleg:innen je Person. Eine Zeile holt deren Notizen
// in die Glocke; ohne Zeile sieht man nur die eigenen.
note_subscriptions: {
// und Entwürfe in die Ansicht; ohne Zeile sieht man nur die eigenen.
colleague_subscriptions: {
Row: {
user_id: string;
author_user_id: string;
@@ -589,7 +589,7 @@ export type Database = {
author_user_id: string;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["note_subscriptions"]["Insert"]>;
Update: Partial<Database["public"]["Tables"]["colleague_subscriptions"]["Insert"]>;
};
};
Views: Record<string, never>;