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>
152 lines
6.1 KiB
TypeScript
152 lines
6.1 KiB
TypeScript
import { sql } from "kysely";
|
|
import type { Tx } from "./db";
|
|
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
|
|
import { todayIso } from "./format";
|
|
import { baueOffeneNotizen, offeneNotizenAbfrage, type NotizZeile, type OpenNote } from "./notes";
|
|
import { buildOrgMaps, orgMapsAbfragen, type Location } from "./org";
|
|
import {
|
|
offeneStellenAbfrage,
|
|
resolveOpenPositions,
|
|
type OffeneStelle,
|
|
type OpenPositionResolved,
|
|
} from "./positions";
|
|
|
|
// Was die Hülle jeder Seite braucht — in zwei Rundreisen statt in sechs.
|
|
//
|
|
// Vorher stand das im Layout selbst, als Promise.all, das wie Gleichzeitigkeit
|
|
// aussah und keine war: eine Transaktion hängt an einer Verbindung, und über
|
|
// eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms Umlaufzeit
|
|
// kostete diese Hülle — die **jede** Seite mitlädt — eine halbe Sekunde
|
|
// Warten für ein paar Kilobyte. Der Weg dahin steht in lib/db/json.ts.
|
|
//
|
|
// Hier und nicht im Layout, damit sich die Zahl der Rundreisen messen lässt,
|
|
// ohne eine React-Komponente aufzubauen.
|
|
|
|
export type ShellData = {
|
|
profile: { full_name: string | null; email: string | null; role: string | null; is_active: boolean | null };
|
|
openPositions: OpenPositionResolved[];
|
|
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 }[];
|
|
};
|
|
|
|
/**
|
|
* Drei Ausgänge, nicht zwei.
|
|
*
|
|
* Seit es die Anmeldung mit Passwort gibt, ist „darf nicht hinein" nicht mehr
|
|
* dasselbe wie „hat keinen Zugang": wer sein Startpasswort noch nicht
|
|
* gewechselt hat, hat einen Zugang und kommt trotzdem an keine Zeile, weil
|
|
* is_hr_user() das mitprüft (Migration 20260908120000). Unterschieden werden
|
|
* muss es, weil die beiden Fälle verschiedene Auswege haben — der eine wartet
|
|
* auf HR, der andere ist in einer Minute erledigt.
|
|
*/
|
|
export type ShellErgebnis =
|
|
| { status: "kein_zugang" }
|
|
| { status: "passwort_wechseln" }
|
|
| { status: "ok"; daten: ShellData };
|
|
|
|
/**
|
|
* Die Zugangsprüfung fragt gleichzeitig mit dem Rest statt davor. Das liest
|
|
* ein paar Zeilen mehr, als eine gesperrte Person sehen dürfte, wirft sie aber
|
|
* weg, ohne sie je auszuliefern — und die eigentliche Grenze ist ohnehin RLS,
|
|
* nicht die Reihenfolge hier.
|
|
*/
|
|
export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebnis> {
|
|
const asOf = todayIso();
|
|
|
|
const gelesen = await tx
|
|
.selectNoFrom((eb) => [
|
|
jsonObjectFrom(
|
|
eb.selectFrom("profiles").select(["full_name", "email", "role", "is_active"]).where("id", "=", userId)
|
|
).as("profile"),
|
|
// Eine Spalte mehr in derselben Abfrage, keine zusätzliche Rundreise.
|
|
// Direkt aus app_passwoerter zu lesen ginge nicht: die Tabelle ist für
|
|
// die Anwendungsrolle gesperrt, an sie kommt nur diese Funktion.
|
|
sql<boolean>`app_muss_passwort_wechseln()`.as("passwortWechseln"),
|
|
...orgMapsAbfragen(eb),
|
|
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
|
|
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")
|
|
.select(["id", "step", "payload"])
|
|
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
|
.where("created_by", "=", userId)
|
|
.orderBy("updated_at", "desc")
|
|
).as("drafts"),
|
|
])
|
|
.executeTakeFirstOrThrow();
|
|
|
|
const profile = gelesen.profile;
|
|
|
|
// Der Passwortwechsel steht **vor** der HR-Prüfung, obwohl er der seltenere
|
|
// Fall ist: er ist der einzige, den die betroffene Person selbst beheben
|
|
// kann. Wer beides hat — offener Wechsel und keine Freischaltung — bekommt
|
|
// danach immer noch „Kein HR-Zugriff", aber wenigstens in dieser Reihenfolge
|
|
// und nicht als Sackgasse.
|
|
if (gelesen.passwortWechseln) return { status: "passwort_wechseln" };
|
|
|
|
if (profile?.role !== "hr" || profile?.is_active !== true) return { status: "kein_zugang" };
|
|
|
|
const orgMaps = buildOrgMaps(gelesen.units as never, gelesen.locations as never);
|
|
|
|
return {
|
|
status: "ok",
|
|
daten: {
|
|
profile,
|
|
// Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen
|
|
// offen sind — das lässt sich nicht in die erste ziehen.
|
|
openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf),
|
|
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),
|
|
}));
|
|
}
|