Files
alpenwerk-hr/lib/shell-data.ts
Andrei Laas c6cff9656e
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m3s
Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell
2026-09-08 16:51:14 +02:00

106 lines
4.4 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[];
};
/**
* 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)).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[]),
},
};
}