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; 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 { 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`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), })); }