From 99b1df9735409dbcf3cb6e2bdb28968ead44ba03 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Wed, 9 Sep 2026 20:00:13 +0200 Subject: [PATCH] Choose whose notes reach your bell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bell is a shared pile: every active HR person sees every open note, regardless of who wrote it. That was agreed and it stays the default — this narrows it, it never widens it. You can now untick colleagues whose notes you do not want to see. What gets stored is the *exceptions*, not the selection. The difference shows the day someone new joins HR: had the selection been stored, she would be invisible to everyone until each person ticked her, and nobody would notice her follow-ups piling up. This way she is visible from day one and hiding her is a deliberate act. Same reasoning that made notes a shared inbox in the first place — the silent gap is worse than a row too many. Own notes always come through: `note_mutes` rejects a self-reference, and the predicate says so again rather than depending on a check constraint staying put. Notes with no author come through too — hiding one because nobody knows who wrote it is exactly the loss this list exists to prevent. The rule lives in lib/notes.ts as one SQL expression because two places need it: the bell in the header and the "Anstehend" card on the dashboard. Two copies drift, and then the card counts something the bell does not show. No SQL function and no audit row, unlike anything that touches employee data — this is a personal display preference, and an audit trail recording every tick would make finding real changes harder. Same pattern as saved reports and hire drafts, and the owner policy on note_mutes means a row for someone else cannot be written even with invented values. The checkbox flips immediately and flips back if saving fails; the list gets clicked through several at a time and a round trip per tick feels like hesitation. Verified: 19 tests, five mutation-checked (or→and, dropping the own-notes clause, inverting `not exists`, inverting the default, and losing the email fallback each turn them red). Typecheck, lint, schema drift, 477 tests and the build are clean. Not seen in a browser: login goes through the company account and the database is unreachable — the migration is reviewed but has not been run. Co-Authored-By: Claude Opus 5 --- actions/notes.ts | 61 ++++++++ app/(app)/layout.tsx | 2 +- components/shell/AppShell.tsx | 14 +- components/shell/NotesBell.tsx | 75 ++++++++- components/shell/Topbar.tsx | 5 +- .../20260909100000_notiz_sichtbarkeit.sql | 83 ++++++++++ lib/dashboard-data.ts | 5 + lib/notes.ts | 55 ++++++- lib/shell-data.ts | 48 +++++- lib/types.ts | 17 +++ tests/components/NotesBell.test.tsx | 144 ++++++++++++++++++ tests/unit/notes-visibility.test.ts | 120 +++++++++++++++ 12 files changed, 614 insertions(+), 15 deletions(-) create mode 100644 actions/notes.ts create mode 100644 db/migrations/20260909100000_notiz_sichtbarkeit.sql create mode 100644 tests/components/NotesBell.test.tsx create mode 100644 tests/unit/notes-visibility.test.ts diff --git a/actions/notes.ts b/actions/notes.ts new file mode 100644 index 0000000..3eaf974 --- /dev/null +++ b/actions/notes.ts @@ -0,0 +1,61 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; +import type { ActionResult } from "@/lib/db/rpc"; + +/** + * Notizen einer Kollegin oder eines Kollegen ein- oder ausblenden. + * + * Kein Aufruf einer SQL-Funktion und kein Protokolleintrag, anders als bei + * allem, was Personaldaten ändert: das hier ist eine persönliche + * Anzeigeeinstellung. Ein Prüfprotokoll, das jeden Haken mitschreibt, machte + * die Suche nach echten Änderungen mühsamer, ohne etwas nachzuweisen. + * Dasselbe Muster wie bei gespeicherten Auswertungen und Entwürfen + * (actions/reports.ts, actions/hireDrafts.ts). + * + * Abgesichert ist es trotzdem: die Regel `note_mutes_owner` lässt nur Zeilen + * zu, deren `user_id` die angemeldete Person ist. Eine fremde Einstellung + * liesse sich auch mit erfundenen Werten nicht schreiben. + */ +export async function setNotizSichtbarkeit(payload: { + kollegeId: string; + sichtbar: boolean; +}): Promise { + const userId = await requireUserId(); + + // Die eigenen Notizen bleiben immer sichtbar. Die Prüfbedingung der + // Tabelle weist das ohnehin ab; hier kommt die Meldung heraus, die jemand + // lesen kann, statt einer Verletzungsmeldung aus der Datenbank. + if (payload.kollegeId === userId) { + return { success: false, error: "Die eigenen Notizen lassen sich nicht ausblenden." }; + } + + try { + await withUser(userId, async (tx) => { + if (payload.sichtbar) { + await tx + .deleteFrom("note_mutes") + .where("user_id", "=", userId) + .where("muted_user_id", "=", payload.kollegeId) + .execute(); + } else { + // `on conflict do nothing`: zweimal dasselbe Ausblenden ist kein + // Fehler, sondern derselbe Wunsch — etwa wenn zwei Reiter offen sind. + await tx + .insertInto("note_mutes") + .values({ user_id: userId, muted_user_id: payload.kollegeId }) + .onConflict((oc) => oc.columns(["user_id", "muted_user_id"]).doNothing()) + .execute(); + } + }); + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; + } + + // Die Glocke steckt in der Hülle jeder Seite, die Karte „Anstehend" auf der + // Übersicht. Beide zeigen dieselbe Menge und müssen gemeinsam nachziehen. + revalidatePath("/", "layout"); + return { success: true }; +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 7c41637..ed60199 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -36,7 +36,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/components/shell/AppShell.tsx b/components/shell/AppShell.tsx index 8f4f1f9..d920393 100644 --- a/components/shell/AppShell.tsx +++ b/components/shell/AppShell.tsx @@ -8,7 +8,17 @@ import { Topbar } from "./Topbar"; // Owns the one piece of state the shell needs (is the mobile drawer open), // so app/(app)/layout.tsx can stay a Server Component and keep doing its // auth check and data loading on the server. -export function AppShell({ userLabel, openNotes, children }: { userLabel: string; openNotes: OpenNote[]; children: ReactNode }) { +export function AppShell({ + userLabel, + openNotes, + kollegen, + children, +}: { + userLabel: string; + openNotes: OpenNote[]; + kollegen: { id: string; name: string; sichtbar: boolean }[]; + children: ReactNode; +}) { const [navOpen, setNavOpen] = useState(false); const closeNav = useCallback(() => setNavOpen(false), []); @@ -27,7 +37,7 @@ export function AppShell({ userLabel, openNotes, children }: { userLabel: string
- setNavOpen(true)} /> + setNavOpen(true)} />
{children}
diff --git a/components/shell/NotesBell.tsx b/components/shell/NotesBell.tsx index 242c883..7b11206 100644 --- a/components/shell/NotesBell.tsx +++ b/components/shell/NotesBell.tsx @@ -1,24 +1,47 @@ "use client"; -import { Bell } from "lucide-react"; +import { Bell, Users } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { completeEmployeeNote } from "@/actions/employees"; +import { setNotizSichtbarkeit } from "@/actions/notes"; import { useToast } from "@/components/ui/Toast"; import { NOTE_CATEGORY_STYLES } from "@/lib/colors"; import { fmtDate } from "@/lib/format"; import type { OpenNote } from "@/lib/notes"; +export type Kollege = { id: string; name: string; sichtbar: boolean }; + // Click-outside mechanics borrowed from CountryPicker (useRef + mousedown // listener) — without its draft-text reset, which has no equivalent here. -export function NotesBell({ notes }: { notes: OpenNote[] }) { +export function NotesBell({ notes, kollegen }: { notes: OpenNote[]; kollegen: Kollege[] }) { const { showToast } = useToast(); const router = useRouter(); const [open, setOpen] = useState(false); const [completingId, setCompletingId] = useState(null); + const [zeigeEinstellung, setZeigeEinstellung] = useState(false); + // Der Haken springt sofort um und wird zurückgedreht, wenn das Speichern + // scheitert. Ohne das fühlt sich jeder Klick wie eine halbe Sekunde + // Bedenkzeit an — die Liste wird beim Durchgehen mehrfach angetippt. + const [eigen, setEigen] = useState>({}); const containerRef = useRef(null); + const sichtbarkeit = (k: Kollege) => eigen[k.id] ?? k.sichtbar; + const ausgeblendet = kollegen.filter((k) => !sichtbarkeit(k)).length; + + async function schalte(k: Kollege) { + const neu = !sichtbarkeit(k); + setEigen((v) => ({ ...v, [k.id]: neu })); + const result = await setNotizSichtbarkeit({ kollegeId: k.id, sichtbar: neu }); + if (result.success) { + router.refresh(); + } else { + setEigen((v) => ({ ...v, [k.id]: !neu })); + showToast(result.error ?? "Fehler beim Speichern.", "error"); + } + } + useEffect(() => { function onClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { @@ -54,7 +77,53 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) { {open && (
-
Meine Notizen ({notes.length})
+
+ + Meine Notizen ({notes.length}) + + +
+ + {zeigeEinstellung && ( +
+

+ Wessen Notizen hier erscheinen. Die eigenen sind immer dabei. +

+ {kollegen.length === 0 ? ( +

Keine weiteren HR-Kolleg:innen freigeschaltet.

+ ) : ( +
    + {kollegen.map((k) => ( +
  • + +
  • + ))} +
+ )} +
+ )} + {notes.length === 0 ? (

Keine offenen Notizen.

) : ( diff --git a/components/shell/Topbar.tsx b/components/shell/Topbar.tsx index 9d16756..66dfd69 100644 --- a/components/shell/Topbar.tsx +++ b/components/shell/Topbar.tsx @@ -25,10 +25,11 @@ function titleFor(pathname: string): string { type TopbarProps = { userLabel: string; openNotes: OpenNote[]; + kollegen: { id: string; name: string; sichtbar: boolean }[]; onOpenNav: () => void; }; -export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) { +export function Topbar({ userLabel, openNotes, kollegen, onOpenNav }: TopbarProps) { const pathname = usePathname(); return ( @@ -47,7 +48,7 @@ export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) {

{titleFor(pathname)}

- +
{/* The name is the first thing worth dropping on a narrow screen — diff --git a/db/migrations/20260909100000_notiz_sichtbarkeit.sql b/db/migrations/20260909100000_notiz_sichtbarkeit.sql new file mode 100644 index 0000000..608d987 --- /dev/null +++ b/db/migrations/20260909100000_notiz_sichtbarkeit.sql @@ -0,0 +1,83 @@ +-- Welche Kolleg:innen man in seinen Benachrichtigungen sehen will. +-- +-- ═══ Was sich damit ändert ═══ +-- +-- Bisher war die Glocke ein gemeinsamer Topf: jede aktive HR-Person sah jede +-- offene Notiz, unabhängig davon, wer sie verfasst hat. Das war so +-- abgestimmt und bleibt der Standard — die Einstellung engt ein, sie öffnet +-- nichts. +-- +-- ═══ Warum hier die Ausnahmen stehen und nicht die Auswahl ═══ +-- +-- Eine Zeile heisst: „die Notizen dieser Person will ich nicht sehen." +-- Gespeichert wird also das Abgewählte, nicht das Gewählte. +-- +-- Der Unterschied fällt auf, sobald jemand Neues in die Personalabteilung +-- kommt. Stünde hier die Auswahl, wäre die neue Kollegin für alle unsichtbar, +-- bis jede einzelne Person sie anhakt — und niemandem fiele auf, dass ihre +-- Wiedervorlagen liegenbleiben. So herum ist sie ab dem ersten Tag sichtbar, +-- und wer sie ausblenden will, tut das ausdrücklich. +-- +-- Dieselbe Überlegung wie bei den Notizen selbst: die stille Lücke ist +-- schlimmer als eine Zeile zu viel. + +create table if not exists note_mutes ( + -- Wessen Einstellung das ist. + user_id uuid not null references app_users(id) on delete cascade, + -- Wessen Notizen ausgeblendet werden. + muted_user_id uuid not null references app_users(id) on delete cascade, + created_at timestamptz not null default now(), + + primary key (user_id, muted_user_id), + + -- Die eigenen Notizen lassen sich nicht abwählen. Sie sind der Grund, + -- warum es die Glocke gibt; ein Haken, der die eigene Wiedervorlage + -- verschwinden lässt, wäre eine Falle. + constraint chk_note_mutes_nicht_selbst check (user_id <> muted_user_id) +); + +comment on table note_mutes is + 'Abgewählte Kolleg:innen je Person. Eine Zeile blendet die Notizen von muted_user_id für user_id aus. Ohne Zeile ist alles sichtbar.'; + +-- Der Zugriffsweg fragt immer „meine Ausnahmen": ohne Index ein Tabellen- +-- scan je Seitenaufruf, mit ihm ein Indexzugriff. Bei einer Handvoll HR- +-- Personen ist das heute belanglos und morgen nicht mehr. +create index if not exists idx_note_mutes_user on note_mutes (user_id); + +alter table note_mutes enable row level security; + +-- Eigentümergebunden wie hire_drafts und saved_reports: man sieht und +-- ändert ausschliesslich die eigenen Zeilen. Eine fremde Einstellung geht +-- niemanden etwas an — auch keine andere HR-Person. +drop policy if exists "note_mutes_owner" on note_mutes; +create policy "note_mutes_owner" on note_mutes + 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()); + +-- ═══ Gegenprobe ═══════════════════════════════════════════════════ +do $$ +begin + if not exists ( + select 1 from pg_tables where tablename = 'note_mutes' and rowsecurity + ) then + raise exception 'note_mutes hat keinen Zeilenschutz.'; + end if; + + if not exists ( + select 1 from pg_policies where tablename = 'note_mutes' and policyname = 'note_mutes_owner' + ) then + raise exception 'Die Eigentümerregel auf note_mutes fehlt.'; + end if; + + -- Der Selbstbezug muss abgewiesen werden. Ohne diese Prüfung liesse sich + -- die eigene Wiedervorlage ausblenden. + begin + insert into note_mutes (user_id, muted_user_id) + values ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001'); + raise exception 'Eine Person kann sich selbst abwaehlen — die Pruefbedingung greift nicht.'; + exception + when check_violation then null; -- so soll es sein + when foreign_key_violation then null; -- Fremdschluessel zuerst: ebenfalls abgewiesen + end; +end $$; diff --git a/lib/dashboard-data.ts b/lib/dashboard-data.ts index c8f7e5b..a94a55b 100644 --- a/lib/dashboard-data.ts +++ b/lib/dashboard-data.ts @@ -2,6 +2,7 @@ import type { Tx } from "./db"; import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json"; import { besetzungenAbfrage, pickPlacements } from "./placement"; import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org"; +import { sichtbareNotizen } from "./notes"; import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions"; import type { HistoryEventType } from "./types"; import type { AnstehendArt } from "./dashboard-filter"; @@ -117,6 +118,10 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) { .where("n.due_date", "is not", null) .where("n.due_date", "<=", bisIso) .where((x) => x.lit(zeigt("note"))) + // Dieselbe Regel wie in der Glocke (lib/notes.ts). Ohne sie zeigte + // die Karte „Anstehend" Wiedervorlagen von Kolleg:innen, die in der + // Glocke abgewählt sind — zwei Zahlen für dieselbe Frage. + .where(sichtbareNotizen(userId ?? "")) .orderBy("n.due_date") ).as("upcomingNotes"), diff --git a/lib/notes.ts b/lib/notes.ts index 0196927..360c293 100644 --- a/lib/notes.ts +++ b/lib/notes.ts @@ -1,3 +1,4 @@ +import { sql, type Expression, type SqlBool } from "kysely"; import type { Tx } from "./db"; import { jsonArrayFrom, zeitstempel } from "./db/json"; import { fmtName } from "./format"; @@ -16,14 +17,55 @@ export type NotizZeile = Omit { + const verfasser = sql.ref(spalte); + return sql`( + ${verfasser} is null + or ${verfasser} = ${userId} + or not exists ( + select 1 from note_mutes m + where m.user_id = ${userId} and m.muted_user_id = ${verfasser}))`; +} + /** * Die offenen Notizen als *Teilabfrage* — zum Einhängen in die eine Abfrage, * die eine Seite ohnehin stellt (lib/db/json.ts). @@ -32,7 +74,7 @@ export type NotizZeile = Omit [zeitstempel(x.ref("n.created_at")).as("created_at"), zeitstempel(x.ref("n.done_at")).as("done_at")]) .where("n.done", "=", false) + .where(sichtbareNotizen(userId)) .orderBy("n.created_at", "desc"); } @@ -65,9 +108,9 @@ export function baueOffeneNotizen(rows: NotizZeile[]): OpenNote[] { }); } -export async function loadOpenNotes(tx: Tx): Promise { +export async function loadOpenNotes(tx: Tx, userId: string): Promise { const { notes } = await tx - .selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes")]) + .selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb, userId)).as("notes")]) .executeTakeFirstOrThrow(); return baueOffeneNotizen(notes as NotizZeile[]); } diff --git a/lib/shell-data.ts b/lib/shell-data.ts index 923fdc1..db94724 100644 --- a/lib/shell-data.ts +++ b/lib/shell-data.ts @@ -28,6 +28,14 @@ export type ShellData = { locations: Location[]; drafts: { id: string; step: number; payload: Record; updated_at: string }[]; openNotes: OpenNote[]; + /** + * Die HR-Kolleg:innen für die Sichtbarkeitseinstellung der Glocke. + * + * `sichtbar` ist die Vorgabe für den Haken: ohne Eintrag in note_mutes + * steht er. Die eigene Person steht nicht in der Liste — die eigenen + * Notizen lassen sich nicht abwählen. + */ + kollegen: { id: string; name: string; sichtbar: boolean }[]; }; /** @@ -65,7 +73,23 @@ export async function loadShellData(tx: Tx, userId: string): Promise`app_muss_passwort_wechseln()`.as("passwortWechseln"), ...orgMapsAbfragen(eb), jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"), - jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes"), + 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 + // Hülle — die Einstellung steckt in der Glocke, also muss sie beim + // ersten Aufschlagen da sein. + jsonArrayFrom( + eb + .selectFrom("profiles") + .select(["id", "full_name", "email"]) + .where("role", "=", "hr") + .where("is_active", "=", true) + .where("id", "<>", userId) + .orderBy("full_name") + ).as("hrLeute"), + jsonArrayFrom( + eb.selectFrom("note_mutes").select("muted_user_id").where("user_id", "=", userId) + ).as("mutes"), jsonArrayFrom( eb .selectFrom("hire_drafts") @@ -100,6 +124,28 @@ export async function loadShellData(tx: Tx, userId: string): Promise m.muted_user_id) + ), }, }; } + +/** + * Der reine Teil: aus den Zeilen die Liste für die Einstellung. + * + * Ohne Namen die E-Mail — ein Haken ohne Beschriftung wäre einer, von dem + * niemand weiss, wen er betrifft. + */ +export function baueKollegen( + leute: { id: string; full_name: string | null; email: string }[], + abgewaehlt: string[] +): ShellData["kollegen"] { + const stumm = new Set(abgewaehlt); + return leute.map((p) => ({ + id: p.id, + name: p.full_name?.trim() || p.email, + sichtbar: !stumm.has(p.id), + })); +} diff --git a/lib/types.ts b/lib/types.ts index 40f05dc..12a5ca2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -576,6 +576,23 @@ export type Database = { // Organisation, über die Person die Verortung in der Akte. Die // Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage. }; + // Abgewählte Kolleg:innen je Person. Eine Zeile blendet deren Notizen + // aus; ohne Zeile ist alles sichtbar. Gespeichert wird das Abgewählte, + // nicht das Gewählte — sonst wäre eine neu hinzugekommene HR-Person für + // alle unsichtbar, bis jede sie einzeln anhakt. + note_mutes: { + Row: { + user_id: string; + muted_user_id: string; + created_at: string; + }; + Insert: { + user_id: string; + muted_user_id: string; + created_at?: string; + }; + Update: Partial; + }; }; Views: Record; Functions: { diff --git a/tests/components/NotesBell.test.tsx b/tests/components/NotesBell.test.tsx new file mode 100644 index 0000000..4f72909 --- /dev/null +++ b/tests/components/NotesBell.test.tsx @@ -0,0 +1,144 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NotesBell, type Kollege } from "@/components/shell/NotesBell"; +import { ToastProvider } from "@/components/ui/Toast"; +import type { OpenNote } from "@/lib/notes"; + +const setNotizSichtbarkeit = vi.fn(async () => ({ success: true }) as { success: boolean; error?: string }); +const refresh = vi.fn(); + +vi.mock("@/actions/notes", () => ({ setNotizSichtbarkeit: (...a: unknown[]) => setNotizSichtbarkeit(...(a as [])) })); +vi.mock("@/actions/employees", () => ({ completeEmployeeNote: vi.fn(async () => ({ success: true })) })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh, push: vi.fn() }) })); + +// Die Einstellung steckt in der Glocke, weil sie dort gebraucht wird: wer +// eine fremde Wiedervorlage sieht, die ihn nichts angeht, will sie an Ort +// und Stelle abwählen können — nicht auf einer Einstellungsseite. + +const KOLLEGEN: Kollege[] = [ + { id: "a", name: "Anna Berger", sichtbar: true }, + { id: "b", name: "Bernd Huber", sichtbar: false }, +]; + +function notiz(teil: Partial = {}): OpenNote { + return { + id: "n1", + employee_id: "e1", + author_user_id: "a", + author_name: "Anna Berger", + category: "Wiedervorlage", + note_text: "Dienstzettel nachfassen", + due_date: "2026-09-20", + done: false, + done_by: null, + done_at: null, + created_at: "2026-09-01T08:00:00.000Z", + employeeName: "Manuel Aigner", + ...teil, + } as OpenNote; +} + +function zeige(kollegen = KOLLEGEN, notes = [notiz()]) { + return render( + + + + ); +} + +async function oeffneEinstellung(user: ReturnType) { + await user.click(screen.getByRole("button", { name: /Meine Notizen/i })); + await user.click(screen.getByRole("button", { name: /Kolleg:innen/i })); +} + +beforeEach(() => { + setNotizSichtbarkeit.mockClear(); + setNotizSichtbarkeit.mockResolvedValue({ success: true }); + refresh.mockClear(); +}); + +describe("Sichtbarkeit der Kolleg:innen", () => { + it("zeigt die Einstellung erst auf Klick", async () => { + const user = userEvent.setup(); + zeige(); + await user.click(screen.getByRole("button", { name: /Meine Notizen/i })); + expect(screen.queryByRole("checkbox", { name: "Anna Berger" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Kolleg:innen/i })); + expect(screen.getByRole("checkbox", { name: "Anna Berger" })).toBeInTheDocument(); + }); + + it("spiegelt den gespeicherten Stand in den Haken", async () => { + const user = userEvent.setup(); + zeige(); + await oeffneEinstellung(user); + expect(screen.getByRole("checkbox", { name: "Anna Berger" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Bernd Huber" })).not.toBeChecked(); + }); + + it("meldet beim Abwählen die Kennung und den neuen Stand", async () => { + const user = userEvent.setup(); + zeige(); + await oeffneEinstellung(user); + await user.click(screen.getByRole("checkbox", { name: "Anna Berger" })); + + expect(setNotizSichtbarkeit).toHaveBeenCalledWith({ kollegeId: "a", sichtbar: false }); + expect(screen.getByRole("checkbox", { name: "Anna Berger" })).not.toBeChecked(); + }); + + it("wählt beim zweiten Klick wieder an", async () => { + const user = userEvent.setup(); + zeige(); + await oeffneEinstellung(user); + await user.click(screen.getByRole("checkbox", { name: "Bernd Huber" })); + + expect(setNotizSichtbarkeit).toHaveBeenCalledWith({ kollegeId: "b", sichtbar: true }); + expect(screen.getByRole("checkbox", { name: "Bernd Huber" })).toBeChecked(); + }); + + it("dreht den Haken zurück, wenn das Speichern scheitert", async () => { + // Der Haken springt sofort um, damit sich das Durchgehen der Liste nicht + // zäh anfühlt. Bleibt er stehen, obwohl nichts gespeichert wurde, zeigt + // die Einstellung etwas anderes als die Datenbank. + setNotizSichtbarkeit.mockResolvedValue({ success: false, error: "Nicht gespeichert." }); + const user = userEvent.setup(); + zeige(); + await oeffneEinstellung(user); + await user.click(screen.getByRole("checkbox", { name: "Anna Berger" })); + + expect(screen.getByRole("checkbox", { name: "Anna Berger" })).toBeChecked(); + expect(await screen.findByText("Nicht gespeichert.")).toBeInTheDocument(); + }); + + it("nennt die Zahl der Ausgeblendeten nur, wenn eingeschränkt ist", async () => { + const user = userEvent.setup(); + const { unmount } = zeige(); + await user.click(screen.getByRole("button", { name: /Meine Notizen/i })); + expect(screen.getByRole("button", { name: /Kolleg:innen/i })).toHaveTextContent("(1 aus)"); + unmount(); + + zeige(KOLLEGEN.map((k) => ({ ...k, sichtbar: true }))); + await user.click(screen.getByRole("button", { name: /Meine Notizen/i })); + expect(screen.getByRole("button", { name: /Kolleg:innen/i })).not.toHaveTextContent("aus)"); + }); + + it("kommt ohne Kolleg:innen zurecht", async () => { + // Eine Personalabteilung aus einer Person: die Liste ist leer, und ein + // Kasten ohne Inhalt wäre eine Sackgasse. + const user = userEvent.setup(); + zeige([]); + await oeffneEinstellung(user); + expect(screen.getByText(/Keine weiteren HR-Kolleg:innen/i)).toBeInTheDocument(); + }); + + it("lässt die Notizen selbst stehen, während die Einstellung offen ist", async () => { + // Die Einstellung schiebt sich über die Liste, sie ersetzt sie nicht: + // wer abwählt, will sehen, was daraufhin verschwindet. + const user = userEvent.setup(); + zeige(); + await oeffneEinstellung(user); + expect(screen.getByText("Dienstzettel nachfassen")).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Anna Berger" })).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/notes-visibility.test.ts b/tests/unit/notes-visibility.test.ts new file mode 100644 index 0000000..fa66bd2 --- /dev/null +++ b/tests/unit/notes-visibility.test.ts @@ -0,0 +1,120 @@ +import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from "kysely"; +import { describe, expect, it } from "vitest"; +import type { Schema } from "@/lib/db/schema"; +import { sichtbareNotizen } from "@/lib/notes"; +import { baueKollegen } from "@/lib/shell-data"; + +// Wessen Notizen jemand sieht, ist die eine Stelle, an der ein Fehler nicht +// auffällt: die Glocke zeigt weiter eine Zahl, nur die falsche. Zu wenig, und +// eine Wiedervorlage bleibt liegen; zu viel, und die Einstellung wirkt nicht. +// Deshalb wird hier die Abfrage gelesen, die tatsächlich herauskommt. + +const db = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (d) => new PostgresIntrospector(d), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, +}); + +const ICH = "11111111-1111-1111-1111-111111111111"; + +function abfrage(userId = ICH) { + return db + .selectFrom("employee_notes as n") + .select("n.id") + .where(sichtbareNotizen(userId)) + .compile(); +} + +const sql = (userId?: string) => abfrage(userId).sql.replace(/\s+/g, " "); + +describe("sichtbareNotizen", () => { + it("lässt Notizen ohne Verfasser durch", () => { + // author_user_id kann leer sein. Eine Notiz auszublenden, weil niemand + // weiss, von wem sie ist, wäre genau der stille Verlust, den die Liste + // verhindern soll. + expect(sql()).toContain('"n"."author_user_id" is null'); + }); + + it("lässt die eigenen Notizen immer durch", () => { + expect(sql()).toContain('"n"."author_user_id" = $'); + }); + + it("blendet nur aus, wer ausdrücklich abgewählt wurde", () => { + const s = sql(); + expect(s).toContain("not exists"); + expect(s).toContain("from note_mutes m"); + expect(s).toContain("m.user_id = $"); + expect(s).toContain("m.muted_user_id = \"n\".\"author_user_id\""); + }); + + it("verknüpft die drei Fälle mit ODER, nicht mit UND", () => { + // Mit UND sähe niemand mehr etwas: keine Notiz ist gleichzeitig ohne + // Verfasser und von mir. + const s = sql(); + expect(s).toMatch(/is null\s+or/); + expect(s).not.toMatch(/is null\s+and/); + }); + + it("bindet die Kennung als Parameter, nicht in den Text", () => { + // Sie kommt aus der Sitzung, nicht aus der Adresse — trotzdem hat sie im + // Abfragetext nichts verloren. + const { sql: text, parameters } = abfrage("bösartig'; drop table employee_notes; --"); + expect(text).not.toContain("drop table"); + expect(parameters).toContain("bösartig'; drop table employee_notes; --"); + }); + + it("nennt die Kennung zweimal — für die eigenen Notizen und für die Ausnahmen", () => { + expect(abfrage().parameters.filter((p) => p === ICH)).toHaveLength(2); + }); + + it("lässt sich auf einen anderen Aliasnamen setzen", () => { + // Die Übersicht bindet dieselbe Tabelle ein; käme sie je unter anderem + // Namen, muss die Regel mitkönnen statt kopiert zu werden. + const s = db + .selectFrom("employee_notes as notiz") + .select("notiz.id") + .where(sichtbareNotizen(ICH, "notiz.author_user_id")) + .compile().sql; + expect(s).toContain('"notiz"."author_user_id"'); + }); +}); + +describe("baueKollegen", () => { + const leute = [ + { id: "a", full_name: "Anna Berger", email: "a@example.test" }, + { id: "b", full_name: null, email: "b@example.test" }, + { id: "c", full_name: " ", email: "c@example.test" }, + ]; + + it("hakt an, wer nicht abgewählt ist", () => { + // Der Standard ist „alle sichtbar": gespeichert wird das Abgewählte. + expect(baueKollegen(leute, []).map((k) => k.sichtbar)).toEqual([true, true, true]); + }); + + it("nimmt den Haken weg, wo eine Ausnahme steht", () => { + expect(baueKollegen(leute, ["b"]).map((k) => [k.id, k.sichtbar])).toEqual([ + ["a", true], + ["b", false], + ["c", true], + ]); + }); + + it("fällt ohne Namen auf die E-Mail zurück", () => { + // Ein Haken ohne Beschriftung wäre einer, von dem niemand weiss, wen er + // betrifft. Auch ein Name aus Leerzeichen zählt als keiner. + expect(baueKollegen(leute, []).map((k) => k.name)).toEqual([ + "Anna Berger", + "b@example.test", + "c@example.test", + ]); + }); + + it("kommt mit einer Ausnahme zurecht, zu der es niemanden mehr gibt", () => { + // Wer die Personalabteilung verlässt, verschwindet aus der Liste; die + // Zeile in note_mutes bleibt, bis der Fremdschlüssel sie räumt. + expect(baueKollegen(leute, ["längst-weg"])).toHaveLength(3); + }); +});