Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite

Reworks the app from a two-role (hr_admin/manager) model to a single
HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/
reorg RPCs to actually defer future-dated changes via a new
pending_org_changes table instead of writing them immediately (applied
by a daily Vercel Cron route), makes reorg undo append-only instead of
deleting history, adds Karenz-return and history-date integrity guards,
deprecates the salary column, and adds explicit schema grants + perf
indexes needed to run against a fresh (non-hosted) Postgres instance.

Adds vitest unit + integration test suites (the latter against a real
local Supabase instance) covering all of the above, plus lint/typecheck/
build wiring (`npm run check`).
This commit is contained in:
2026-07-14 20:32:20 +02:00
parent 4299277af0
commit 901c5c426e
67 changed files with 4765 additions and 286 deletions

View File

@@ -16,7 +16,6 @@ if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
}
const ADMIN_EMAIL = "m.stubhan@loudspring.at";
const MANAGER_TEST_EMAIL = "manager-test@test.manner.at";
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
@@ -106,6 +105,17 @@ function addressCountryFor(nationality: string): string {
}
const STREETS = ["Hauptstraße", "Bahnhofstraße", "Schulgasse", "Kirchenplatz", "Gartenweg", "Industriestraße", "Ringstraße", "Feldweg"];
// Home address should be plausible for the employee's actual work location,
// not a one-size-fits-all Vienna postal code regardless of where they're
// based (found during the consolidation review).
const HOME_LOCALE_BY_LOCATION: Record<string, { city: string; postal: () => string }> = {
"Wien-Hernals": { city: "Wien", postal: () => String(randInt(1100, 1230)) },
Wolkersdorf: { city: "Wolkersdorf", postal: () => "2120" },
Köln: { city: "Köln", postal: () => String(randInt(50667, 51149)) },
Brünn: { city: "Brno", postal: () => `${randInt(600, 664)} ${randInt(10, 99)}` },
Ljubljana: { city: "Ljubljana", postal: () => "1000" },
};
const EXIT_REASONS = [
"Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung",
];
@@ -321,7 +331,6 @@ type EmployeeRow = {
is_lead: boolean;
employment_type: "Vollzeit" | "Teilzeit";
weekly_hours: number;
monthly_salary_gross: number;
contract_type: "unbefristet" | "befristet";
contract_end_date: string | null;
paygrade: "A" | "B" | "C" | "D" | "E" | "F";
@@ -367,21 +376,13 @@ function birthDateForAge(age: number): Date {
return new Date(year, randInt(0, 11), randInt(1, 28));
}
function paygradeAndSalaryForIc(): { paygrade: EmployeeRow["paygrade"]; salary: number } {
const grade = weightedPick<EmployeeRow["paygrade"]>([
function paygradeForIc(): EmployeeRow["paygrade"] {
return weightedPick<EmployeeRow["paygrade"]>([
["A", 15],
["B", 35],
["C", 30],
["D", 20],
]);
const ranges: Record<string, [number, number]> = {
A: [2200, 2700],
B: [2600, 3300],
C: [3200, 4100],
D: [4000, 5200],
};
const [min, max] = ranges[grade];
return { paygrade: grade, salary: randInt(min, max) };
}
function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId: string | null, divisionId: string, managerId: string | null) {
@@ -390,6 +391,7 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId
const lastName = pick(LAST_NAMES);
const nationality = weightedPick(NATIONALITIES);
const location = weightedPick(LOCATION_WEIGHTS);
const homeLocale = HOME_LOCALE_BY_LOCATION[location.name];
return {
id: randomUUID(),
@@ -397,7 +399,7 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId
last_name: lastName,
gender,
nationality,
address: `${pick(STREETS)} ${randInt(1, 90)}, ${randInt(1010, 2500)} Wien`,
address: `${pick(STREETS)} ${randInt(1, 90)}, ${homeLocale.postal()} ${homeLocale.city}`,
address_country: addressCountryFor(nationality),
email: makeEmail(firstName, lastName),
phone: `+43 664 ${randInt(1000000, 9999999)}`,
@@ -415,7 +417,7 @@ const employees: EmployeeRow[] = [];
const history: HistoryRow[] = [];
const icPoolForStatusAssignment: EmployeeRow[] = [];
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { salary: number; paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
const age = randInt(22, 60);
const birthDate = birthDateForAge(age);
const maxTenureYears = Math.min(15, age - 20);
@@ -433,7 +435,6 @@ function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { salary:
sv_nummer: makeSvNummer(birthDate),
employment_type: employmentType,
weekly_hours: weeklyHours,
monthly_salary_gross: opts.salary,
contract_type: isBefristet ? "befristet" : "unbefristet",
contract_end_date: contractEndDate ? isoDate(contractEndDate) : null,
paygrade: opts.paygrade,
@@ -461,16 +462,6 @@ function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { salary:
description: `Beförderung im Rahmen der Laufbahnentwicklung, neue Position: ${row.job_title}`,
});
}
if (chance(0.08)) {
const adjDate = randomDateBetween(addDays(entryDate, 180), TODAY);
history.push({
employee_id: row.id,
event_date: isoDate(adjDate),
event_type: "Gehaltsanpassung",
description: "Jährliche Gehaltsanpassung im Rahmen der Kollektivvertragsrunde",
});
}
return row;
}
@@ -489,11 +480,11 @@ divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsf
const ceo = finalizeEmployee(
newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null),
{ salary: 15500, paygrade: "F" }
{ paygrade: "F" }
);
const gfAssistant = finalizeEmployee(
newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id),
{ salary: 3900, paygrade: "C" }
{ paygrade: "C" }
);
employees.push(ceo, gfAssistant);
@@ -509,7 +500,7 @@ for (const div of DIVISIONS) {
const divisionHead = finalizeEmployee(
newHireBase(div.headTitle, 1, true, null, divisionId, ceo.id),
{ salary: randInt(9000, 11500), paygrade: "F" }
{ paygrade: "F" }
);
employees.push(divisionHead);
@@ -529,16 +520,16 @@ for (const div of DIVISIONS) {
const teamLead = finalizeEmployee(
newHireBase(team.leadTitle, 2, true, teamId, divisionId, divisionHead.id),
{ salary: randInt(5000, 6800), paygrade: "E" }
{ paygrade: "E" }
);
employees.push(teamLead);
for (let i = 0; i < size - 1; i++) {
const jobTitle = pick(team.icTitles);
const { paygrade, salary } = paygradeAndSalaryForIc();
const paygrade = paygradeForIc();
const ic = finalizeEmployee(
newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id),
{ salary, paygrade }
{ paygrade }
);
employees.push(ic);
icPoolForStatusAssignment.push(ic);
@@ -586,12 +577,22 @@ for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
});
}
// ~3 Geplant (future entry — overwrite entry_date/history)
// ~3 Geplant (future entry). A person who hasn't started yet can't already
// have a Beförderung or other history predating that future entry date —
// found during the consolidation review that reassigning an already-
// finalized IC to Geplant only patched their Eintritt row's date, leaving
// any earlier-generated history (e.g. a Beförderung) still on the record
// with a date before the (now future) entry_date. Fixed by dropping every
// history row for that employee except Eintritt, then moving Eintritt to
// the new future date.
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const futureEntry = addDays(TODAY, randInt(10, 90));
e.status = "Geplant";
e.entry_date = isoDate(futureEntry);
for (let hi = history.length - 1; hi >= 0; hi--) {
if (history[hi].employee_id === e.id && history[hi].event_type !== "Eintritt") history.splice(hi, 1);
}
const historyEntry = history.find((h) => h.employee_id === e.id && h.event_type === "Eintritt");
if (historyEntry) historyEntry.event_date = e.entry_date;
}
@@ -666,40 +667,30 @@ async function main() {
console.log(`Seeding ${positions.length} open positions...`);
await insertInChunks("positions", positions);
console.log("Creating hr_admin account...");
const adminPassword = randomUUID().slice(0, 12) + "!Aa1";
const { data: adminUser, error: adminErr } = await supabase.auth.admin.createUser({
// The app is HR-only now (see docs/decisions/0001-hr-only-access.md) — no
// second "manager" role exists to seed a test account for. This is the
// one deliberate, explicit bootstrap grant of HR access (not an automatic
// one): every other new profile row defaults to is_active = false and
// must be activated by an existing HR user (§2.3).
console.log("Creating initial HR account...");
const hrPassword = randomUUID().slice(0, 12) + "!Aa1";
const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({
email: ADMIN_EMAIL,
password: adminPassword,
password: hrPassword,
email_confirm: true,
});
if (adminErr) throw new Error(`Creating admin user failed: ${adminErr.message}`);
if (hrErr) throw new Error(`Creating HR user failed: ${hrErr.message}`);
await supabase.from("profiles").insert({
id: adminUser.user.id,
id: hrUser.user.id,
email: ADMIN_EMAIL,
full_name: "Maximilian Stubhan",
role: "hr_admin",
});
console.log("Creating manager test account...");
const managerPassword = randomUUID().slice(0, 12) + "!Bb2";
const { data: managerUser, error: managerErr } = await supabase.auth.admin.createUser({
email: MANAGER_TEST_EMAIL,
password: managerPassword,
email_confirm: true,
});
if (managerErr) throw new Error(`Creating manager test user failed: ${managerErr.message}`);
await supabase.from("profiles").insert({
id: managerUser.user.id,
email: MANAGER_TEST_EMAIL,
full_name: "Test Manager",
role: "manager",
role: "hr",
is_active: true,
});
console.log("\nDone.");
console.log(`hr_admin login: ${ADMIN_EMAIL} / ${adminPassword}`);
console.log(`manager login: ${MANAGER_TEST_EMAIL} / ${managerPassword}`);
console.log("(Passwords are shown once here only — store them somewhere safe.)");
console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`);
console.log("(Password is shown once here only — store it somewhere safe.)");
}
main().catch((err) => {