Files
alpenwerk-hr/lib/shell-data.ts
Maximilian Stubhan 99e4357c02
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m18s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m45s
Let colleagues finish each other's drafts, one at a time
Seeing a colleague's draft turned out to be half a feature: the point of
sharing it is to finish it while they are away. So writing is allowed
now -- but never by two people at once.

A draft is a single JSONB field. Whoever saves writes the whole state,
not the changed field, so two open wizards overwrite each other
completely and the second person sees nothing wrong: their own state is
right there on screen. That is why writing stayed with the owner until
now, and a lock is what makes giving that up safe.

The lock lives in the row (locked_by, locked_at) and is enforced by the
update and delete policies, not by the application. It expires, and that
is the important half: releasing happens when the wizard closes, and a
closed laptop never closes a wizard. Without expiry one crashed tab
would take a draft away for good -- worse than the problem being solved.
The wizard refreshes its lock while open so a long form does not lose it
mid-way.

Delete had to widen too, which reads like more than was asked for: the
wizard deletes the draft once the person is hired. Without it the hire
would go through and the draft would sit there forever. The card still
only offers delete on your own drafts.

Four of five mutations against the lock go red. The fifth -- dropping
`!open` from the refresh guard -- does not, because freigeben() already
nulls the ref the interval checks. The condition stays as the readable
statement of intent, now with a comment saying so.

Not run against a live database here; the CI migration job is the first
real execution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:43:43 +02:00

155 lines
6.5 KiB
TypeScript

import { sql } from "kysely";
import type { Tx } from "./db";
import { jsonArrayFrom, jsonObjectFrom } from "./db/json";
import { baueEntwuerfe, entwuerfeAbfrage, type Entwurf, type EntwurfZeile } from "./entwuerfe";
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: Entwurf[];
openNotes: OpenNote[];
/**
* Die HR-Kolleg:innen für die Sichtbarkeitseinstellung der Glocke.
*
* `sichtbar` ist der Stand des Hakens: gesetzt, wenn die Person
* hinzugewählt ist. Die eigene Person steht nicht in der Liste — die
* eigenen Notizen und Entwürfe sind immer dabei.
*/
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
// eigene Auswahl. 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("colleague_subscriptions").select("author_user_id").where("user_id", "=", userId)
).as("abos"),
// Dieselbe Liste wie auf der Übersicht, aus derselben Abfrage: die
// eigenen und die der hinzugewählten Kolleg:innen.
//
// Sie füttert den Einstellungsassistenten (HireWizardProvider), und was
// er darin findet, lässt sich fortsetzen. Bis zur Sperre standen hier
// nur die eigenen — ein fremder Entwurf wäre umsonst durchlaufen
// worden, weil das Speichern an der Regel gescheitert wäre. Seit es
// die Sperre gibt (20260910160000), ist Fortsetzen erlaubt, solange
// niemand anderes drin ist.
jsonArrayFrom(entwuerfeAbfrage(eb, userId)).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: baueEntwuerfe(gelesen.drafts as EntwurfZeile[], userId),
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[], userId),
kollegen: baueKollegen(
gelesen.hrLeute as { id: string; full_name: string | null; email: string }[],
(gelesen.abos as { author_user_id: string }[]).map((a) => a.author_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 }[],
hinzugewaehlt: string[]
): ShellData["kollegen"] {
const dabei = new Set(hinzugewaehlt);
return leute.map((p) => ({
id: p.id,
name: p.full_name?.trim() || p.email,
sichtbar: dabei.has(p.id),
}));
}