Choose whose notes reach your bell
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 <noreply@anthropic.com>
This commit is contained in:
144
tests/components/NotesBell.test.tsx
Normal file
144
tests/components/NotesBell.test.tsx
Normal file
@@ -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> = {}): 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(
|
||||
<ToastProvider>
|
||||
<NotesBell notes={notes} kollegen={kollegen} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function oeffneEinstellung(user: ReturnType<typeof userEvent.setup>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
120
tests/unit/notes-visibility.test.ts
Normal file
120
tests/unit/notes-visibility.test.ts
Normal file
@@ -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<Schema>({
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user