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:
@@ -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"),
|
||||
|
||||
|
||||
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 { jsonArrayFrom, zeitstempel } from "./db/json";
|
||||
import { fmtName } from "./format";
|
||||
@@ -16,14 +17,55 @@ export type NotizZeile = Omit<Database["public"]["Tables"]["employee_notes"]["Ro
|
||||
last_name: string | null;
|
||||
};
|
||||
|
||||
// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
||||
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
|
||||
// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
|
||||
// „Meine Notizen" (Topbar-Glocke): das gemeinsame, mitarbeiterübergreifende
|
||||
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, zu wem
|
||||
// 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
|
||||
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
|
||||
// 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 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
|
||||
* Unterschied fällt erst beim Vergleichen auf.
|
||||
*/
|
||||
export function offeneNotizenAbfrage(eb: OrgEb) {
|
||||
export function offeneNotizenAbfrage(eb: OrgEb, userId: string) {
|
||||
return eb
|
||||
.selectFrom("employee_notes as n")
|
||||
.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")])
|
||||
.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<OpenNote[]> {
|
||||
export async function loadOpenNotes(tx: Tx, userId: string): Promise<OpenNote[]> {
|
||||
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[]);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ export type ShellData = {
|
||||
locations: Location[];
|
||||
drafts: { id: string; step: number; payload: Record<string, unknown>; 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<ShellErgebn
|
||||
sql<boolean>`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<ShellErgebn
|
||||
locations: gelesen.locations as Location[],
|
||||
drafts: gelesen.drafts as ShellData["drafts"],
|
||||
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
|
||||
// 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>;
|
||||
Functions: {
|
||||
|
||||
Reference in New Issue
Block a user