Files
alpenwerk-hr/lib/shell-data.ts
Maximilian Stubhan e8e675fd07
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Turn the note filter around: yours by default, colleagues added
Yesterday's version had it the other way — everyone visible, untick to
hide. The decision from the business side is the opposite: you see your
own notes, and you tick the colleagues you also want. So note_mutes
becomes note_subscriptions and the predicate flips from `not exists` to
`exists`.

The existing rows are not carried over. The meaning inverts rather than
the sign: converting faithfully ("everyone except the muted") would write
almost the whole roster into the new table and reproduce exactly the state
the change is meant to end. Anyone opening the setting tomorrow would
think it had not taken effect. The table is a day old; what is lost is a
few ticks from trying it out.

What this costs is worth saying plainly: the silent case that could not
happen under exceptions can happen now. Do not tick a colleague and you
will not see her follow-ups — not while she is on holiday either. That is
the flip side of the decision, and it is written down in the migration
rather than discovered later.

Each note now says who wrote it. Own notes read "von mir" rather than
repeating your own name, which would sit on every second line and tell
nobody anything. The flag is computed on the server: the user id is
already there, and threading it through four components for one word is a
poor trade. The counter on the button follows the same turn — "+2" for
what you added, nothing when you added nothing.

Verified: 21 tests, five mutation-checked (restoring `not exists`,
dropping the own-notes clause, inverting the default, hiding the author,
and printing your own name instead of "von mir" each turn them red). 489
tests, typecheck, lint, schema drift and build clean. The migration is
reviewed but not run — no reachable database here.

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

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 der Stand des Hakens: gesetzt, wenn die Person
* hinzugewählt ist. Die eigene Person steht nicht in der Liste — die
* eigenen Notizen 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
// 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_subscriptions").select("author_user_id").where("user_id", "=", userId)
).as("abos"),
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[], 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),
}));
}