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); + }); +});