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:
105
supabase/seed.ts
105
supabase/seed.ts
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user