The picker in the bell now governs both lists, so note_subscriptions is renamed to colleague_subscriptions -- a name that only mentions notes would mislead the next reader. Reading and writing a draft now reach differently far. hire_drafts_owner (for all) is split into four policies: select lets in your own drafts and those of the people you added, while insert/update/delete stay with the owner. A draft is unfinished work with no lock and no history; two people writing into the same row would overwrite each other silently. That split forces a change in the actions: a policy does not reject a write, it lets it hit no rows. saveHireDraft and deleteHireDraft now read the row count instead of reporting success over a row that never changed. The card shows a foreign draft with its author and without Fortsetzen or Loeschen -- offering a button that reliably ends in a database error is a promise without cover. check-schema-types.mjs learns `alter table ... rename to`; without it the drift check reports one rename as two errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
6.6 KiB
TypeScript
158 lines
6.6 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 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"),
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("hire_drafts")
|
|
.select(["id", "step", "payload"])
|
|
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
|
// Hier bewusst **nur** die eigenen, obwohl die Übersicht seit
|
|
// September 2026 auch fremde zeigt: diese Liste füttert den
|
|
// Einstellungsassistenten (HireWizardProvider), und was er darin
|
|
// findet, lässt sich fortsetzen. Ein fremder Entwurf gehört nicht
|
|
// hinein — die Regel hire_drafts_update wiese das Speichern ab,
|
|
// und die Person hätte den Assistenten umsonst durchlaufen.
|
|
.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),
|
|
}));
|
|
}
|