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

@@ -16,13 +16,22 @@ export async function saveHireDraft(payload: {
try {
const id = await withUser(userId, async (tx) => {
if (payload.id) {
// Ob die Zeile der aufrufenden Person gehört, entscheidet die
// Policy hire_drafts_owner — nicht eine Prüfung hier.
await tx
// Ob die Zeile der aufrufenden Person gehört, entscheidet die Regel
// hire_drafts_update — nicht eine Prüfung hier.
//
// Die Zahl der geänderten Zeilen wird trotzdem gelesen, und zwar
// seit fremde Entwürfe sichtbar sind: eine Regel weist ein UPDATE
// nicht mit einem Fehler ab, sie lässt es ins Leere laufen. Ohne
// diese Prüfung meldete die Anwendung „gespeichert", und gespeichert
// wäre nichts.
const ergebnis = await tx
.updateTable("hire_drafts")
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
.where("id", "=", payload.id)
.execute();
.executeTakeFirst();
if (ergebnis.numUpdatedRows === 0n) {
throw new Error("Der Entwurf liess sich nicht speichern: er gehört jemand anderem oder wurde inzwischen gelöscht.");
}
return payload.id;
}
@@ -43,7 +52,15 @@ export async function saveHireDraft(payload: {
export async function deleteHireDraft(id: string): Promise<ActionResult> {
try {
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
// Auch hier die Zeilenzahl: die Regel hire_drafts_delete lässt ein
// fremdes Löschen leer laufen statt es abzuweisen. „Entwurf gelöscht"
// über einem Entwurf, der noch dasteht, wäre die schlechtere Auskunft.
await withUser(await currentUserId(), async (tx) => {
const ergebnis = await tx.deleteFrom("hire_drafts").where("id", "=", id).executeTakeFirst();
if (ergebnis.numDeletedRows === 0n) {
throw new Error("Der Entwurf liess sich nicht löschen: er gehört jemand anderem oder war schon weg.");
}
});
revalidatePath("/");
return { success: true };
} catch (err) {

View File

@@ -6,7 +6,12 @@ import { withUser } from "@/lib/db";
import type { ActionResult } from "@/lib/db/rpc";
/**
* Notizen einer Kollegin oder eines Kollegen hinzuwählen oder abwählen.
* Eine Kollegin oder einen Kollegen hinzuwählen oder abwählen.
*
* Die Auswahl steuert zwei Listen: die Notizen in der Glocke und die
* Entwürfe auf der Übersicht. Der Name der Funktion nennt nur die erste,
* weil die Einstellung in der Glocke sitzt — was sie bewirkt, steht an der
* Einstellung selbst.
*
* Kein Aufruf einer SQL-Funktion und kein Protokolleintrag, anders als bei
* allem, was Personaldaten ändert: das hier ist eine persönliche
@@ -15,7 +20,7 @@ import type { ActionResult } from "@/lib/db/rpc";
* Dasselbe Muster wie bei gespeicherten Auswertungen und Entwürfen
* (actions/reports.ts, actions/hireDrafts.ts).
*
* Abgesichert ist es trotzdem: die Regel `note_subscriptions_owner` lässt nur Zeilen
* Abgesichert ist es trotzdem: die Regel `colleague_subscriptions_owner` lässt nur Zeilen
* zu, deren `user_id` die angemeldete Person ist. Eine fremde Einstellung
* liesse sich auch mit erfundenen Werten nicht schreiben.
*/
@@ -38,13 +43,13 @@ export async function setNotizSichtbarkeit(payload: {
// `on conflict do nothing`: zweimal dasselbe Hinzuwählen ist kein
// Fehler, sondern derselbe Wunsch — etwa wenn zwei Reiter offen sind.
await tx
.insertInto("note_subscriptions")
.insertInto("colleague_subscriptions")
.values({ user_id: userId, author_user_id: payload.kollegeId })
.onConflict((oc) => oc.columns(["user_id", "author_user_id"]).doNothing())
.execute();
} else {
await tx
.deleteFrom("note_subscriptions")
.deleteFrom("colleague_subscriptions")
.where("user_id", "=", userId)
.where("author_user_id", "=", payload.kollegeId)
.execute();

View File

@@ -183,7 +183,7 @@ export default async function DashboardPage({
return (
<div className="flex flex-col gap-6">
{drafts && drafts.length > 0 && <DraftsCard drafts={drafts} />}
{drafts.length > 0 && <DraftsCard drafts={drafts} />}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{kpis.map((kpi) => (
<Link

View File

@@ -6,11 +6,18 @@ import { useTransition } from "react";
import { deleteHireDraft } from "@/actions/hireDrafts";
import { useHireWizard } from "@/components/hire/HireWizardContext";
import { useToast } from "@/components/ui/Toast";
import type { Entwurf } from "@/lib/entwuerfe";
import { fmtDate } from "@/lib/format";
type Draft = { id: string; step: number; payload: Record<string, unknown>; updated_at: string };
// Angefangene Neueinstellungen — die eigenen und, wer in der Glocke
// hinzugewählt ist, auch dessen.
//
// Fremde Entwürfe stehen hier zum Ansehen. Fortsetzen und Löschen fehlen bei
// ihnen nicht aus Vorsicht, sondern weil die Datenbank sie ohnehin abwiese
// (hire_drafts_update, hire_drafts_delete): eine Schaltfläche anzubieten, die
// verlässlich in eine Fehlermeldung führt, wäre ein Versprechen ohne Deckung.
export function DraftsCard({ drafts }: { drafts: Draft[] }) {
export function DraftsCard({ drafts }: { drafts: Entwurf[] }) {
const { openWizard } = useHireWizard();
const { showToast } = useToast();
const router = useRouter();
@@ -39,25 +46,38 @@ export function DraftsCard({ drafts }: { drafts: Draft[] }) {
const lastName = typeof d.payload.lastName === "string" ? d.payload.lastName : "";
const name = [firstName, lastName].filter(Boolean).join(" ") || "Ohne Namen";
return (
<li key={d.id} className="flex items-center justify-between py-2 text-sm">
<li key={d.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<div>
<span className="font-semibold text-ink">{name}</span>
<span className="ml-2 text-xs text-ink-muted">Gespeichert am {fmtDate(d.updated_at)}</span>
{/* Nur bei fremden Entwürfen. Der eigene Name stünde sonst an
jeder Zeile und sagte nichts. */}
{d.autor && <span className="ml-2 text-xs text-ink-muted">von {d.autor}</span>}
</div>
<div className="flex items-center gap-3">
<button type="button" onClick={() => openWizard({ draftId: d.id })} className="text-xs font-semibold text-brand-700 hover:underline">
Fortsetzen
</button>
<button
type="button"
onClick={() => handleDelete(d.id)}
disabled={pending}
aria-label="Entwurf löschen"
className="text-ink-muted hover:text-danger-solid"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
{d.vonMir ? (
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => openWizard({ draftId: d.id })}
className="text-xs font-semibold text-brand-700 hover:underline"
>
Fortsetzen
</button>
<button
type="button"
onClick={() => handleDelete(d.id)}
disabled={pending}
aria-label="Entwurf löschen"
className="text-ink-muted hover:text-danger-solid"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
) : (
// Warum hier nichts steht, ist sonst nicht zu erraten: die
// Zeile sähe aus wie eine, an der die Schaltflächen fehlen.
<span className="shrink-0 text-xs text-ink-muted">Nur zur Ansicht</span>
)}
</li>
);
})}

View File

@@ -99,8 +99,13 @@ export function NotesBell({ notes, kollegen }: { notes: OpenNote[]; kollegen: Ko
{zeigeEinstellung && (
<div className="border-b border-border bg-surface px-4 py-3">
{/* Dass die Auswahl auch die Entwürfe auf der Übersicht
steuert, steht hier und nicht nur dort: sie wird hier
getroffen, und eine Wirkung, die zwei Klicks entfernt
sichtbar wird, sucht sonst niemand. */}
<p className="mb-2 text-xs text-ink-muted">
Wessen Notizen hier zusätzlich erscheinen. Die eigenen sind immer dabei.
Wessen Notizen hier und wessen Entwürfe auf der Übersicht zusätzlich erscheinen. Die
eigenen sind immer dabei.
</p>
{kollegen.length === 0 ? (
<p className="py-2 text-sm text-ink-muted">Keine weiteren HR-Kolleg:innen freigeschaltet.</p>

View File

@@ -0,0 +1,118 @@
-- Die Auswahl der Kolleg:innen gilt jetzt für Notizen **und** Entwürfe.
--
-- ═══ 1. Der Name stimmt nicht mehr ═══════════════════════════════
--
-- `note_subscriptions` hiess nach dem, wofür die Auswahl gestern allein
-- galt. Sie steuert ab jetzt zwei Listen; ein Name, der nur eine davon
-- nennt, führt beim nächsten Lesen in die Irre.
alter table note_subscriptions rename to colleague_subscriptions;
alter index idx_note_subscriptions_user rename to idx_colleague_subscriptions_user;
alter table colleague_subscriptions
rename constraint chk_note_abos_nicht_selbst to chk_kollegen_abos_nicht_selbst;
drop policy if exists "note_subscriptions_owner" on colleague_subscriptions;
drop policy if exists "colleague_subscriptions_owner" on colleague_subscriptions;
create policy "colleague_subscriptions_owner" on colleague_subscriptions
for all
using (user_id = app_current_user_id() and is_hr_user())
with check (user_id = app_current_user_id() and is_hr_user());
comment on table colleague_subscriptions is
'Hinzugewählte Kolleg:innen je Person. Eine Zeile holt deren Notizen und Entwürfe in die Ansicht von user_id. Ohne Zeile sieht man nur die eigenen.';
-- ═══ 2. Entwürfe: lesen weiter, schreiben eng ════════════════════
--
-- Bisher stand über hire_drafts eine einzige Regel `for all`. Sie wird in
-- vier zerlegt, weil Lesen und Schreiben ab jetzt verschieden weit reichen:
--
-- lesen — die eigenen und die der hinzugewählten Kolleg:innen
-- anlegen — nur auf den eigenen Namen
-- ändern — nur die eigenen
-- löschen — nur die eigenen
--
-- Warum das Schreiben eng bleibt: ein Entwurf ist unfertige Arbeit. Zwei
-- Personen, die abwechselnd in derselben Zeile schreiben, überschreiben
-- einander lautlos — es gibt keine Sperre und keine Historie, die das
-- auffangen könnte. Wer einen fremden Entwurf übernehmen soll, braucht dafür
-- einen eigenen Vorgang, keine stillschweigend geöffnete Regel.
--
-- ═══ Was das erzwingt ═══
--
-- Solange fremde Entwürfe unsichtbar waren, konnte niemand versuchen, einen
-- zu speichern. Jetzt schon — und ein UPDATE, das die Regel abweist, trifft
-- keine Zeile und meldet trotzdem keinen Fehler. Die Anwendung muss die Zahl
-- der geänderten Zeilen prüfen (actions/hireDrafts.ts), sonst sähe die
-- Person „gespeichert", und nichts wäre gespeichert.
drop policy if exists "hire_drafts_owner" on hire_drafts;
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 (
created_by = 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 = hire_drafts.created_by
)
)
);
drop policy if exists "hire_drafts_insert" on hire_drafts;
create policy "hire_drafts_insert" on hire_drafts
for insert
with check (created_by = app_current_user_id() and is_hr_user());
drop policy if exists "hire_drafts_update" on hire_drafts;
create policy "hire_drafts_update" on hire_drafts
for update
using (created_by = app_current_user_id() and is_hr_user())
with check (created_by = app_current_user_id() and is_hr_user());
drop policy if exists "hire_drafts_delete" on hire_drafts;
create policy "hire_drafts_delete" on hire_drafts
for delete
using (created_by = app_current_user_id() and is_hr_user());
-- ═══ Gegenprobe ═══════════════════════════════════════════════════
do $$
declare
anzahl int;
begin
if exists (select 1 from pg_tables where tablename = 'note_subscriptions') then
raise exception 'note_subscriptions steht noch — die Umbenennung hat nicht gegriffen.';
end if;
if not exists (
select 1 from pg_policies
where tablename = 'colleague_subscriptions' and policyname = 'colleague_subscriptions_owner'
) then
raise exception 'Die Eigentuemerregel auf colleague_subscriptions fehlt.';
end if;
-- Die alte Sammelregel darf nicht übrigbleiben: sie erlaubte `for all`
-- und machte die vier neuen wirkungslos, weil Policies sich addieren.
if exists (select 1 from pg_policies where tablename = 'hire_drafts' and policyname = 'hire_drafts_owner') then
raise exception 'hire_drafts_owner steht noch — die alte Sammelregel wuerde die neuen aushebeln.';
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 (select, insert, update, delete).', anzahl;
end if;
-- Keine der drei Schreibregeln darf die Abos kennen. Stünde dort dieselbe
-- Bedingung wie beim Lesen, liesse sich ein fremder Entwurf überschreiben.
if exists (
select 1 from pg_policies
where tablename = 'hire_drafts'
and cmd in ('INSERT', 'UPDATE', 'DELETE')
and coalesce(qual, '') || coalesce(with_check, '') like '%colleague_subscriptions%'
) then
raise exception 'Eine Schreibregel auf hire_drafts kennt die Abos — Schreiben muss eigentuemergebunden bleiben.';
end if;
end $$;

View File

@@ -268,8 +268,30 @@ ist ein bewusster zweiter Schritt.
### `hire_drafts`, `saved_reports`
Zwischenstand des Einstellungsassistenten (`payload` JSONB, `step`) und
gespeicherte Berichtskonfigurationen. Beide sind auf die anlegende Person
eingeschränkt.
gespeicherte Berichtskonfigurationen.
`saved_reports` gehört ganz der anlegenden Person. Bei `hire_drafts` gehen
Lesen und Schreiben seit September 2026 verschieden weit: **lesen** darf, wem
der Entwurf gehört, und wer die anlegende Person in seiner Glocke hinzugewählt
hat (`colleague_subscriptions`); **anlegen, ändern und löschen** darf nur die
anlegende Person selbst. Ein Entwurf ist unfertige Arbeit ohne Sperre und ohne
Historie — zwei Personen, die abwechselnd hineinschreiben, überschreiben
einander lautlos.
Die Anwendung liest deshalb bei `update` und `delete` die Zahl der
betroffenen Zeilen: eine Policy weist ein Schreiben nicht mit einem Fehler ab,
sie lässt es ins Leere laufen (`actions/hireDrafts.ts`).
### `colleague_subscriptions`
Wen jemand hinzugewählt hat, je eine Zeile aus `user_id` und
`author_user_id`. Die Vorgabe ist die eigene Person: **ohne** Zeile sieht man
nur die eigenen Notizen und Entwürfe, mit Zeile zusätzlich die der genannten
Person. Die Auswahl sitzt in der Glocke in der Kopfzeile und steuert beides.
Ein Abo auf sich selbst lässt `chk_kollegen_abos_nicht_selbst` nicht zu — die
eigenen Zeilen sind ohnehin immer dabei. Die Tabelle hiess bis September 2026
`note_subscriptions`, als die Auswahl nur für die Notizen galt.
---
@@ -390,8 +412,12 @@ Wo das Muster abweicht:
ohne Freischaltung. Sonst könnte niemand erfahren, warum er nicht
hineinkommt.
- `app_users` ebenso: die eigene Zeile oder HR.
- `hire_drafts` und `saved_reports` verlangen zusätzlich, dass die Zeile der
anfragenden Person gehört.
- `saved_reports` und `colleague_subscriptions` verlangen zusätzlich, dass
die Zeile der anfragenden Person gehört.
- `hire_drafts` hat vier — `select`, `insert`, `update`, `delete` getrennt,
weil das Lesen seit September 2026 weiter reicht als das Schreiben (siehe
oben). Die alte Sammelregel `for all` musste dafür weichen: Policies
addieren sich, eine stehengebliebene hätte die vier neuen ausgehebelt.
- `locations` trennt Lesen und Schreiben in zwei Policies, prüft aber beide
Male dasselbe.

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>;

View File

@@ -114,6 +114,19 @@ function parseMigrations() {
}
const anweisung = sql.slice(kopf.index + kopf[0].length, ende);
// Eine umbenannte Tabelle behält ihre Spalten und wechselt den Namen.
// Ohne diesen Fall stünde der alte Name weiter im Modell aus den
// Migrationen, und der Abgleich meldete zwei Fehler für eine Änderung:
// die alte Tabelle fehle in lib/types.ts, die neue kenne das Schema
// nicht. `rename constraint` und `rename column` sind davon nicht
// betroffen — nur das unmittelbare `rename to`.
const umbenannt = anweisung.match(/^\s*rename to (\w+)/i);
if (umbenannt) {
tables.delete(kopf[1].toLowerCase());
tables.set(umbenannt[1].toLowerCase(), tabelle);
continue;
}
for (const m of anweisung.matchAll(/\badd column (?:if not exists )?(\w+)/gi)) {
tabelle.add(m[1].toLowerCase());
}

View File

@@ -0,0 +1,134 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DraftsCard } from "@/components/dashboard/DraftsCard";
import { ToastProvider } from "@/components/ui/Toast";
import type { Entwurf } from "@/lib/entwuerfe";
const openWizard = vi.fn();
const deleteHireDraft = vi.fn<(id: string) => Promise<{ success: boolean; error?: string }>>(async () => ({
success: true,
}));
const refresh = vi.fn();
vi.mock("@/components/hire/HireWizardContext", () => ({ useHireWizard: () => ({ openWizard }) }));
vi.mock("@/actions/hireDrafts", () => ({ deleteHireDraft: (id: string) => deleteHireDraft(id) }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh, push: vi.fn() }) }));
// Seit die Auswahl in der Glocke auch die Entwürfe steuert, stehen hier
// fremde Zeilen. Sie dürfen aussehen wie das, was sie sind: zum Ansehen.
// Böte die Karte an ihnen „Fortsetzen", liefe die Person durch den ganzen
// Assistenten und bekäme am Ende eine Fehlermeldung aus der Datenbank.
function entwurf(teil: Partial<Entwurf> = {}): Entwurf {
return {
id: "d1",
step: 2,
payload: { firstName: "Manuel", lastName: "Aigner" },
updated_at: "2026-09-09T08:00:00.000Z",
vonMir: true,
autor: null,
...teil,
};
}
const fremd = entwurf({ id: "d2", payload: { firstName: "Eva", lastName: "Novak" }, vonMir: false, autor: "Anna Berger" });
function zeige(drafts: Entwurf[]) {
return render(
<ToastProvider>
<DraftsCard drafts={drafts} />
</ToastProvider>
);
}
/** Die Zeile zu einem Namen — die Karte zeigt mehrere nebeneinander. */
function zeile(name: string) {
return screen.getByText(name).closest("li") as HTMLElement;
}
beforeEach(() => {
openWizard.mockClear();
deleteHireDraft.mockClear();
deleteHireDraft.mockResolvedValue({ success: true });
refresh.mockClear();
});
describe("DraftsCard", () => {
it("zeigt zu eigenen Entwürfen Fortsetzen und Löschen", () => {
zeige([entwurf()]);
const z = zeile("Manuel Aigner");
expect(within(z).getByRole("button", { name: "Fortsetzen" })).toBeInTheDocument();
expect(within(z).getByRole("button", { name: "Entwurf löschen" })).toBeInTheDocument();
});
it("nennt an eigenen Entwürfen keinen Verfasser", () => {
// Der eigene Name stünde an jeder Zeile und sagte nichts.
zeige([entwurf()]);
expect(within(zeile("Manuel Aigner")).queryByText(/^von /)).not.toBeInTheDocument();
});
it("nennt an fremden Entwürfen, von wem sie stammen", () => {
zeige([fremd]);
expect(within(zeile("Eva Novak")).getByText("von Anna Berger")).toBeInTheDocument();
});
it("bietet an fremden Entwürfen weder Fortsetzen noch Löschen", () => {
// Die Datenbank wiese beides ab (hire_drafts_update, hire_drafts_delete).
// Eine Schaltfläche, die verlässlich in einen Fehler führt, ist ein
// Versprechen ohne Deckung.
zeige([fremd]);
const z = zeile("Eva Novak");
expect(within(z).queryByRole("button", { name: "Fortsetzen" })).not.toBeInTheDocument();
expect(within(z).queryByRole("button", { name: "Entwurf löschen" })).not.toBeInTheDocument();
});
it("sagt an fremden Entwürfen, warum dort nichts steht", () => {
// Ohne den Hinweis sähe die Zeile aus wie eine, an der die Schaltflächen
// fehlen.
zeige([fremd]);
expect(within(zeile("Eva Novak")).getByText("Nur zur Ansicht")).toBeInTheDocument();
});
it("trennt die Zeilen: eigene bedienbar, fremde nicht", () => {
// Beide nebeneinander — der Fall, in dem eine Verwechslung teuer wird.
zeige([entwurf(), fremd]);
expect(within(zeile("Manuel Aigner")).getByRole("button", { name: "Fortsetzen" })).toBeInTheDocument();
expect(within(zeile("Eva Novak")).queryByRole("button", { name: "Fortsetzen" })).not.toBeInTheDocument();
});
it("setzt beim Klick genau den angeklickten Entwurf fort", () => {
const user = userEvent.setup();
zeige([entwurf(), fremd]);
return user.click(within(zeile("Manuel Aigner")).getByRole("button", { name: "Fortsetzen" })).then(() => {
expect(openWizard).toHaveBeenCalledWith({ draftId: "d1" });
});
});
it("löscht genau den angeklickten Entwurf", async () => {
// Zwei eigene nebeneinander: eine Karte, die immer den ersten löscht,
// fiele mit nur einer Zeile nicht auf.
const user = userEvent.setup();
zeige([entwurf(), entwurf({ id: "d3", payload: { firstName: "Lena", lastName: "Wolf" } })]);
await user.click(within(zeile("Lena Wolf")).getByRole("button", { name: "Entwurf löschen" }));
expect(deleteHireDraft).toHaveBeenCalledWith("d3");
});
it("meldet den Fehler, wenn das Löschen abgewiesen wird", async () => {
deleteHireDraft.mockResolvedValue({ success: false, error: "Gehört jemand anderem." });
const user = userEvent.setup();
zeige([entwurf()]);
await user.click(screen.getByRole("button", { name: "Entwurf löschen" }));
expect(await screen.findByText("Gehört jemand anderem.")).toBeInTheDocument();
});
it("bleibt bei leerer Liste unsichtbar", () => {
// Eine Überschrift über nichts ist eine Zeile, die jeden Tag gelesen und
// jeden Tag verworfen wird.
// Nicht auf einen leeren Container prüfen: der ToastProvider legt
// seinen eigenen Kasten an, und der stünde da, egal was die Karte tut.
zeige([]);
expect(screen.queryByRole("heading", { name: /Entwürfe/ })).not.toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,136 @@
import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from "kysely";
import { describe, expect, it } from "vitest";
import type { Schema } from "@/lib/db/schema";
import { baueEntwuerfe, entwuerfeAbfrage, type EntwurfZeile } from "@/lib/entwuerfe";
// Wessen Entwürfe jemand sieht — und, wichtiger, an welchen die Schaltflächen
// hängen. Die Regeln in der Datenbank sind die Grenze; hier wird gelesen, was
// die Abfrage daraus macht, weil ein Fehler darin nicht auffiele: die Karte
// zeigte eine Liste, nur die falsche.
const db = new Kysely<Schema>({
dialect: {
createAdapter: () => new PostgresAdapter(),
createDriver: () => new DummyDriver(),
createIntrospector: (d) => new PostgresIntrospector(d),
createQueryCompiler: () => new PostgresQueryCompiler(),
},
});
const ICH = "11111111-1111-1111-1111-111111111111";
const ANDERE = "22222222-2222-2222-2222-222222222222";
// entwuerfeAbfrage erwartet den Ausdrucksbauer aus selectNoFrom — genau so
// hängt lib/dashboard-data.ts sie ein.
function abfrage(userId = ICH) {
return db.selectNoFrom((eb) => [entwuerfeAbfrage(eb, userId).as("x")]).compile();
}
const text = (userId?: string) => abfrage(userId).sql.replace(/\s+/g, " ");
describe("entwuerfeAbfrage", () => {
it("zeigt die eigenen Entwürfe", () => {
expect(text()).toContain('"d"."created_by" = $');
});
it("holt dazu, wer hinzugewählt wurde", () => {
// Vorzeichen und Tabelle zusammen: ein `not exists` kehrte die Bedeutung
// um, ohne dass ein Wort sich änderte.
const s = text();
expect(s).toContain("exists");
expect(s).not.toContain("not exists");
expect(s).toContain("from colleague_subscriptions s");
expect(s).toContain('s.author_user_id = "d"."created_by"');
});
it("verknüpft eigene und hinzugewählte mit ODER, nicht mit UND", () => {
// Mit UND bliebe die Liste immer leer: kein Entwurf ist gleichzeitig von
// mir und von jemandem, den ich hinzugewählt habe.
expect(text()).toMatch(/"d"\."created_by" = \$\d+ or exists/);
});
it("holt den Namen der verfassenden Person dazu", () => {
// Ohne ihn stünde an einem fremden Entwurf nur, dass er fremd ist.
const s = text();
expect(s).toContain('left join "profiles" as "p" on "p"."id" = "d"."created_by"');
expect(s).toContain('"p"."full_name" as "author_name"');
expect(s).toContain('"p"."email" as "author_email"');
});
it("stellt die eigenen Entwürfe nach vorn", () => {
// Vor der Freigabe standen dort nur die eigenen; wer seinen halbfertigen
// Entwurf sucht, soll ihn nicht zwischen fremden suchen.
const s = text();
expect(s).toMatch(/order by case when "d"\."created_by" = \$\d+ then 0 else 1 end/);
expect(s).toContain('"d"."updated_at" desc');
});
it("bindet die Kennung als Parameter, nicht in den Text", () => {
const { sql: roh, parameters } = abfrage("bösartig'; drop table hire_drafts; --");
expect(roh).not.toContain("drop table");
expect(parameters).toContain("bösartig'; drop table hire_drafts; --");
});
it("formatiert den Zeitstempel innerhalb von JSON ausdrücklich", () => {
// In JSON gibt Postgres ihn anders aus als der Treiber es täte, und der
// Unterschied fällt erst beim Vergleichen zweier Listen auf.
expect(text()).toContain("to_char(");
});
});
describe("baueEntwuerfe", () => {
function zeile(teil: Partial<EntwurfZeile> = {}): EntwurfZeile {
return {
id: "d1",
step: 2,
payload: { firstName: "Manuel", lastName: "Aigner" },
updated_at: "2026-09-09T08:00:00.000Z",
created_by: ANDERE,
author_name: "Anna Berger",
author_email: "a@example.test",
...teil,
};
}
it("erkennt den eigenen Entwurf", () => {
const [e] = baueEntwuerfe([zeile({ created_by: ICH })], ICH);
expect(e.vonMir).toBe(true);
});
it("nennt bei eigenen Entwürfen keinen Verfasser", () => {
// Der eigene Name stünde an jeder Zeile und sagte nichts.
const [e] = baueEntwuerfe([zeile({ created_by: ICH })], ICH);
expect(e.autor).toBeNull();
});
it("nennt bei fremden Entwürfen den Namen", () => {
const [e] = baueEntwuerfe([zeile()], ICH);
expect(e.vonMir).toBe(false);
expect(e.autor).toBe("Anna Berger");
});
it("fällt ohne Namen auf die E-Mail zurück", () => {
// Auch ein Name aus Leerzeichen zählt als keiner.
expect(baueEntwuerfe([zeile({ author_name: " " })], ICH)[0].autor).toBe("a@example.test");
expect(baueEntwuerfe([zeile({ author_name: null })], ICH)[0].autor).toBe("a@example.test");
});
it("nennt jemanden, auch wenn beides fehlt", () => {
// Ein Entwurf ohne jede Zuordnung wäre einer, bei dem niemand weiss, wen
// er fragen soll.
expect(baueEntwuerfe([zeile({ author_name: null, author_email: null })], ICH)[0].autor).toBe("Unbekannt");
});
it("hält ohne Anmeldung nichts für eigen", () => {
// created_by kann leer sein, userId auch. Beide leer heisst nicht „meiner"
// — sonst hinge an einer herrenlosen Zeile eine Löschen-Schaltfläche.
const [e] = baueEntwuerfe([zeile({ created_by: null })], null);
expect(e.vonMir).toBe(false);
});
it("reicht Inhalt und Zeitpunkt unverändert weiter", () => {
const [e] = baueEntwuerfe([zeile()], ICH);
expect(e).toMatchObject({ id: "d1", step: 2, updated_at: "2026-09-09T08:00:00.000Z" });
expect(e.payload).toEqual({ firstName: "Manuel", lastName: "Aigner" });
});
});

View File

@@ -48,7 +48,7 @@ describe("sichtbareNotizen", () => {
const s = sql();
expect(s).toContain("exists");
expect(s).not.toContain("not exists");
expect(s).toContain("from note_subscriptions s");
expect(s).toContain("from colleague_subscriptions s");
expect(s).toContain("s.user_id = $");
expect(s).toContain("s.author_user_id = \"n\".\"author_user_id\"");
});