The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
6.2 KiB
TypeScript
160 lines
6.2 KiB
TypeScript
import type { Tx } from "./db";
|
|
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
|
|
import { besetzungenAbfrage, pickPlacements } from "./placement";
|
|
import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org";
|
|
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
|
|
import type { HistoryEventType } from "./types";
|
|
import type { AnstehendArt } from "./dashboard-filter";
|
|
|
|
// Was die Übersichtsseite liest — in zwei Rundreisen statt in dreizehn.
|
|
//
|
|
// Hier und nicht in der Seite, damit sich die Zahl der Rundreisen messen und
|
|
// das Ergebnis gegen den alten Weg halten lässt, ohne eine React-Komponente
|
|
// aufzubauen.
|
|
|
|
export type DashboardParams = {
|
|
userId: string | null;
|
|
today: string;
|
|
yearStart: string;
|
|
yearEnd: string;
|
|
bisIso: string;
|
|
arten: AnstehendArt[];
|
|
};
|
|
|
|
export async function loadDashboardData(tx: Tx, p: DashboardParams) {
|
|
const { userId, today, yearStart, yearEnd, bisIso, arten } = p;
|
|
const zeigt = (art: AnstehendArt) => arten.includes(art);
|
|
// Elf Abfragen standen hier in einem Promise.all, das keine
|
|
// Gleichzeitigkeit war: eine Transaktion hängt an einer Verbindung, und
|
|
// über eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms
|
|
// Umlaufzeit war das eine knappe Sekunde Warten für Daten, die zusammen
|
|
// keine 200 kB wiegen. Jetzt: eine Rundreise, und eine zweite für die
|
|
// offenen Stellen, deren zweite Hälfte vom Ergebnis der ersten abhängt.
|
|
// Der Weg dahin steht in lib/db/json.ts.
|
|
const countIn = (eb: OrgEb, types: readonly HistoryEventType[]) =>
|
|
eb
|
|
.selectFrom("employee_history")
|
|
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
|
.where("event_type", "in", [...types])
|
|
.where("event_date", ">=", yearStart)
|
|
.where("event_date", "<=", yearEnd);
|
|
|
|
const g = await tx
|
|
.selectNoFrom((eb) => [
|
|
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"),
|
|
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("employees")
|
|
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
|
.orderBy("id")
|
|
).as("staffRows"),
|
|
|
|
// Entries/exits count history events, which is what the linked report
|
|
// counts too. `entry_date` would also sweep up rehires, whose event is
|
|
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
|
// different numbers for the same year.
|
|
jsonObjectFrom(countIn(eb, ["Eintritt", "Wiedereintritt"])).as("hiresYtd"),
|
|
jsonObjectFrom(countIn(eb, ["Austritt"])).as("exitsYtd"),
|
|
|
|
...orgMapsAbfragen(eb),
|
|
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
|
jsonArrayFrom(besetzungenAbfrage(eb)).as("placementRows"),
|
|
|
|
// Abgewählte Arten werden gar nicht erst gelesen — die Karte zeigt sie
|
|
// ohnehin nicht, und eine Teilabfrage, deren Ergebnis niemand ansieht,
|
|
// ist eine Teilabfrage zu viel.
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("employees")
|
|
.select(["id", "first_name", "last_name", "entry_date"])
|
|
.where("status", "=", "Geplant")
|
|
.where("entry_date", ">=", today)
|
|
.where("entry_date", "<=", bisIso)
|
|
.where((e) => e.lit(zeigt("hire")))
|
|
).as("upcomingHires"),
|
|
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("employees")
|
|
.select(["id", "first_name", "last_name", "exit_date"])
|
|
.where("exit_date", "is not", null)
|
|
.where("exit_date", ">=", today)
|
|
.where("exit_date", "<=", bisIso)
|
|
.where((e) => e.lit(zeigt("exit")))
|
|
).as("upcomingExits"),
|
|
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("employees")
|
|
.select(["id", "first_name", "last_name", "karenz_return_date"])
|
|
.where("status", "=", "Karenz")
|
|
.where("karenz_return_date", "is not", null)
|
|
.where("karenz_return_date", ">=", today)
|
|
.where("karenz_return_date", "<=", bisIso)
|
|
.where((e) => e.lit(zeigt("return")))
|
|
).as("upcomingReturns"),
|
|
|
|
// Wiedervorlagen. Sie verhalten sich anders als der Rest dieser Karte:
|
|
// ein Eintritt am Montag ist am Dienstag vorbei, eine Wiedervorlage
|
|
// nicht. Deshalb gibt es hier **keine untere Grenze** — was fällig war
|
|
// und nicht abgehakt wurde, bleibt stehen, bis jemand „Erledigt" klickt.
|
|
// Eine Aufgabe, die von selbst aus der Liste rutscht, ist eine
|
|
// vergessene Aufgabe.
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("employee_notes as n")
|
|
.innerJoin("employees as e", "e.id", "n.employee_id")
|
|
.select(["n.id", "n.employee_id", "n.due_date", "n.category", "n.note_text", "e.first_name", "e.last_name"])
|
|
.where("n.done", "=", false)
|
|
.where("n.due_date", "is not", null)
|
|
.where("n.due_date", "<=", bisIso)
|
|
.where((x) => x.lit(zeigt("note")))
|
|
.orderBy("n.due_date")
|
|
).as("upcomingNotes"),
|
|
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("employee_history as h")
|
|
.leftJoin("employees as e", "e.id", "h.employee_id")
|
|
.select([
|
|
"h.id",
|
|
"h.employee_id",
|
|
"h.event_date",
|
|
"h.event_type",
|
|
"h.description",
|
|
"e.first_name",
|
|
"e.last_name",
|
|
])
|
|
.orderBy("h.event_date", "desc")
|
|
.orderBy("h.created_at", "desc")
|
|
.limit(10)
|
|
).as("history"),
|
|
])
|
|
.executeTakeFirstOrThrow();
|
|
|
|
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
|
|
|
|
return {
|
|
drafts: userId ? g.drafts : [],
|
|
staffRows: g.staffRows,
|
|
hiresYtd: Number(g.hiresYtd?.anzahl ?? 0),
|
|
exitsYtd: Number(g.exitsYtd?.anzahl ?? 0),
|
|
openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
|
|
orgMaps,
|
|
placements: pickPlacements(g.placementRows as never, today),
|
|
upcomingHires: g.upcomingHires,
|
|
upcomingExits: g.upcomingExits,
|
|
upcomingReturns: g.upcomingReturns,
|
|
upcomingNotes: g.upcomingNotes,
|
|
history: g.history,
|
|
};
|
|
}
|