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:
61
actions/notes.ts
Normal file
61
actions/notes.ts
Normal file
@@ -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<ActionResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
|
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
|
||||||
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
|
<AppShell userLabel={userLabel} openNotes={data.openNotes} kollegen={data.kollegen}>
|
||||||
{children}
|
{children}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</HireWizardProvider>
|
</HireWizardProvider>
|
||||||
|
|||||||
@@ -8,7 +8,17 @@ import { Topbar } from "./Topbar";
|
|||||||
// Owns the one piece of state the shell needs (is the mobile drawer open),
|
// 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
|
// so app/(app)/layout.tsx can stay a Server Component and keep doing its
|
||||||
// auth check and data loading on the server.
|
// 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 [navOpen, setNavOpen] = useState(false);
|
||||||
const closeNav = useCallback(() => setNavOpen(false), []);
|
const closeNav = useCallback(() => setNavOpen(false), []);
|
||||||
|
|
||||||
@@ -27,7 +37,7 @@ export function AppShell({ userLabel, openNotes, children }: { userLabel: string
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-dvh flex-col lg:ml-[236px] print:ml-0 print:block print:min-h-0">
|
<div className="flex min-h-dvh flex-col lg:ml-[236px] print:ml-0 print:block print:min-h-0">
|
||||||
<div className="print:hidden">
|
<div className="print:hidden">
|
||||||
<Topbar userLabel={userLabel} openNotes={openNotes} onOpenNav={() => setNavOpen(true)} />
|
<Topbar userLabel={userLabel} openNotes={openNotes} kollegen={kollegen} onOpenNav={() => setNavOpen(true)} />
|
||||||
</div>
|
</div>
|
||||||
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6 print:p-0">
|
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6 print:p-0">
|
||||||
<div className="mx-auto w-full max-w-[1280px] print:max-w-none">{children}</div>
|
<div className="mx-auto w-full max-w-[1280px] print:max-w-none">{children}</div>
|
||||||
|
|||||||
@@ -1,24 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Bell } from "lucide-react";
|
import { Bell, Users } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { completeEmployeeNote } from "@/actions/employees";
|
import { completeEmployeeNote } from "@/actions/employees";
|
||||||
|
import { setNotizSichtbarkeit } from "@/actions/notes";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
|
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
|
||||||
import { fmtDate } from "@/lib/format";
|
import { fmtDate } from "@/lib/format";
|
||||||
import type { OpenNote } from "@/lib/notes";
|
import type { OpenNote } from "@/lib/notes";
|
||||||
|
|
||||||
|
export type Kollege = { id: string; name: string; sichtbar: boolean };
|
||||||
|
|
||||||
// Click-outside mechanics borrowed from CountryPicker (useRef + mousedown
|
// Click-outside mechanics borrowed from CountryPicker (useRef + mousedown
|
||||||
// listener) — without its draft-text reset, which has no equivalent here.
|
// 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 { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [completingId, setCompletingId] = useState<string | null>(null);
|
const [completingId, setCompletingId] = useState<string | null>(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<Record<string, boolean>>({});
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
function onClickOutside(e: MouseEvent) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
@@ -54,7 +77,53 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]">
|
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]">
|
||||||
<div className="border-b border-border px-4 py-2.5 text-xs font-bold uppercase tracking-wide text-ink-muted">Meine Notizen ({notes.length})</div>
|
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide text-ink-muted">
|
||||||
|
Meine Notizen ({notes.length})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZeigeEinstellung((v) => !v)}
|
||||||
|
aria-expanded={zeigeEinstellung}
|
||||||
|
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||||
|
zeigeEinstellung ? "text-brand-700" : "text-ink-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
Kolleg:innen
|
||||||
|
{/* Nur wenn eingeschränkt: sonst ist die Zahl bei jedem
|
||||||
|
Aufschlagen zu lesen, ohne je etwas zu bedeuten. */}
|
||||||
|
{ausgeblendet > 0 && <span className="text-warning-text">({ausgeblendet} aus)</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zeigeEinstellung && (
|
||||||
|
<div className="border-b border-border bg-surface px-4 py-3">
|
||||||
|
<p className="mb-2 text-xs text-ink-muted">
|
||||||
|
Wessen Notizen hier 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>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{kollegen.map((k) => (
|
||||||
|
<li key={k.id}>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-sm hover:bg-white">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sichtbarkeit(k)}
|
||||||
|
onChange={() => schalte(k)}
|
||||||
|
className="h-4 w-4 rounded border-border text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
/>
|
||||||
|
<span className={sichtbarkeit(k) ? "text-ink" : "text-ink-muted line-through"}>{k.name}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<p className="px-4 py-6 text-center text-sm text-ink-muted">Keine offenen Notizen.</p>
|
<p className="px-4 py-6 text-center text-sm text-ink-muted">Keine offenen Notizen.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ function titleFor(pathname: string): string {
|
|||||||
type TopbarProps = {
|
type TopbarProps = {
|
||||||
userLabel: string;
|
userLabel: string;
|
||||||
openNotes: OpenNote[];
|
openNotes: OpenNote[];
|
||||||
|
kollegen: { id: string; name: string; sichtbar: boolean }[];
|
||||||
onOpenNav: () => void;
|
onOpenNav: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) {
|
export function Topbar({ userLabel, openNotes, kollegen, onOpenNav }: TopbarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +48,7 @@ export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) {
|
|||||||
<h1 className="truncate text-base font-bold text-ink">{titleFor(pathname)}</h1>
|
<h1 className="truncate text-base font-bold text-ink">{titleFor(pathname)}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1 sm:gap-4">
|
<div className="flex shrink-0 items-center gap-1 sm:gap-4">
|
||||||
<NotesBell notes={openNotes} />
|
<NotesBell notes={openNotes} kollegen={kollegen} />
|
||||||
<NewHireButton />
|
<NewHireButton />
|
||||||
<div className="flex items-center gap-2 text-sm sm:border-l sm:border-border sm:pl-4">
|
<div className="flex items-center gap-2 text-sm sm:border-l sm:border-border sm:pl-4">
|
||||||
{/* The name is the first thing worth dropping on a narrow screen —
|
{/* The name is the first thing worth dropping on a narrow screen —
|
||||||
|
|||||||
83
db/migrations/20260909100000_notiz_sichtbarkeit.sql
Normal file
83
db/migrations/20260909100000_notiz_sichtbarkeit.sql
Normal file
@@ -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 $$;
|
||||||
@@ -2,6 +2,7 @@ import type { Tx } from "./db";
|
|||||||
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
|
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
|
||||||
import { besetzungenAbfrage, pickPlacements } from "./placement";
|
import { besetzungenAbfrage, pickPlacements } from "./placement";
|
||||||
import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org";
|
import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org";
|
||||||
|
import { sichtbareNotizen } from "./notes";
|
||||||
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
|
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
|
||||||
import type { HistoryEventType } from "./types";
|
import type { HistoryEventType } from "./types";
|
||||||
import type { AnstehendArt } from "./dashboard-filter";
|
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", "is not", null)
|
||||||
.where("n.due_date", "<=", bisIso)
|
.where("n.due_date", "<=", bisIso)
|
||||||
.where((x) => x.lit(zeigt("note")))
|
.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")
|
.orderBy("n.due_date")
|
||||||
).as("upcomingNotes"),
|
).as("upcomingNotes"),
|
||||||
|
|
||||||
|
|||||||
55
lib/notes.ts
55
lib/notes.ts
@@ -1,3 +1,4 @@
|
|||||||
|
import { sql, type Expression, type SqlBool } from "kysely";
|
||||||
import type { Tx } from "./db";
|
import type { Tx } from "./db";
|
||||||
import { jsonArrayFrom, zeitstempel } from "./db/json";
|
import { jsonArrayFrom, zeitstempel } from "./db/json";
|
||||||
import { fmtName } from "./format";
|
import { fmtName } from "./format";
|
||||||
@@ -16,14 +17,55 @@ export type NotizZeile = Omit<Database["public"]["Tables"]["employee_notes"]["Ro
|
|||||||
last_name: string | null;
|
last_name: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
// „Meine Notizen" (Topbar-Glocke): das gemeinsame, mitarbeiterübergreifende
|
||||||
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
|
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, zu wem
|
||||||
// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
|
// sie gehören (mit Nutzer abgestimmt).
|
||||||
|
//
|
||||||
|
// Seit note_mutes lässt sich einschränken, *wessen* Notizen man sehen will.
|
||||||
|
// Der Standard bleibt „alle": wer niemanden abwählt, sieht denselben Topf wie
|
||||||
|
// vorher.
|
||||||
//
|
//
|
||||||
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
|
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
|
||||||
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
|
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
|
||||||
// direkten Zugang ist es schlicht ein Join.
|
// direkten Zugang ist es schlicht ein Join.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wessen Notizen diese Person sehen will.
|
||||||
|
*
|
||||||
|
* Der Standard ist unverändert: alle. Wer niemanden abgewählt hat, sieht
|
||||||
|
* weiterhin den gemeinsamen Topf — die Einstellung engt ein, sie öffnet
|
||||||
|
* nichts.
|
||||||
|
*
|
||||||
|
* Zwei Dinge, die zusammengehören:
|
||||||
|
*
|
||||||
|
* 1. **Die eigenen Notizen immer.** Sie sind der Grund, warum es die Glocke
|
||||||
|
* gibt. `note_mutes` lässt einen Selbstbezug ohnehin nicht zu; die
|
||||||
|
* Bedingung steht hier trotzdem, damit die Zusage nicht davon abhängt,
|
||||||
|
* dass eine Prüfbedingung an anderer Stelle bestehen bleibt.
|
||||||
|
*
|
||||||
|
* 2. **Notizen ohne Verfasser bleiben sichtbar.** `author_user_id` kann leer
|
||||||
|
* sein. Eine Notiz auszublenden, weil niemand weiss, von wem sie ist,
|
||||||
|
* wäre genau der stille Verlust, den diese Liste verhindern soll.
|
||||||
|
*
|
||||||
|
* Steht als eigener Ausdruck hier und nicht in der jeweiligen Seite, weil
|
||||||
|
* ihn zwei Stellen brauchen: die Glocke in der Kopfzeile und die Karte
|
||||||
|
* „Anstehend" auf der Übersicht. Zwei Fassungen derselben Regel driften
|
||||||
|
* auseinander, und die Übersicht zeigte dann etwas anderes als die Glocke.
|
||||||
|
*
|
||||||
|
* Der Vorgabewert für `spalte` setzt voraus, dass die Notiztabelle als `n`
|
||||||
|
* eingebunden ist — so machen es beide Aufrufer. Wer anders aliasiert, gibt
|
||||||
|
* den Namen mit.
|
||||||
|
*/
|
||||||
|
export function sichtbareNotizen(userId: string, spalte = "n.author_user_id"): Expression<SqlBool> {
|
||||||
|
const verfasser = sql.ref(spalte);
|
||||||
|
return sql<SqlBool>`(
|
||||||
|
${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 offenen Notizen als *Teilabfrage* — zum Einhängen in die eine Abfrage,
|
||||||
* die eine Seite ohnehin stellt (lib/db/json.ts).
|
* die eine Seite ohnehin stellt (lib/db/json.ts).
|
||||||
@@ -32,7 +74,7 @@ export type NotizZeile = Omit<Database["public"]["Tables"]["employee_notes"]["Ro
|
|||||||
* formatiert Postgres sie anders als der Treiber es sonst täte, und der
|
* formatiert Postgres sie anders als der Treiber es sonst täte, und der
|
||||||
* Unterschied fällt erst beim Vergleichen auf.
|
* Unterschied fällt erst beim Vergleichen auf.
|
||||||
*/
|
*/
|
||||||
export function offeneNotizenAbfrage(eb: OrgEb) {
|
export function offeneNotizenAbfrage(eb: OrgEb, userId: string) {
|
||||||
return eb
|
return eb
|
||||||
.selectFrom("employee_notes as n")
|
.selectFrom("employee_notes as n")
|
||||||
.leftJoin("employees as e", "e.id", "n.employee_id")
|
.leftJoin("employees as e", "e.id", "n.employee_id")
|
||||||
@@ -51,6 +93,7 @@ export function offeneNotizenAbfrage(eb: OrgEb) {
|
|||||||
])
|
])
|
||||||
.select((x) => [zeitstempel(x.ref("n.created_at")).as("created_at"), zeitstempel(x.ref("n.done_at")).as("done_at")])
|
.select((x) => [zeitstempel(x.ref("n.created_at")).as("created_at"), zeitstempel(x.ref("n.done_at")).as("done_at")])
|
||||||
.where("n.done", "=", false)
|
.where("n.done", "=", false)
|
||||||
|
.where(sichtbareNotizen(userId))
|
||||||
.orderBy("n.created_at", "desc");
|
.orderBy("n.created_at", "desc");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +108,9 @@ export function baueOffeneNotizen(rows: NotizZeile[]): OpenNote[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
|
export async function loadOpenNotes(tx: Tx, userId: string): Promise<OpenNote[]> {
|
||||||
const { notes } = await tx
|
const { notes } = await tx
|
||||||
.selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes")])
|
.selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb, userId)).as("notes")])
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
return baueOffeneNotizen(notes as NotizZeile[]);
|
return baueOffeneNotizen(notes as NotizZeile[]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export type ShellData = {
|
|||||||
locations: Location[];
|
locations: Location[];
|
||||||
drafts: { id: string; step: number; payload: Record<string, unknown>; updated_at: string }[];
|
drafts: { id: string; step: number; payload: Record<string, unknown>; updated_at: string }[];
|
||||||
openNotes: OpenNote[];
|
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<ShellErgebn
|
|||||||
sql<boolean>`app_muss_passwort_wechseln()`.as("passwortWechseln"),
|
sql<boolean>`app_muss_passwort_wechseln()`.as("passwortWechseln"),
|
||||||
...orgMapsAbfragen(eb),
|
...orgMapsAbfragen(eb),
|
||||||
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
|
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(
|
jsonArrayFrom(
|
||||||
eb
|
eb
|
||||||
.selectFrom("hire_drafts")
|
.selectFrom("hire_drafts")
|
||||||
@@ -100,6 +124,28 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebn
|
|||||||
locations: gelesen.locations as Location[],
|
locations: gelesen.locations as Location[],
|
||||||
drafts: gelesen.drafts as ShellData["drafts"],
|
drafts: gelesen.drafts as ShellData["drafts"],
|
||||||
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]),
|
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]),
|
||||||
|
kollegen: baueKollegen(
|
||||||
|
gelesen.hrLeute as { id: string; full_name: string | null; email: string }[],
|
||||||
|
(gelesen.mutes as { muted_user_id: string }[]).map((m) => 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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
17
lib/types.ts
17
lib/types.ts
@@ -576,6 +576,23 @@ export type Database = {
|
|||||||
// Organisation, über die Person die Verortung in der Akte. Die
|
// Organisation, über die Person die Verortung in der Akte. Die
|
||||||
// Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage.
|
// 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<Database["public"]["Tables"]["note_mutes"]["Insert"]>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
Views: Record<string, never>;
|
Views: Record<string, never>;
|
||||||
Functions: {
|
Functions: {
|
||||||
|
|||||||
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