Zweiter Schritt weg von Supabase. Sämtliche 49 Lesezugriffe und alle
Mutationen laufen jetzt über lib/db statt über die REST-Schicht: Kysely auf
einem pg-Pool, jede Abfrage in einer Transaktion, in der zuerst
app.user_id gesetzt wird. Die Anmeldung hängt noch an GoTrue — sie liefert
die Kennung, die in withUser() geht. Damit war der Umbau in zwei Hälften
teilbar und die Anwendung durchgehend lauffähig.
Was dabei ersatzlos verschwindet:
- fetchAllRows. Es gab die Funktion nur, weil PostgREST jede Antwort bei
1000 Zeilen still abschneidet und ein Bericht dann leise falsch war.
Am direkten Zugang ist eine Abfrage eine Abfrage.
- sanitizeIlikeTerm samt Test. Sie entschärfte Zeichen, die in der
Filtersyntax strukturelle Bedeutung hatten; jetzt wird der Suchbegriff
als Parameter gebunden und ein Komma ist ein Komma. Die Lücke ist nicht
abgesichert, sondern weg.
- lib/supabase/admin.ts. Der Dienstschlüssel, der RLS aushebelte, hatte
genau einen Aufrufer — den nächtlichen Lauf. Der benutzt jetzt dieselbe
Rolle ohne BYPASSRLS und ruft eine SECURITY-DEFINER-Funktion auf, die
selbst prüft, was sie tut. Es gibt keinen privilegierten Zugang mehr.
Nebenbei besser geworden, weil der direkte Zugang es erlaubt:
- Eine Seite ist eine Transaktion. Das Layout etwa liest Profil,
Planstellen, Standorte, Entwürfe und Notizen auf einem einheitlichen
Lesestand statt in fünf unabhängigen Anfragen.
- Der Bereichsfilter der Mitarbeiterliste ist ein EXISTS statt einer
eingebetteten Ressource mit !inner — eine Person mit mehreren
Zuordnungen über die Zeit erschien dort mehrfach.
- Seitenweise Listen sortieren zusätzlich nach id. Bei gleichem Nachnamen
oder gleichem Zeitstempel war die Reihenfolge vorher unbestimmt, und
dieselbe Zeile konnte auf zwei Seiten erscheinen oder auf keiner.
- Angehörige werden in der Datenbank gezählt statt alle Zeilen zu holen.
- Namen an Ereigniszeilen kommen aus einem Join statt aus einem
Nachschlag, der ausserhalb der Transaktion lag.
Der Statusfilter ist mitgezogen: dieselbe Regel wie deriveStatusAsOf,
Klausel für Klausel, jetzt als Kysely-Ausdruck. Der Integrationstest, der
beide über den gesamten Bestand vergleicht, läuft weiter — mit eigener
Verbindung, denn geprüft wird die Bedingung, nicht die Berechtigung.
Zwei Fehler auf dem Weg, beide vom Typprüfer gefangen: apply_due_pending_
changes() nimmt kein Argument, wurde von callFunction aber mit jsonb
aufgerufen — Postgres hätte keine passende Signatur gefunden. Und der
Sicherheitstest lädt jetzt Module mit `import "server-only"`, was ausserhalb
der Server-Übersetzung wirft.
Typecheck, Lint, Build und 180 Tests sind grün. Ungeprüft bleibt der Lauf
gegen eine echte Datenbank — dafür fehlt eine DATABASE_URL.
144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
import { sql, type Tx } from "./db";
|
|
|
|
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
|
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
|
//
|
|
// employees ──A008──> position_assignments ──> om_positions ──> org_units
|
|
// └────────> jobs
|
|
//
|
|
// Das ist der Grund, warum es diese Datei gibt: die Verkettung braucht es an
|
|
// einem Dutzend Stellen, und sie zeitrichtig aufzulösen ist die Arbeit.
|
|
|
|
export type Placement = {
|
|
employeeId: string;
|
|
positionId: string;
|
|
positionNumber: string;
|
|
orgUnitId: string;
|
|
isChief: boolean;
|
|
jobTitle: string;
|
|
validFrom: string;
|
|
validTo: string | null;
|
|
/** Die Besetzung läuft am Stichtag; sonst ist es die zuletzt beendete. */
|
|
current: boolean;
|
|
};
|
|
|
|
type Row = {
|
|
employee_id: string;
|
|
valid_from: string;
|
|
valid_to: string | null;
|
|
position_id: string;
|
|
position_number: string;
|
|
org_unit_id: string;
|
|
is_chief: boolean;
|
|
job_title: string;
|
|
};
|
|
|
|
function toPlacement(row: Row, asOf: string): Placement {
|
|
return {
|
|
employeeId: row.employee_id,
|
|
positionId: row.position_id,
|
|
positionNumber: row.position_number,
|
|
orgUnitId: row.org_unit_id,
|
|
isChief: row.is_chief,
|
|
jobTitle: row.job_title,
|
|
validFrom: row.valid_from,
|
|
validTo: row.valid_to,
|
|
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Die am Stichtag laufende Besetzung je Person — und für alle, die zu dem
|
|
* Zeitpunkt keine hatten, die zuletzt beendete. Ohne diesen Rückfall stünde
|
|
* bei jeder ausgetretenen Person „–" statt der Stelle, die sie innehatte.
|
|
*/
|
|
export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement> {
|
|
const byEmployee = new Map<string, Placement>();
|
|
for (const row of rows) {
|
|
const p = toPlacement(row, asOf);
|
|
const best = byEmployee.get(p.employeeId);
|
|
if (!best) {
|
|
byEmployee.set(p.employeeId, p);
|
|
continue;
|
|
}
|
|
// Laufend schlägt beendet; unter beendeten gewinnt die jüngste.
|
|
if (p.current && !best.current) byEmployee.set(p.employeeId, p);
|
|
else if (p.current === best.current && p.validFrom > best.validFrom) byEmployee.set(p.employeeId, p);
|
|
}
|
|
return byEmployee;
|
|
}
|
|
|
|
export async function loadPlacements(
|
|
tx: Tx,
|
|
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
|
|
): Promise<Map<string, Placement>> {
|
|
if (employeeIds?.length === 0) return new Map();
|
|
|
|
// Ein Join statt einer eingebetteten Ressource. Und ohne die
|
|
// 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg,
|
|
// das es dafür brauchte.
|
|
let q = tx
|
|
.selectFrom("position_assignments as pa")
|
|
.innerJoin("om_positions as p", "p.id", "pa.position_id")
|
|
.innerJoin("jobs as j", "j.id", "p.job_id")
|
|
.select([
|
|
"pa.employee_id",
|
|
"pa.valid_from",
|
|
"pa.valid_to",
|
|
"p.id as position_id",
|
|
"p.position_number",
|
|
"p.org_unit_id",
|
|
"p.is_chief",
|
|
"j.title as job_title",
|
|
])
|
|
.orderBy("pa.employee_id");
|
|
|
|
if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds);
|
|
|
|
return pickPlacements((await q.execute()) as Row[], asOf);
|
|
}
|
|
|
|
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
|
|
// Sie steht nirgends als Spalte; om_reporting_lines() rechnet sie aus dem
|
|
// Baum aus. formal_manager_id ist die zuständige Leitung, acting_manager_id
|
|
// die nächste besetzte und anwesende darüber — beides, damit sich in der
|
|
// Oberfläche zeigen lässt, dass eine Vertretung im Spiel ist, statt sie
|
|
// stillschweigend als die echte Führungskraft auszugeben.
|
|
|
|
export type ReportingLine = {
|
|
employee_id: string;
|
|
position_id: string;
|
|
org_unit_id: string;
|
|
is_chief: boolean;
|
|
formal_manager_id: string | null;
|
|
acting_manager_id: string | null;
|
|
};
|
|
|
|
/**
|
|
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher —
|
|
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
|
|
* achthundert.
|
|
*/
|
|
export async function loadReportingLines(
|
|
tx: Tx,
|
|
asOf: string,
|
|
filter?: { employeeId?: string; actingManagerId?: string }
|
|
): Promise<ReportingLine[]> {
|
|
const conditions = [sql`true`];
|
|
if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`);
|
|
if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`);
|
|
|
|
const result = await sql<ReportingLine>`
|
|
select * from om_reporting_lines(${asOf}::date)
|
|
where ${sql.join(conditions, sql` and `)}
|
|
`.execute(tx);
|
|
|
|
return result.rows;
|
|
}
|
|
|
|
/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */
|
|
export async function loadReportingLineMap(tx: Tx, asOf: string): Promise<Map<string, ReportingLine>> {
|
|
const lines = await loadReportingLines(tx, asOf);
|
|
return new Map(lines.map((l) => [l.employee_id, l]));
|
|
}
|