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

@@ -2,7 +2,6 @@ import Link from "next/link";
import { Suspense } from "react";
import { AuditFilters } from "@/components/audit/AuditFilters";
import { actionBadgeStyle } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import { createClient } from "@/lib/supabase/server";
const PAGE_SIZE = 25;

View File

@@ -8,22 +8,16 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
const { id } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { data: profile } = await supabase.from("profiles").select("role").eq("id", user!.id).single();
const canEdit = profile?.role === "hr_admin";
const { data: employee } = await supabase.from("employees_directory").select("*").eq("id", id).single();
const { data: employee } = await supabase.from("employees").select("*").eq("id", id).single();
if (!employee) notFound();
const [{ data: manager }, { data: directReports }, { data: history }, { data: divisions }, { data: departments }, { data: teams }, { data: locations }, { data: openPositions }] =
await Promise.all([
employee.manager_id
? supabase.from("employees_directory").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
? supabase.from("employees").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
: Promise.resolve({ data: null }),
supabase
.from("employees_directory")
.from("employees")
.select("id, first_name, last_name, job_title, status")
.eq("manager_id", id)
.order("last_name"),
@@ -46,7 +40,6 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
teams={teams ?? []}
locations={locations ?? []}
openPositions={openPositions ?? []}
canEdit={canEdit}
/>
);
}

View File

@@ -36,7 +36,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
const to = from + PAGE_SIZE - 1;
let query = supabase
.from("employees_directory")
.from("employees")
.select(
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status",
{ count: "exact" }

View File

@@ -13,25 +13,27 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: profile } = await supabase.from("profiles").select("full_name, email, role").eq("id", user.id).single();
const canEdit = profile?.role === "hr_admin";
// Defense in depth: proxy.ts already redirects any non-active-HR session
// away before this layout ever renders. Re-checking here means a gap in
// the proxy matcher (or a future route added outside it) still fails
// closed instead of silently granting access — see docs/security.md.
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
const userLabel = profile?.full_name || profile?.email || user.email || "";
const userLabel = profile.full_name || profile.email || user.email || "";
const [openPositions, locationsRes, draftsRes] = canEdit
? await Promise.all([
loadOpenPositions(supabase),
supabase.from("locations").select("id, name, country").order("name"),
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
])
: [[], { data: [] }, { data: [] }];
const [openPositions, locationsRes, draftsRes] = await Promise.all([
loadOpenPositions(supabase),
supabase.from("locations").select("id, name, country").order("name"),
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
]);
return (
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
<div className="flex min-h-screen">
<Sidebar />
<div className="ml-[236px] flex flex-1 flex-col">
<Topbar userLabel={userLabel} role={profile?.role} canEdit={canEdit} />
<Topbar userLabel={userLabel} />
<main className="flex-1 px-6 py-6">
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
</main>

View File

@@ -14,7 +14,7 @@ export default async function OrgChartPage() {
{ data: reorgScenarios },
] = await Promise.all([
supabase
.from("employees_directory")
.from("employees")
.select("id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level")
.in("status", ["Aktiv", "Karenz"]),
supabase.from("divisions").select("*").order("name"),

View File

@@ -67,36 +67,36 @@ export default async function DashboardPage() {
upcomingReturnsRes,
historyRes,
] = await Promise.all([
supabase.from("employees_directory").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]),
supabase.from("employees_directory").select("id", { count: "exact", head: true }).eq("status", "Karenz"),
supabase.from("employees").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]),
supabase.from("employees").select("id", { count: "exact", head: true }).eq("status", "Karenz"),
supabase
.from("employees_directory")
.from("employees")
.select("id", { count: "exact", head: true })
.gte("entry_date", yearStart)
.lte("entry_date", yearEnd),
supabase
.from("employees_directory")
.from("employees")
.select("id", { count: "exact", head: true })
.gte("exit_date", yearStart)
.lte("exit_date", yearEnd),
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
supabase.from("employees_directory").select("weekly_hours").in("status", ["Aktiv", "Karenz"]),
supabase.from("employees").select("weekly_hours").in("status", ["Aktiv", "Karenz"]),
supabase.from("divisions").select("id, name"),
supabase.from("employees_directory").select("division_id").in("status", ["Aktiv", "Karenz"]),
supabase.from("employees").select("division_id").in("status", ["Aktiv", "Karenz"]),
supabase
.from("employees_directory")
.from("employees")
.select("id, first_name, last_name, entry_date")
.eq("status", "Geplant")
.gte("entry_date", todayIso)
.lte("entry_date", in60Iso),
supabase
.from("employees_directory")
.from("employees")
.select("id, first_name, last_name, exit_date")
.not("exit_date", "is", null)
.gte("exit_date", todayIso)
.lte("exit_date", in60Iso),
supabase
.from("employees_directory")
.from("employees")
.select("id, first_name, last_name, karenz_return_date")
.eq("status", "Karenz")
.not("karenz_return_date", "is", null)
@@ -148,7 +148,7 @@ export default async function DashboardPage() {
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
const historyEmployeesRes = historyEmployeeIds.length
? await supabase.from("employees_directory").select("id, first_name, last_name").in("id", historyEmployeeIds)
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
: { data: [] as { id: string; first_name: string; last_name: string }[] };
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));

View File

@@ -12,9 +12,9 @@ export default async function PositionsPage() {
supabase.from("divisions").select("*").order("name"),
supabase.from("departments").select("*"),
supabase.from("teams").select("*"),
supabase.from("employees_directory").select("team_id, division_id, weekly_hours").in("status", ["Aktiv", "Karenz"]),
supabase.from("employees").select("team_id, division_id, weekly_hours").in("status", ["Aktiv", "Karenz"]),
supabase
.from("employees_directory")
.from("employees")
.select("id, first_name, last_name, team_id, division_id, org_level, is_lead")
.eq("status", "Aktiv")
.or("is_lead.eq.true,org_level.eq.0"),

View File

@@ -47,9 +47,9 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
};
let query = supabase
.from("employees_directory")
.from("employees")
.select(
"id, first_name, last_name, job_title, division_id, team_id, location_id, status, employment_type, contract_type, entry_date, exit_date, weekly_hours, monthly_salary_gross, source, paygrade, birth_date, gender"
"id, first_name, last_name, job_title, division_id, team_id, location_id, status, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender"
);
if (params.division) query = query.eq("division_id", params.division);