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[]; }; /** * 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)).as("notes"), 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[]), }, }; }