Let the same choice open notes and drafts
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:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,13 +46,21 @@ 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>
|
||||
{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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWizard({ draftId: d.id })}
|
||||
className="text-xs font-semibold text-brand-700 hover:underline"
|
||||
>
|
||||
Fortsetzen
|
||||
</button>
|
||||
<button
|
||||
@@ -58,6 +73,11 @@ export function DraftsCard({ drafts }: { drafts: Draft[] }) {
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
118
db/migrations/20260910120000_abos_gelten_auch_fuer_entwuerfe.sql
Normal file
118
db/migrations/20260910120000_abos_gelten_auch_fuer_entwuerfe.sql
Normal 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 $$;
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
84
lib/entwuerfe.ts
Normal 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
28
lib/kollegen.ts
Normal 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)})`;
|
||||
}
|
||||
10
lib/notes.ts
10
lib/notes.ts
@@ -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)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
134
tests/components/DraftsCard.test.tsx
Normal file
134
tests/components/DraftsCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
136
tests/unit/entwuerfe.test.ts
Normal file
136
tests/unit/entwuerfe.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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\"");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user