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

@@ -1,3 +1,8 @@
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
# project's env vars). Generate with e.g. `openssl rand -hex 32`.
CRON_SECRET=

10
.gitignore vendored
View File

@@ -40,3 +40,13 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# supabase CLI local dev (generated, project-machine-specific)
/supabase/.branches
/supabase/.temp
/supabase/snippets
# playwright
/playwright-report
/test-results
/blob-report

View File

@@ -31,7 +31,6 @@ export async function hireEmployee(payload: {
contract_end_date?: string;
employment_type?: "Vollzeit" | "Teilzeit";
weekly_hours?: number;
monthly_salary_gross: number;
paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
source: "Intern" | "Extern";
}): Promise<ActionResult & { employeeId?: string }> {
@@ -66,7 +65,6 @@ export async function promoteEmployee(payload: {
employee_id: string;
effective_date: string;
new_title: string;
new_salary: number;
new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
}): Promise<ActionResult> {
return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
@@ -119,7 +117,7 @@ export type EmployeeSearchResult = { id: string; first_name: string; last_name:
export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> {
const supabase = await createClient();
let q = supabase
.from("employees_directory")
.from("employees")
.select("id, first_name, last_name, job_title, team_id")
.in("status", ["Aktiv", "Karenz"])
.limit(20);

View File

@@ -43,7 +43,7 @@ export type SuperiorSearchResult = { id: string; first_name: string; last_name:
export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise<SuperiorSearchResult[]> {
const supabase = await createClient();
let q = supabase
.from("employees_directory")
.from("employees")
.select("id, first_name, last_name, job_title, division_id")
.eq("status", "Aktiv")
.limit(20);

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);

View File

@@ -1,4 +1,4 @@
import { login } from "@/actions/auth";
import { login, logout } from "@/actions/auth";
type LoginPageProps = {
searchParams: Promise<{ error?: string }>;
@@ -16,6 +16,11 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
{error && (
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
{error}
<form action={logout} className="mt-2">
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
Abmelden und mit anderem Konto versuchen
</button>
</form>
</div>
)}

View File

@@ -0,0 +1,24 @@
import { NextResponse, type NextRequest } from "next/server";
import { createAdminClient } from "@/lib/supabase/admin";
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
// ändern with a future "Wirksam ab" date) once their date has arrived — see
// apply_due_pending_changes() in supabase/migrations. Runs as a Vercel Cron
// job (see vercel.json), not on behalf of any HR user, so it authenticates
// via a shared secret rather than a Supabase session and uses the
// service-role client (the one legitimate server-only use case for it).
export async function GET(request: NextRequest) {
const authHeader = request.headers.get("authorization");
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
}
const supabase = createAdminClient();
const { data, error } = await supabase.rpc("apply_due_pending_changes");
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ applied: data });
}

View File

@@ -17,7 +17,7 @@ import { OrganisationTab } from "./tabs/OrganisationTab";
import { StammdatenTab } from "./tabs/StammdatenTab";
import { VertragTab } from "./tabs/VertragTab";
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Division = Database["public"]["Tables"]["divisions"]["Row"];
type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];
@@ -36,14 +36,13 @@ type EmployeeDetailProps = {
teams: Team[];
locations: Location[];
openPositions: OpenPosition[];
canEdit: boolean;
};
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const TABS = ["Stammdaten", "Vertrag & Gehalt", "Organisation", "Historie"] as const;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, manager, directReports, history, divisions, departments, teams, locations, canEdit } = props;
const { employee, manager, directReports, history, divisions, departments, teams, locations } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null);
@@ -77,32 +76,30 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
</div>
</div>
{canEdit && (
<div className="flex flex-wrap gap-2">
{isActive && (
<>
<ActionButton icon={ArrowRightLeft} label="Versetzen" onClick={() => setPanel("transfer")} />
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
<ActionButton icon={Clock} label={employee.status === "Karenz" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} />
<ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />
<button
onClick={() => setPanel("terminate")}
className="flex items-center gap-1.5 rounded border border-danger-solid px-3 py-1.5 text-sm font-semibold text-danger-solid hover:bg-danger-bg"
>
<XCircle className="h-4 w-4" /> Austritt
</button>
</>
)}
{employee.status === "Ausgetreten" && (
<div className="flex flex-wrap gap-2">
{isActive && (
<>
<ActionButton icon={ArrowRightLeft} label="Versetzen" onClick={() => setPanel("transfer")} />
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
<ActionButton icon={Clock} label={employee.status === "Karenz" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} />
<ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />
<button
onClick={() => setPanel("rehire")}
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-600"
onClick={() => setPanel("terminate")}
className="flex items-center gap-1.5 rounded border border-danger-solid px-3 py-1.5 text-sm font-semibold text-danger-solid hover:bg-danger-bg"
>
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
<XCircle className="h-4 w-4" /> Austritt
</button>
)}
</div>
)}
</>
)}
{employee.status === "Ausgetreten" && (
<button
onClick={() => setPanel("rehire")}
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-600"
>
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
</button>
)}
</div>
</div>
</div>
@@ -122,29 +119,25 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
<div className="rounded border border-border bg-white p-6">
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} />}
{tab === "Vertrag & Gehalt" && <VertragTab employee={employee} />}
{tab === "Vertrag" && <VertragTab employee={employee} />}
{tab === "Organisation" && <OrganisationTab manager={manager} directReports={directReports} breadcrumb={breadcrumb} />}
{tab === "Historie" && <HistorieTab history={history} />}
</div>
{canEdit && (
<>
<TransferPanel
open={panel === "transfer"}
onClose={() => setPanel(null)}
employee={employee}
divisions={divisions}
departments={departments}
teams={teams}
currentTeamId={employee.team_id}
/>
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
<DatenAendernPanel open={panel === "daten"} onClose={() => setPanel(null)} employee={employee} />
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
</>
)}
<TransferPanel
open={panel === "transfer"}
onClose={() => setPanel(null)}
employee={employee}
divisions={divisions}
departments={departments}
teams={teams}
currentTeamId={employee.team_id}
/>
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
<DatenAendernPanel open={panel === "daten"} onClose={() => setPanel(null)} employee={employee} />
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
</div>
);
}

View File

@@ -9,7 +9,7 @@ import { useToast } from "@/components/ui/Toast";
import { UN_COUNTRIES } from "@/lib/countries";
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
export function DatenAendernPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
const { showToast } = useToast();

View File

@@ -9,7 +9,7 @@ import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Mode = "adjust" | "return";
type EmploymentMode = "unverändert" | "Vollzeit" | "Teilzeit";

View File

@@ -7,7 +7,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { Database, PaygradeType } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
const PAYGRADES: { value: PaygradeType; label: string }[] = [
{ value: "A", label: "A Einstieg" },
@@ -23,12 +23,11 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
const router = useRouter();
const [effectiveDate, setEffectiveDate] = useState("");
const [newTitle, setNewTitle] = useState(employee.job_title);
const [newSalary, setNewSalary] = useState(String(employee.monthly_salary_gross ?? ""));
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
const [pending, setPending] = useState(false);
async function handleSubmit() {
if (!effectiveDate || !newTitle || !newSalary) {
if (!effectiveDate || !newTitle) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return;
}
@@ -37,7 +36,6 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
employee_id: employee.id,
effective_date: effectiveDate,
new_title: newTitle,
new_salary: Number(newSalary),
new_paygrade: paygrade,
});
setPending(false);
@@ -85,15 +83,6 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
<label className="mb-1 block text-sm font-semibold text-ink">Neue Position*</label>
<input value={newTitle} onChange={(e) => setNewTitle(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Neues Bruttogehalt (14x/Jahr)*</label>
<input
type="number"
value={newSalary}
onChange={(e) => setNewSalary(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Paygrade</label>
<select

View File

@@ -8,7 +8,7 @@ import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
export function RehirePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
const { showToast } = useToast();

View File

@@ -7,7 +7,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
const EXIT_REASONS = ["Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung"];
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];

View File

@@ -7,7 +7,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Division = Database["public"]["Tables"]["divisions"]["Row"];
type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];

View File

@@ -1,7 +1,7 @@
import { fmtAge, fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
export function StammdatenTab({ employee, location }: { employee: EmployeeRow; location?: Location }) {

View File

@@ -1,7 +1,7 @@
import { fmtDate, fmtEUR } from "@/lib/format";
import { fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
const PAYGRADE_LABELS: Record<string, string> = {
A: "A Einstieg",
@@ -21,7 +21,6 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
["Wochenstunden", `${employee.weekly_hours} h`],
["Urlaubsanspruch", "25 Tage"],
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
["Bruttogehalt (14x/Jahr)", fmtEUR(employee.monthly_salary_gross)],
];
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { hireEmployee } from "@/actions/employees";
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
@@ -27,24 +27,18 @@ type HireWizardProps = {
export function HireWizard({ open, onClose, openPositions, locations, resumeDraft, initialPositionId }: HireWizardProps) {
const { showToast } = useToast();
const router = useRouter();
const [step, setStep] = useState(0);
const [draft, setDraft] = useState<HireDraftData>(EMPTY_HIRE_DRAFT);
const [draftId, setDraftId] = useState<string | undefined>(undefined);
// The parent remounts this component (via a changing `key`) each time it's
// freshly opened, so these initializers — reading resumeDraft/
// initialPositionId once at mount — are the reset, no effect needed.
const [step, setStep] = useState(() => resumeDraft?.step ?? 0);
const [draft, setDraft] = useState<HireDraftData>(() =>
resumeDraft
? { ...EMPTY_HIRE_DRAFT, ...(resumeDraft.payload as Partial<HireDraftData>) }
: { ...EMPTY_HIRE_DRAFT, positionId: initialPositionId ?? "" }
);
const [draftId] = useState<string | undefined>(() => resumeDraft?.id);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
if (resumeDraft) {
setDraft({ ...EMPTY_HIRE_DRAFT, ...(resumeDraft.payload as Partial<HireDraftData>) });
setStep(resumeDraft.step);
setDraftId(resumeDraft.id);
} else {
setDraft({ ...EMPTY_HIRE_DRAFT, positionId: initialPositionId ?? "" });
setStep(0);
setDraftId(undefined);
}
}, [open, resumeDraft, initialPositionId]);
const selectedPosition = useMemo(
() => openPositions.find((p) => p.id === draft.positionId) ?? null,
[openPositions, draft.positionId]
@@ -57,7 +51,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
const stepValid = [
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId),
Boolean(draft.positionId && draft.besetzung),
Boolean(draft.entryDate && draft.salary),
Boolean(draft.entryDate),
true,
][step];
@@ -89,7 +83,6 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
contract_end_date: draft.contractType === "befristet" ? draft.contractEndDate : undefined,
employment_type: draft.employmentType,
weekly_hours: Number(draft.weeklyHours),
monthly_salary_gross: Number(draft.salary),
paygrade: draft.paygrade,
source: draft.besetzung,
});

View File

@@ -29,17 +29,24 @@ export function HireWizardProvider({
const [open, setOpen] = useState(false);
const [resumeDraft, setResumeDraft] = useState<HireDraft | null>(null);
const [initialPositionId, setInitialPositionId] = useState<string | undefined>(undefined);
// Forces HireWizard to remount fresh each time it's opened, so its
// internal draft/step state is (re-)initialized directly from the current
// resumeDraft/initialPositionId props at mount time — no reset-on-open
// effect needed inside HireWizard itself.
const [openKey, setOpenKey] = useState(0);
function openWizard(options?: OpenWizardOptions) {
setResumeDraft(options?.draftId ? (drafts.find((d) => d.id === options.draftId) ?? null) : null);
setInitialPositionId(options?.positionId);
setOpen(true);
setOpenKey((k) => k + 1);
}
return (
<HireWizardContext.Provider value={{ openWizard }}>
{children}
<HireWizard
key={openKey}
open={open}
onClose={() => setOpen(false)}
openPositions={openPositions}

View File

@@ -1,4 +1,4 @@
import { fmtDate, fmtEUR } from "@/lib/format";
import { fmtDate } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types";
@@ -34,7 +34,6 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
["Eintrittsdatum", fmtDate(draft.entryDate)],
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
["Bruttogehalt (14x/Jahr)", fmtEUR(Number(draft.salary))],
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
];

View File

@@ -72,10 +72,6 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Bruttogehalt/Monat (14x)*</label>
<input type="number" value={draft.salary} onChange={(e) => update({ salary: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Paygrade*</label>
<select

View File

@@ -19,7 +19,6 @@ export type HireDraftData = {
contractEndDate: string;
employmentType: EmploymentType;
weeklyHours: string;
salary: string;
paygrade: PaygradeType;
};
@@ -39,6 +38,5 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
contractEndDate: "",
employmentType: "Vollzeit",
weeklyHours: "38.5",
salary: "",
paygrade: "B",
};

View File

@@ -39,7 +39,6 @@ type ReportsPageClientProps = {
function formatValue(measure: Measure, value: number): string {
if (["headcount", "hires", "exits"].includes(measure)) return String(Math.round(value));
if (measure === "fte") return value.toFixed(1);
if (measure === "avg_salary") return new Intl.NumberFormat("de-AT", { style: "currency", currency: "EUR" }).format(value);
if (measure === "parttime_rate" || measure === "female_share") return `${value.toFixed(1)}%`;
if (measure === "avg_age" || measure === "avg_tenure") return `${value.toFixed(1)} Jahre`;
return value.toFixed(1);

View File

@@ -22,21 +22,18 @@ function titleFor(pathname: string): string {
type TopbarProps = {
userLabel: string;
role?: string;
canEdit: boolean;
};
export function Topbar({ userLabel, role, canEdit }: TopbarProps) {
export function Topbar({ userLabel }: TopbarProps) {
const pathname = usePathname();
return (
<header className="flex h-14 items-center justify-between border-b border-border bg-white px-6">
<h1 className="text-base font-bold text-ink">{titleFor(pathname)}</h1>
<div className="flex items-center gap-4">
{canEdit && <NewHireButton />}
<NewHireButton />
<div className="flex items-center gap-2 border-l border-border pl-4 text-sm">
<span className="font-semibold text-ink">{userLabel}</span>
{role && <span className="text-xs text-ink-muted">({role === "hr_admin" ? "HR-Admin" : "Manager"})</span>}
<form action={logout}>
<button type="submit" aria-label="Abmelden" className="ml-2 rounded p-1.5 text-ink-muted hover:bg-surface">
<LogOut className="h-4 w-4" />

View File

@@ -14,10 +14,18 @@ type CountryPickerProps = {
// value, not an object with its own detail fields.
export function CountryPicker({ value, onChange, countries, placeholder = "Land suchen…" }: CountryPickerProps) {
const [query, setQuery] = useState(value);
const [prevValue, setPrevValue] = useState(value);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => setQuery(value), [value]);
// Re-sync the local draft text when `value` changes externally (e.g. the
// surrounding form loads a different employee). Adjusting state directly
// during render — rather than in an effect — avoids an extra render pass
// and the synchronous setState-in-effect this used to do.
if (value !== prevValue) {
setPrevValue(value);
setQuery(value);
}
useEffect(() => {
function onClickOutside(e: MouseEvent) {

View File

@@ -18,30 +18,38 @@ export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, o
const [query, setQuery] = useState("");
const [results, setResults] = useState<T[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
// Derived from "have we finished searching for the current query yet",
// rather than a separate state flag flipped synchronously at the top of
// the effect below — the effect only ever sets state from the async
// search's own completion callback now.
const [lastSearchedQuery, setLastSearchedQuery] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tooShort = query.trim().length < minChars;
const loading = !tooShort && lastSearchedQuery !== query.trim();
useEffect(() => {
if (query.trim().length < minChars) {
setResults([]);
setOpen(false);
return;
}
if (tooShort) return;
let cancelled = false;
setLoading(true);
const timeout = setTimeout(() => {
onSearch(query.trim()).then((res) => {
if (cancelled) return;
setResults(res);
setOpen(true);
setLoading(false);
setLastSearchedQuery(query.trim());
});
}, 200);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [query, minChars, onSearch]);
}, [query, minChars, onSearch, tooShort]);
// Derived rather than reset via effect: once the query drops below
// minChars, hide the dropdown and any stale results immediately without
// needing a synchronous setState inside the effect above.
const showDropdown = open && !tooShort;
const visibleResults = tooShort ? [] : results;
useEffect(() => {
function onClickOutside(e: MouseEvent) {
@@ -77,12 +85,12 @@ export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, o
</button>
)}
</div>
{open && (
{showDropdown && (
<div className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-lg">
{loading && <div className="px-3 py-2 text-sm text-ink-muted">Suche</div>}
{!loading && results.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
{!loading && visibleResults.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
{!loading &&
results.map((item, i) => (
visibleResults.map((item, i) => (
<button
key={i}
type="button"

View File

@@ -4,11 +4,6 @@ const dateFormatter = new Intl.DateTimeFormat("de-AT", {
year: "numeric",
});
const eurFormatter = new Intl.NumberFormat("de-AT", {
style: "currency",
currency: "EUR",
});
export function fmtDate(date: string | Date | null | undefined): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
@@ -16,11 +11,6 @@ export function fmtDate(date: string | Date | null | undefined): string {
return dateFormatter.format(d);
}
export function fmtEUR(amount: number | null | undefined): string {
if (amount === null || amount === undefined) return "••• (ausgeblendet)";
return eurFormatter.format(amount);
}
export function initials(firstName: string, lastName: string): string {
const a = firstName.trim().charAt(0).toUpperCase();
const b = lastName.trim().charAt(0).toUpperCase();

View File

@@ -28,7 +28,7 @@ export async function loadOpenPositions(supabase: SupabaseClient<Database>): Pro
new Set((positions ?? []).map((p) => p.reports_to_employee_id).filter((id): id is string => Boolean(id)))
);
const { data: managers } = managerIds.length
? await supabase.from("employees_directory").select("id, first_name, last_name").in("id", managerIds)
? await supabase.from("employees").select("id, first_name, last_name").in("id", managerIds)
: { data: [] as { id: string; first_name: string; last_name: string }[] };
const managerNameById = new Map((managers ?? []).map((m) => [m.id, `${m.first_name} ${m.last_name}`]));

View File

@@ -3,7 +3,6 @@ export type Measure =
| "fte"
| "hires"
| "exits"
| "avg_salary"
| "parttime_rate"
| "avg_age"
| "avg_tenure"
@@ -26,7 +25,6 @@ export const MEASURE_LABELS: Record<Measure, string> = {
fte: "FTE",
hires: "Eintritte",
exits: "Austritte",
avg_salary: "Ø Bruttogehalt",
parttime_rate: "Teilzeitquote",
avg_age: "Ø Alter",
avg_tenure: "Ø Zugehörigkeit",
@@ -46,7 +44,7 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
paygrade: "Paygrade",
};
export const AVERAGE_MEASURES: Measure[] = ["avg_salary", "parttime_rate", "avg_age", "avg_tenure", "female_share"];
export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share"];
export const DATE_SCOPED_MEASURES: Measure[] = ["hires", "exits"];
export type ReportEmployee = {
@@ -63,7 +61,6 @@ export type ReportEmployee = {
entry_date: string;
exit_date: string | null;
weekly_hours: number;
monthly_salary_gross: number | null;
source: string;
paygrade: string;
birth_date: string;
@@ -127,10 +124,6 @@ export function measureValue(rows: ReportEmployee[], measure: Measure): number {
return rows.length;
case "fte":
return rows.reduce((s, e) => s + e.weekly_hours / 38.5, 0);
case "avg_salary": {
const withSalary = rows.filter((e) => e.monthly_salary_gross != null);
return withSalary.length ? withSalary.reduce((s, e) => s + (e.monthly_salary_gross ?? 0), 0) / withSalary.length : 0;
}
case "parttime_rate":
return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100;
case "avg_age":
@@ -194,7 +187,7 @@ export function aggregateReport(
export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [
{ name: "Headcount nach Bereich", measure: "headcount", group: "division" },
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
{ name: "Ø Gehalt nach Paygrade", measure: "avg_salary", group: "paygrade" },
{ name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" },
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
{ name: "Eintritte nach Bereich", measure: "hires", group: "division" },
{ name: "Austritte nach Abteilung", measure: "exits", group: "department" },

View File

@@ -1,6 +1,7 @@
// Hand-written to match supabase/schema.sql (no DB connection string available to
// run `supabase gen types typescript` in this environment — regenerate from the
// live project once you have the Supabase CLI linked).
// Hand-written to match supabase/schema.sql + supabase/migrations/*.sql (no DB
// connection string available to run `supabase gen types typescript` in this
// environment — regenerate from the live project once you have the Supabase
// CLI linked).
export type EmploymentStatus = "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten";
export type EmploymentType = "Vollzeit" | "Teilzeit";
@@ -8,7 +9,10 @@ export type ContractType = "unbefristet" | "befristet";
export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F";
export type SourceType = "Intern" | "Extern";
export type GenderType = "m" | "w";
export type ProfileRole = "hr_admin" | "manager";
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
// union (not a string literal) so a future hr_admin/hr_user split, if ever
// technically required, is a type-level addition, not a rewrite.
export type ProfileRole = "hr";
export type HistoryEventType =
| "Eintritt"
| "Beförderung"
@@ -23,6 +27,14 @@ export type HistoryEventType =
| "Rückkehr";
export type PositionStatus = "open" | "filled";
export type ReorgMoveKind = "emp" | "team" | "abt" | "dept";
export type PendingChangeType =
| "transfer"
| "promotion"
| "karenz_start"
| "karenz_return"
| "contract_change"
| "reorg";
export type PendingChangeStatus = "pending" | "applied" | "cancelled";
// @supabase/postgrest-js requires every table/view to carry a Relationships
// array (used for typed embedded selects) — left empty since no code in this
@@ -53,9 +65,36 @@ export type Database = {
Update: Partial<{ id: string; name: string; country: string }>;
};
profiles: NoRelationships & {
Row: { id: string; email: string; full_name: string | null; role: ProfileRole; created_at: string };
Insert: { id: string; email: string; full_name?: string | null; role?: ProfileRole; created_at?: string };
Update: Partial<{ id: string; email: string; full_name: string | null; role: ProfileRole; created_at: string }>;
Row: {
id: string;
email: string;
full_name: string | null;
role: ProfileRole;
is_active: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id: string;
email: string;
full_name?: string | null;
role?: ProfileRole;
is_active?: boolean;
created_by?: string | null;
created_at?: string;
updated_at?: string;
};
Update: Partial<{
id: string;
email: string;
full_name: string | null;
role: ProfileRole;
is_active: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
}>;
};
employees: NoRelationships & {
Row: {
@@ -80,7 +119,8 @@ export type Database = {
is_lead: boolean;
employment_type: EmploymentType;
weekly_hours: number;
monthly_salary_gross: number;
/** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */
monthly_salary_gross: number | null;
contract_type: ContractType;
contract_end_date: string | null;
paygrade: PaygradeType;
@@ -89,6 +129,7 @@ export type Database = {
entry_date: string;
exit_date: string | null;
exit_reason: string | null;
karenz_start_date: string | null;
karenz_return_date: string | null;
avatar_color: string | null;
created_at: string;
@@ -115,7 +156,6 @@ export type Database = {
is_lead?: boolean;
employment_type?: EmploymentType;
weekly_hours?: number;
monthly_salary_gross: number;
contract_type?: ContractType;
contract_end_date?: string | null;
paygrade?: PaygradeType;
@@ -124,6 +164,7 @@ export type Database = {
entry_date: string;
exit_date?: string | null;
exit_reason?: string | null;
karenz_start_date?: string | null;
karenz_return_date?: string | null;
avatar_color?: string | null;
created_at?: string;
@@ -138,6 +179,7 @@ export type Database = {
event_date: string;
event_type: HistoryEventType;
description: string;
reorg_scenario_id: string | null;
created_at: string;
};
Insert: {
@@ -146,6 +188,7 @@ export type Database = {
event_date: string;
event_type: HistoryEventType;
description: string;
reorg_scenario_id?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
@@ -240,47 +283,35 @@ export type Database = {
Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
Update: Partial<Database["public"]["Tables"]["reorg_moves"]["Insert"]>;
};
};
Views: {
employees_directory: NoRelationships & {
pending_org_changes: NoRelationships & {
Row: {
id: string;
personnel_number: number;
first_name: string;
last_name: string;
gender: GenderType;
birth_date: string;
sv_nummer: string | null;
nationality: string;
address: string | null;
address_country: string | null;
email: string;
phone: string | null;
team_id: string | null;
division_id: string;
job_title: string;
location_id: string;
manager_id: string | null;
org_level: number;
is_lead: boolean;
employment_type: EmploymentType;
weekly_hours: number;
monthly_salary_gross: number | null; // masked to null for non-admin sessions
contract_type: ContractType;
contract_end_date: string | null;
paygrade: PaygradeType;
source: SourceType;
status: EmploymentStatus;
entry_date: string;
exit_date: string | null;
exit_reason: string | null;
karenz_return_date: string | null;
avatar_color: string | null;
employee_id: string;
change_type: PendingChangeType;
effective_date: string;
payload: Record<string, unknown>;
reorg_scenario_id: string | null;
status: PendingChangeStatus;
created_by: string | null;
created_at: string;
updated_at: string;
applied_at: string | null;
};
Insert: {
id?: string;
employee_id: string;
change_type: PendingChangeType;
effective_date: string;
payload: Record<string, unknown>;
reorg_scenario_id?: string | null;
status?: PendingChangeStatus;
created_by?: string | null;
created_at?: string;
applied_at?: string | null;
};
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
};
};
Views: Record<string, never>;
Functions: {
hire_employee: { Args: { payload: Record<string, unknown> }; Returns: string };
terminate_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
@@ -295,6 +326,7 @@ export type Database = {
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
};
};
};

1225
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,13 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "node --env-file=.env.test.local node_modules/vitest/vitest.mjs run --config vitest.integration.config.ts",
"test:e2e": "playwright test",
"check": "npm run lint && npm run typecheck && npm run test && npm run build"
},
"dependencies": {
"@supabase/ssr": "^0.12.1",
@@ -21,10 +27,13 @@
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^9.39.5",
"eslint-config-next": "16.2.10",
"postcss": "^8.5.19",
"supabase": "^2.109.1",
"tailwindcss": "^4.3.2",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.1.10"
}
}

View File

@@ -2,9 +2,16 @@ import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename/export).
// This performs the optimistic auth check: redirect unauthenticated users to
// /login, and signed-in users away from /login. Real authorization (hr_admin
// vs manager) is enforced server-side via RLS, not here.
// This is the app's single entry-point gate (spec
// §2.2): unauthenticated users are sent to /login, and — this is the part
// that used to be missing — authenticated users who are NOT an active,
// explicitly-provisioned HR user are sent to /login too, with an error
// message, instead of being let through. Previously this only checked for
// a Supabase Auth session at all, which meant any signed-in user (even one
// with no profile row, or the old "manager" role) could open the app.
// This UI-layer gate is defense in depth, not the real boundary — every
// table is independently RLS-gated on is_hr_user() regardless of what this
// proxy does.
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
@@ -31,13 +38,25 @@ export async function proxy(request: NextRequest) {
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
if (!user && !isLoginRoute) {
if (!user) {
if (isLoginRoute) return response;
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
if (user && isLoginRoute) {
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
const isActiveHr = profile?.role === "hr" && profile?.is_active === true;
if (!isActiveHr) {
if (isLoginRoute) return response;
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("error", "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.");
return NextResponse.redirect(url);
}
if (isLoginRoute) {
const url = request.nextUrl.clone();
url.pathname = "/";
return NextResponse.redirect(url);

33
supabase/config.toml Normal file
View File

@@ -0,0 +1,33 @@
project_id = "alpenwerk-hr"
[api]
enabled = true
port = 55321
schemas = ["public", "graphql_public"]
extra_search_path = ["public"]
max_rows = 1000
[db]
port = 55322
major_version = 15
[studio]
enabled = true
port = 55323
[inbucket]
enabled = true
port = 55324
[analytics]
enabled = false
[auth]
enabled = true
site_url = "http://127.0.0.1:3000"
additional_redirect_urls = ["http://127.0.0.1:3000"]
jwt_expiry = 3600
enable_signup = true
[auth.email]
enable_confirmations = false

View File

@@ -0,0 +1,360 @@
-- Alpenwerk HR — initial schema
--
-- Based on spec §3, with the following corrections
-- (see the Phase 1 plan for the full rationale):
-- - gender_type restricted to m/w (spec's domain rules exclude "divers")
-- - employees.location replaced by a locations reference table + location_id FK
-- (the spec's own CHECK listed Wien/Linz/Graz, which contradicts §2's real site list)
-- - nationality constrained to the picklist named in §2
-- - added: locations, profiles (role-based access), employees_directory (salary-masked view)
-- - added: address/address_country/contract_end_date columns (required by §4.5's "Daten ändern"
-- panel and the befristet/"Befristet bis" rule, but missing from §3's literal table)
-- - added: 'Stammdatenänderung' history event type (required by §4.5, missing from §3's enum)
-- - added: reorg_scenarios.undo_snapshot jsonb (required by §4.7's undo feature)
-- - added: position number generation + org-unit auto-derivation as real functions/triggers
create extension if not exists "pgcrypto";
-- ── Org units ────────────────────────────────────────────────
create table divisions ( -- "Bereich", numbers 20xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^20\d{6}$'),
name text not null unique
);
create table departments ( -- "Abteilung", numbers 21xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^21\d{6}$'),
name text not null,
division_id uuid not null references divisions(id)
);
create table teams ( -- "Team", numbers 22xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^22\d{6}$'),
name text not null,
department_id uuid not null references departments(id)
);
-- ── Locations (site ties to a country; country picklist drives the UI's
-- "select country auto-selects its locations" rule from §2) ─────────
create table locations (
id uuid primary key default gen_random_uuid(),
name text not null unique, -- Wien-Hernals, Wolkersdorf, Köln, Brünn, Ljubljana
country text not null check (country in ('Österreich', 'Deutschland', 'Tschechien', 'Slowenien'))
);
-- ── Profiles (role-based access per §6) ─────────────────────
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text not null,
full_name text,
role text not null default 'manager' check (role in ('hr_admin', 'manager')),
created_at timestamptz not null default now()
);
-- ── Employees ────────────────────────────────────────────────
create type employment_status as enum ('Aktiv', 'Karenz', 'Geplant', 'Ausgetreten');
create type employment_type as enum ('Vollzeit', 'Teilzeit');
create type contract_type as enum ('unbefristet', 'befristet');
create type paygrade_type as enum ('A', 'B', 'C', 'D', 'E', 'F');
create type source_type as enum ('Intern', 'Extern');
create type gender_type as enum ('m', 'w');
create table employees (
id uuid primary key default gen_random_uuid(),
personnel_number int generated always as identity (start with 1001), -- Pers.-Nr.
first_name text not null,
last_name text not null,
gender gender_type not null,
birth_date date not null,
sv_nummer text,
nationality text not null default 'Österreich' check (nationality in (
'Österreich', 'Deutschland', 'Tschechien', 'Slowenien', 'Türkei',
'Serbien', 'Kroatien', 'Bosnien', 'Ungarn', 'Andere'
)),
address text,
address_country text check (address_country in ('Österreich', 'Deutschland', 'Tschechien', 'Slowenien', 'Andere')),
email text not null unique,
phone text,
team_id uuid references teams(id), -- nullable only for CEO / division heads without a team
division_id uuid not null references divisions(id), -- auto-derived from team_id by trigger when team_id is set
job_title text not null,
location_id uuid not null references locations(id),
manager_id uuid references employees(id),
org_level int not null default 3 check (org_level between 0 and 3), -- 0=CEO,1=division head,2=team lead,3=IC
is_lead boolean not null default false,
employment_type employment_type not null default 'Vollzeit',
weekly_hours numeric(4,1) not null default 38.5,
monthly_salary_gross numeric(10,2) not null check (monthly_salary_gross > 0), -- 14x/year convention
contract_type contract_type not null default 'unbefristet',
contract_end_date date,
paygrade paygrade_type not null default 'B',
source source_type not null default 'Extern',
status employment_status not null default 'Aktiv',
entry_date date not null,
exit_date date,
exit_reason text,
karenz_return_date date,
avatar_color text, -- hex, for initials badge; app falls back to a deterministic hash if null
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint chk_exit_after_entry check (exit_date is null or exit_date >= entry_date),
constraint chk_karenz_return_after_entry check (karenz_return_date is null or karenz_return_date >= entry_date),
constraint chk_befristet_end check (contract_type <> 'befristet' or contract_end_date is not null),
constraint chk_weekly_hours check (
(employment_type = 'Vollzeit' and weekly_hours = 38.5) or
(employment_type = 'Teilzeit' and weekly_hours > 0 and weekly_hours < 38.5)
)
);
create index on employees (team_id);
create index on employees (division_id);
create index on employees (manager_id);
create index on employees (status);
-- ── Employee history (append-only audit trail per person) ───
create type history_event_type as enum (
'Eintritt', 'Beförderung', 'Versetzung', 'Karenz', 'Vertragsänderung', 'Stammdatenänderung',
'Austritt', 'Wiedereintritt', 'Reorganisation', 'Gehaltsanpassung', 'Rückkehr'
);
create table employee_history (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
event_date date not null,
event_type history_event_type not null,
description text not null,
created_at timestamptz not null default now()
);
create index on employee_history (employee_id, event_date desc);
-- ── Positions (Planstellen) ──────────────────────────────────
create table positions (
id uuid primary key default gen_random_uuid(),
position_number text not null unique check (position_number ~ '^6\d{7}$'),
title text not null,
team_id uuid not null references teams(id),
division_id uuid not null references divisions(id), -- auto-derived from team_id by trigger
is_lead boolean not null default false,
reports_to_employee_id uuid references employees(id), -- the superior manager chosen at creation
status text not null default 'open' check (status in ('open', 'filled')),
created_at timestamptz not null default now(),
filled_at timestamptz,
filled_by_employee_id uuid references employees(id)
);
create index on positions (team_id);
create index on positions (status);
-- ── Hire drafts (resumable wizard state) ─────────────────────
create table hire_drafts (
id uuid primary key default gen_random_uuid(),
created_by uuid references auth.users(id),
step int not null default 0,
payload jsonb not null, -- full wizard form state
updated_at timestamptz not null default now()
);
-- ── Saved reports ────────────────────────────────────────────
create table saved_reports (
id uuid primary key default gen_random_uuid(),
created_by uuid references auth.users(id),
name text not null,
config jsonb not null, -- { measure, group, split, filters... }
created_at timestamptz not null default now()
);
-- ── Audit log (system-wide, immutable) ───────────────────────
create table audit_log (
id uuid primary key default gen_random_uuid(),
occurred_at timestamptz not null default now(),
actor_user_id uuid references auth.users(id),
actor_name text not null,
action text not null, -- e.g. 'Neueinstellung','Austritt','Versetzung','Beförderung','Karenz',
-- 'Vertragsänderung','Stammdatenänderung','Wiedereinstellung','Ausschreibung',
-- 'Interne Besetzung','Reorganisation','Reorganisation rückgängig','Rückkehr',
-- 'Gehaltsanpassung'
target_label text not null, -- human-readable name of what changed
target_employee_id uuid references employees(id),
details text
);
create index on audit_log (occurred_at desc);
-- ── Reorg scenarios (persistence of in-progress/applied reorg plans) ─
create table reorg_scenarios (
id uuid primary key default gen_random_uuid(),
name text not null,
effective_date date not null,
created_by uuid references auth.users(id),
applied boolean not null default false,
applied_at timestamptz,
undo_snapshot jsonb, -- pre-change employee state + history/audit high-water marks, for undo
created_at timestamptz not null default now()
);
create table reorg_moves (
id uuid primary key default gen_random_uuid(),
scenario_id uuid not null references reorg_scenarios(id) on delete cascade,
kind text not null check (kind in ('emp', 'team', 'abt', 'dept')),
payload jsonb not null -- employee ids / team id / dept id / target division, counts, labels
);
-- ── Functions & triggers ─────────────────────────────────────
-- Auto-derive division_id from team_id (keeps the denormalized division in sync
-- with the team's real parent chain; §3's closing instruction).
create or replace function fn_set_employee_org_unit()
returns trigger
language plpgsql
as $$
begin
if new.team_id is not null then
select dep.division_id into new.division_id
from teams t
join departments dep on dep.id = t.department_id
where t.id = new.team_id;
end if;
return new;
end;
$$;
create trigger trg_employees_set_org_unit
before insert or update of team_id on employees
for each row execute function fn_set_employee_org_unit();
create or replace function fn_set_position_org_unit()
returns trigger
language plpgsql
as $$
begin
select dep.division_id into new.division_id
from teams t
join departments dep on dep.id = t.department_id
where t.id = new.team_id;
return new;
end;
$$;
create trigger trg_positions_set_org_unit
before insert or update of team_id on positions
for each row execute function fn_set_position_org_unit();
create or replace function fn_touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger trg_employees_touch_updated_at
before update on employees
for each row execute function fn_touch_updated_at();
-- Unique 8-digit position numbers starting with '6' (§2).
create or replace function generate_position_number()
returns text
language plpgsql
as $$
declare
candidate text;
begin
loop
candidate := '6' || lpad(floor(random() * 10000000)::text, 7, '0');
exit when not exists (select 1 from positions where position_number = candidate);
end loop;
return candidate;
end;
$$;
alter table positions alter column position_number set default generate_position_number();
-- ── Role helper (SECURITY DEFINER avoids RLS recursion on profiles) ──
create or replace function is_hr_admin()
returns boolean
language sql
security definer
set search_path = public
stable
as $$
select exists (
select 1 from profiles p where p.id = auth.uid() and p.role = 'hr_admin'
);
$$;
-- ── Salary-masked read view for the manager role (§6) ────────
-- Owned by the migration role (postgres), which bypasses RLS on the base
-- table, so this view is reachable by both roles while column-masking
-- salary per session via is_hr_admin().
create view employees_directory as
select
e.id, e.personnel_number, e.first_name, e.last_name, e.gender, e.birth_date, e.sv_nummer,
e.nationality, e.address, e.address_country, e.email, e.phone, e.team_id, e.division_id,
e.job_title, e.location_id, e.manager_id, e.org_level, e.is_lead, e.employment_type,
e.weekly_hours,
case when is_hr_admin() then e.monthly_salary_gross else null end as monthly_salary_gross,
e.contract_type, e.contract_end_date, e.paygrade, e.source, e.status, e.entry_date, e.exit_date,
e.exit_reason, e.karenz_return_date, e.avatar_color, e.created_at, e.updated_at
from employees e;
grant select on employees_directory to authenticated;
-- ── Row Level Security ───────────────────────────────────────
alter table divisions enable row level security;
alter table departments enable row level security;
alter table teams enable row level security;
alter table locations enable row level security;
alter table profiles enable row level security;
alter table employees enable row level security;
alter table employee_history enable row level security;
alter table positions enable row level security;
alter table hire_drafts enable row level security;
alter table saved_reports enable row level security;
alter table audit_log enable row level security;
alter table reorg_scenarios enable row level security;
alter table reorg_moves enable row level security;
-- Org reference data: readable by any authenticated user, writable by hr_admin only.
create policy "org_read" on divisions for select using (auth.role() = 'authenticated');
create policy "org_write" on divisions for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on departments for select using (auth.role() = 'authenticated');
create policy "org_write" on departments for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on teams for select using (auth.role() = 'authenticated');
create policy "org_write" on teams for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on locations for select using (auth.role() = 'authenticated');
create policy "org_write" on locations for all using (is_hr_admin()) with check (is_hr_admin());
-- Profiles: users read their own row; hr_admin reads/writes all.
create policy "profiles_select_own" on profiles for select using (auth.uid() = id);
create policy "profiles_select_admin" on profiles for select using (is_hr_admin());
create policy "profiles_write_admin" on profiles for insert with check (is_hr_admin());
create policy "profiles_update_admin" on profiles for update using (is_hr_admin()) with check (is_hr_admin());
-- Employees: only hr_admin reads/writes the base table directly. The manager
-- role reads through employees_directory instead (salary masked there).
create policy "employees_admin_all" on employees for all using (is_hr_admin()) with check (is_hr_admin());
-- Employee history: any authenticated user can read; only hr_admin can append; immutable otherwise.
create policy "history_read" on employee_history for select using (auth.role() = 'authenticated');
create policy "history_insert_admin" on employee_history for insert with check (is_hr_admin());
-- Positions: any authenticated user can browse open positions; hr_admin manages them.
create policy "positions_read" on positions for select using (auth.role() = 'authenticated');
create policy "positions_write_admin" on positions for all using (is_hr_admin()) with check (is_hr_admin());
-- Hire drafts: scoped to their creator.
create policy "hire_drafts_owner" on hire_drafts for all
using (created_by = auth.uid()) with check (created_by = auth.uid());
-- Saved reports: scoped to their creator.
create policy "saved_reports_owner" on saved_reports for all
using (created_by = auth.uid()) with check (created_by = auth.uid());
-- Audit log: any authenticated user can read; only hr_admin can append; immutable (no update/delete policy).
create policy "audit_read" on audit_log for select using (auth.role() = 'authenticated');
create policy "audit_insert_admin" on audit_log for insert with check (is_hr_admin());
-- Reorg scenarios/moves: any authenticated user can see the (small) recent list; hr_admin manages them.
create policy "reorg_scenarios_read" on reorg_scenarios for select using (auth.role() = 'authenticated');
create policy "reorg_scenarios_write_admin" on reorg_scenarios for all using (is_hr_admin()) with check (is_hr_admin());
create policy "reorg_moves_read" on reorg_moves for select using (auth.role() = 'authenticated');
create policy "reorg_moves_write_admin" on reorg_moves for all using (is_hr_admin()) with check (is_hr_admin());

View File

@@ -0,0 +1,11 @@
-- Addendum to supabase/schema.sql — run after that file.
--
-- Staatsbürgerschaft and Wohnland now use a searchable picker over the
-- full UN member states list (193 countries, see lib/countries.ts)
-- instead of the original ~9/5-value picklists. The old CHECK constraints
-- would reject nearly all of those values, so they're dropped here. The
-- app is the source of truth for valid values (same approach the rest of
-- the app already relies on for large open-ended pickers); the columns
-- stay plain text (nationality keeps its NOT NULL).
alter table employees drop constraint if exists employees_nationality_check;
alter table employees drop constraint if exists employees_address_country_check;

View File

@@ -0,0 +1,555 @@
-- Alpenwerk HR — mutation RPCs (Phase 2/3)
--
-- One Postgres function per business mutation from the spec
-- §4.4/§4.5/§4.6/§4.7. Each function runs as SECURITY INVOKER (the caller's own
-- session), so the existing RLS policy on `employees` (hr_admin only) is the
-- real authorization gate; the is_hr_admin() check at the top of each function
-- just produces a clearer error message than a bare RLS violation.
--
-- A single function call is one Postgres transaction: if any statement raises,
-- everything in that call rolls back automatically. Run this whole file in the
-- Supabase SQL Editor after supabase/schema.sql.
alter table employee_history add column if not exists reorg_scenario_id uuid references reorg_scenarios(id);
create or replace function current_actor_name()
returns text language sql stable as $$
select coalesce(p.full_name, p.email, 'Unbekannt') from profiles p where p.id = auth.uid();
$$;
create or replace function require_hr_admin()
returns void language plpgsql as $$
begin
if not is_hr_admin() then
raise exception 'Nicht berechtigt: nur HR-Admin darf diese Aktion ausführen.';
end if;
end;
$$;
-- Manager derivation (§2's "reports-to" rule), used by every mutation that
-- changes an employee's team/leadership status.
create or replace function resolve_manager_for(p_team_id uuid, p_is_lead boolean, p_division_id uuid)
returns uuid language plpgsql as $$
declare
v_manager uuid;
begin
if p_team_id is not null and not p_is_lead then
select id into v_manager from employees
where team_id = p_team_id and is_lead = true and status <> 'Ausgetreten' limit 1;
elsif p_team_id is not null and p_is_lead then
select id into v_manager from employees
where division_id = p_division_id and team_id is null and org_level = 1 and status <> 'Ausgetreten' limit 1;
else
select id into v_manager from employees where org_level = 0 and status <> 'Ausgetreten' limit 1;
end if;
return v_manager;
end;
$$;
create or replace function generate_company_email(p_first_name text, p_last_name text)
returns text language plpgsql as $$
declare
base text;
candidate text;
n int := 1;
translit text;
begin
translit := lower(p_first_name || '.' || p_last_name);
translit := replace(replace(replace(replace(translit, 'ä','ae'), 'ö','oe'), 'ü','ue'), 'ß','ss');
base := regexp_replace(translit, '[^a-z0-9.]', '', 'g');
candidate := base || '@test.manner.at';
while exists (select 1 from employees where email = candidate) loop
n := n + 1;
candidate := base || n::text || '@test.manner.at';
end loop;
return candidate;
end;
$$;
-- ── Hire (§4.4) ───────────────────────────────────────────────
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, monthly_salary_gross, contract_type, contract_end_date,
paygrade, source, status, entry_date
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
(payload->>'monthly_salary_gross')::numeric,
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
nullif(payload->>'contract_end_date', '')::date,
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
coalesce((payload->>'source')::source_type, 'Extern'),
(case when (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- ── Austritt (§4.5) ───────────────────────────────────────────
create or replace function terminate_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select manager_id, first_name || ' ' || last_name into v_manager, v_name from employees where id = v_employee_id;
update employees set manager_id = v_manager where manager_id = v_employee_id and status <> 'Ausgetreten';
update employees set
status = 'Ausgetreten',
exit_date = (payload->>'exit_date')::date,
exit_reason = payload->>'exit_reason'
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'exit_date')::date, 'Austritt',
'Austritt (' || (payload->>'exit_reason') || ')' || case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, payload->>'exit_reason');
end;
$$;
-- ── Versetzung (§4.5) ─────────────────────────────────────────
create or replace function transfer_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_new_team_id uuid := (payload->>'new_team_id')::uuid;
v_division_id uuid;
v_manager uuid;
v_name text;
v_is_lead boolean;
begin
perform require_hr_admin();
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_new_team_id;
select is_lead, first_name || ' ' || last_name into v_is_lead, v_name from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_new_team_id, v_is_lead, v_division_id);
update employees set
team_id = v_new_team_id,
job_title = coalesce(nullif(payload->>'new_title', ''), job_title),
manager_id = v_manager
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'effective_date')::date, 'Versetzung',
'Versetzung, wirksam ab ' || (payload->>'effective_date') ||
case when payload->>'new_title' is not null and payload->>'new_title' <> '' then ', neue Position: ' || (payload->>'new_title') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || (payload->>'effective_date'));
end;
$$;
-- ── Beförderung (§4.5) ────────────────────────────────────────
create or replace function promote_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_old_paygrade paygrade_type;
v_name text;
v_details text;
begin
perform require_hr_admin();
select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name from employees where id = v_employee_id;
update employees set
job_title = payload->>'new_title',
monthly_salary_gross = (payload->>'new_salary')::numeric,
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_employee_id;
v_details := 'Neue Position: ' || (payload->>'new_title');
if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then
v_details := v_details || ', neue Paygrade: ' || (payload->>'new_paygrade');
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'effective_date')::date, 'Beförderung', v_details);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details);
end;
$$;
-- ── Karenz verwalten (§4.5) — adjust return date or record actual return ──
create or replace function adjust_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
update employees set karenz_return_date = (payload->>'new_return_date')::date where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Karenz',
'Rückkehrdatum angepasst auf ' || (payload->>'new_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Neues Rückkehrdatum: ' || (payload->>'new_return_date'));
end;
$$;
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead
into v_name, v_team_id, v_division_id, v_is_lead
from employees where id = v_employee_id;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
manager_id = v_manager,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'return_date'));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
end;
$$;
-- ── Daten ändern (§4.5) — diffs person vs. contract fields ────
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end
where id = v_employee_id;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── Wiedereinstellung (§4.5) ──────────────────────────────────
create or replace function rehire_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_rehire_date date := (payload->>'rehire_date')::date;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select team_id, division_id, is_lead, first_name || ' ' || last_name
into v_team_id, v_division_id, v_is_lead, v_name
from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = (case when v_rehire_date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
entry_date = v_rehire_date,
exit_date = null,
exit_reason = null,
manager_id = v_manager
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_rehire_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || (payload->>'rehire_date'));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || (payload->>'rehire_date'));
end;
$$;
-- ── Position ausschreiben (§4.6) ──────────────────────────────
create or replace function create_position(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_superior_id uuid := (payload->>'superior_employee_id')::uuid;
v_is_lead boolean := coalesce((payload->>'is_lead')::boolean, false);
v_team_id uuid;
begin
perform require_hr_admin();
if v_is_lead then
v_team_id := (payload->>'team_id')::uuid;
else
select team_id into v_team_id from employees where id = v_superior_id;
end if;
insert into positions (title, team_id, is_lead, reports_to_employee_id)
values (payload->>'title', v_team_id, v_is_lead, v_superior_id)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Ausschreibung', payload->>'title', 'Position ausgeschrieben');
return v_id;
end;
$$;
-- ── Intern besetzen (§4.6) ────────────────────────────────────
create or replace function staff_position_internally(payload jsonb)
returns void language plpgsql as $$
declare
v_position record;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_position.team_id, v_position.is_lead, v_position.division_id);
update employees set
team_id = v_position.team_id,
job_title = v_position.title,
source = 'Intern',
is_lead = case when v_position.is_lead then true else is_lead end,
org_level = case when v_position.is_lead then 2 else org_level end,
manager_id = v_manager
where id = v_employee_id;
if v_position.is_lead then
update employees set manager_id = v_employee_id
where team_id = v_position.team_id and id <> v_employee_id and is_lead = false and status <> 'Ausgetreten';
end if;
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_employee_id
where id = v_position.id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Versetzung', 'Interne Besetzung: ' || v_position.title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Interne Besetzung', v_name, v_employee_id, v_position.title);
end;
$$;
-- ── Reorganisation (§4.7) ─────────────────────────────────────
-- payload: { name, effective_date, moves: [{ kind, label, employee_ids: uuid[], target_team_id }] }
create or replace function apply_reorg(payload jsonb)
returns uuid language plpgsql as $$
declare
v_scenario_id uuid;
v_move jsonb;
v_employee_id text;
v_target_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_snapshot jsonb := '{}'::jsonb;
v_old record;
v_total_moves int := 0;
begin
perform require_hr_admin();
insert into reorg_scenarios (name, effective_date, created_by, applied, applied_at)
values (payload->>'name', (payload->>'effective_date')::date, auth.uid(), true, now())
returning id into v_scenario_id;
for v_move in select * from jsonb_array_elements(payload->'moves')
loop
v_target_team_id := (v_move->>'target_team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_target_team_id;
insert into reorg_moves (scenario_id, kind, payload) values (v_scenario_id, v_move->>'kind', v_move);
for v_employee_id in select jsonb_array_elements_text(v_move->'employee_ids')
loop
select * into v_old from employees where id = v_employee_id::uuid;
v_snapshot := v_snapshot || jsonb_build_object(v_employee_id, jsonb_build_object(
'team_id', v_old.team_id, 'division_id', v_old.division_id, 'manager_id', v_old.manager_id
));
v_is_lead := v_old.is_lead;
v_manager := resolve_manager_for(v_target_team_id, v_is_lead, v_division_id);
update employees set team_id = v_target_team_id, manager_id = v_manager where id = v_employee_id::uuid;
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
values (v_employee_id::uuid, (payload->>'effective_date')::date, 'Reorganisation',
'Reorganisation "' || (payload->>'name') || '": neues Team zugewiesen', v_scenario_id);
v_total_moves := v_total_moves + 1;
end loop;
end loop;
update reorg_scenarios set undo_snapshot = v_snapshot where id = v_scenario_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation', payload->>'name', v_total_moves || ' Mitarbeiter:innen betroffen');
return v_scenario_id;
end;
$$;
create or replace function undo_reorg(payload jsonb)
returns void language plpgsql as $$
declare
v_scenario record;
v_key text;
v_val jsonb;
begin
perform require_hr_admin();
select * into v_scenario from reorg_scenarios where id = (payload->>'scenario_id')::uuid and applied = true;
if not found or v_scenario.undo_snapshot is null then
raise exception 'Reorganisation kann nicht rückgängig gemacht werden (kein Snapshot vorhanden).';
end if;
for v_key, v_val in select * from jsonb_each(v_scenario.undo_snapshot)
loop
update employees set
team_id = nullif(v_val->>'team_id','')::uuid,
division_id = (v_val->>'division_id')::uuid,
manager_id = nullif(v_val->>'manager_id','')::uuid
where id = v_key::uuid;
end loop;
delete from employee_history where reorg_scenario_id = v_scenario.id;
update reorg_scenarios set applied = false where id = v_scenario.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation rückgängig', v_scenario.name, 'Reorganisation zurückgesetzt');
end;
$$;

View File

@@ -0,0 +1,29 @@
-- Addendum to supabase/functions.sql — run after that file.
--
-- The spec's "Karenz verwalten" panel (§4.5) only covers employees already on
-- Karenz (adjust return date / record return). It doesn't specify the fields
-- for *starting* a Karenz period from Aktiv, even though §4.3 clearly shows a
-- "Karenz" button for that case. This fills that gap with a reasonable,
-- minimal form: start date + planned return date + optional note.
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
update employees set status = 'Karenz', karenz_return_date = (payload->>'planned_return_date')::date
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'karenz_start_date')::date, 'Karenz',
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;

View File

@@ -0,0 +1,15 @@
-- Addendum to supabase/schema.sql + functions.sql — run after those.
--
-- employee_history intentionally has no UPDATE/DELETE policy (§4.9's
-- "unveraenderbar" / append-only requirement). But undo_reorg needs to
-- remove the specific history rows a reorg created — found via live
-- testing: the DELETE inside undo_reorg silently matched 0 rows under RLS
-- (no error, since RLS just filters DELETE-eligible rows to none), leaving
-- Reorganisation entries behind after an otherwise-successful undo.
--
-- Scope the exception as narrowly as possible: hr_admin may delete a
-- history row only if it carries a reorg_scenario_id, i.e. only rows
-- apply_reorg created. Eintritt/Austritt/Beförderung/etc. rows (always
-- reorg_scenario_id IS NULL) remain fully immutable.
create policy "history_delete_admin_reorg_undo" on employee_history for delete
using (is_hr_admin() and reorg_scenario_id is not null);

View File

@@ -0,0 +1,71 @@
-- Addendum to supabase/functions.sql — run after that file (and functions_2/3.sql).
--
-- "Daten ändern" had no "Wirksam ab" field, unlike Versetzung/Beförderung/
-- Karenz — every change was silently logged with today's date regardless
-- of when it should actually take effect. Adds an effective_date input
-- (defaults to today if omitted) used for both the history event_date and
-- noted in the change description.
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end
where id = v_employee_id;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;

View File

@@ -0,0 +1,172 @@
-- HR-only access model (spec §2)
--
-- Rationale: the app previously had two profile roles (hr_admin / manager),
-- with "manager" intended as a read-only, salary-masked role. The revised
-- scope is HR-only: nobody except an active, explicitly-provisioned HR user
-- may open the app at all. This migration:
-- 1. Collapses profiles.role to the single allowed value 'hr'.
-- 2. Adds profiles.is_active (default false — new users get zero access
-- until an existing HR user explicitly activates them; see §2.3).
-- 3. Renames the authorization gate from is_hr_admin() to is_hr_user()
-- (checks role='hr' AND is_active=true) to match §2.4's naming and
-- semantics, and repoints every RLS policy at it.
-- 4. Fixes a real gap: several tables (divisions/departments/teams/
-- locations, employee_history, positions, audit_log, reorg_scenarios/
-- reorg_moves) had read policies scoped to `auth.role() = 'authenticated'`
-- — i.e. ANY signed-in Supabase Auth user, not just HR. Every one of
-- those is tightened to is_hr_user().
-- 5. Drops the employees_directory salary-masking view: with the
-- "manager" role gone and salary out of MVP scope (see the salary
-- deprecation migration), there is nothing left to mask and no second
-- role to mask it from. All reads now go through the base `employees`
-- table, gated by the same is_hr_user()-only policy as writes.
--
-- The application layer (app/(app)/layout.tsx) previously let any
-- authenticated Supabase user reach the shell (it only checked for a
-- session, not profiles.role/is_active) and merely hid the edit UI for
-- non-admins. That check is being replaced in the app code alongside this
-- migration — this migration is what makes that enforceable at the data
-- layer regardless of what the UI does.
-- ── profiles: single role, explicit activation ──────────────────────
alter table profiles drop constraint if exists profiles_role_check;
update profiles set role = 'hr' where role <> 'hr';
alter table profiles add constraint profiles_role_check check (role = 'hr');
alter table profiles alter column role set default 'hr';
alter table profiles add column if not exists is_active boolean not null default false;
alter table profiles add column if not exists created_by uuid references auth.users(id);
alter table profiles add column if not exists updated_at timestamptz not null default now();
-- Any *existing* profile row (i.e. someone already explicitly provisioned
-- before this migration) keeps working — activation is only "off by
-- default" for rows created from here on.
update profiles set is_active = true where is_active = false;
create or replace function fn_touch_profiles_updated_at()
returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_profiles_touch_updated_at on profiles;
create trigger trg_profiles_touch_updated_at
before update on profiles
for each row execute function fn_touch_profiles_updated_at();
-- ── Authorization gate: is_hr_user() replaces is_hr_admin() ─────────
create or replace function is_hr_user()
returns boolean
language sql
security definer
set search_path = public
stable
as $$
select exists (
select 1 from profiles p where p.id = auth.uid() and p.role = 'hr' and p.is_active = true
);
$$;
create or replace function current_hr_user_id()
returns uuid
language sql
security definer
set search_path = public
stable
as $$
select p.id from profiles p where p.id = auth.uid() and p.role = 'hr' and p.is_active = true;
$$;
-- Back-compat shim so any not-yet-migrated call site (or a function defined
-- in an older addendum file not touched by this migration) keeps working;
-- new code should call is_hr_user() directly. Safe to drop once nothing
-- references is_hr_admin() anymore (tracked in docs/decisions).
create or replace function is_hr_admin()
returns boolean
language sql
stable
as $$
select is_hr_user();
$$;
create or replace function require_hr_admin()
returns void language plpgsql as $$
begin
if not is_hr_user() then
raise exception 'Nicht berechtigt: nur aktive HR-Benutzer:innen dürfen diese Aktion ausführen.';
end if;
end;
$$;
-- ── Re-scope every "any authenticated user" read policy to HR-only ──
drop policy if exists "org_read" on divisions;
create policy "org_read" on divisions for select using (is_hr_user());
drop policy if exists "org_write" on divisions;
create policy "org_write" on divisions for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "org_read" on departments;
create policy "org_read" on departments for select using (is_hr_user());
drop policy if exists "org_write" on departments;
create policy "org_write" on departments for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "org_read" on teams;
create policy "org_read" on teams for select using (is_hr_user());
drop policy if exists "org_write" on teams;
create policy "org_write" on teams for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "org_read" on locations;
create policy "org_read" on locations for select using (is_hr_user());
drop policy if exists "org_write" on locations;
create policy "org_write" on locations for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "history_read" on employee_history;
create policy "history_read" on employee_history for select using (is_hr_user());
drop policy if exists "history_insert_admin" on employee_history;
create policy "history_insert_admin" on employee_history for insert with check (is_hr_user());
drop policy if exists "positions_read" on positions;
create policy "positions_read" on positions for select using (is_hr_user());
drop policy if exists "positions_write_admin" on positions;
create policy "positions_write_admin" on positions for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "audit_read" on audit_log;
create policy "audit_read" on audit_log for select using (is_hr_user());
drop policy if exists "audit_insert_admin" on audit_log;
create policy "audit_insert_admin" on audit_log for insert with check (is_hr_user());
drop policy if exists "reorg_scenarios_read" on reorg_scenarios;
create policy "reorg_scenarios_read" on reorg_scenarios for select using (is_hr_user());
drop policy if exists "reorg_scenarios_write_admin" on reorg_scenarios;
create policy "reorg_scenarios_write_admin" on reorg_scenarios for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "reorg_moves_read" on reorg_moves;
create policy "reorg_moves_read" on reorg_moves for select using (is_hr_user());
drop policy if exists "reorg_moves_write_admin" on reorg_moves;
create policy "reorg_moves_write_admin" on reorg_moves for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "employees_admin_all" on employees;
create policy "employees_hr_all" on employees for all using (is_hr_user()) with check (is_hr_user());
-- profiles: users may always read their own row (needed to determine their
-- own HR status before is_hr_user() would otherwise apply); HR manages all.
drop policy if exists "profiles_select_admin" on profiles;
create policy "profiles_select_admin" on profiles for select using (is_hr_user());
drop policy if exists "profiles_write_admin" on profiles;
create policy "profiles_write_admin" on profiles for insert with check (is_hr_user());
drop policy if exists "profiles_update_admin" on profiles;
create policy "profiles_update_admin" on profiles for update using (is_hr_user()) with check (is_hr_user());
-- hire_drafts / saved_reports stay owner-scoped (unchanged) — but an owner
-- who is no longer an active HR user should not retain access either.
drop policy if exists "hire_drafts_owner" on hire_drafts;
create policy "hire_drafts_owner" on hire_drafts for all
using (created_by = auth.uid() and is_hr_user()) with check (created_by = auth.uid() and is_hr_user());
drop policy if exists "saved_reports_owner" on saved_reports;
create policy "saved_reports_owner" on saved_reports for all
using (created_by = auth.uid() and is_hr_user()) with check (created_by = auth.uid() and is_hr_user());
-- ── Drop the salary-masking view: no second role left to mask from ──
drop view if exists employees_directory;

View File

@@ -0,0 +1,38 @@
-- Deferred/effective-dated changes (spec §3.3)
--
-- Finding from the consolidation review: transfer_employee, promote_employee,
-- start_karenz, change_employee_data, and apply_reorg all accepted a
-- "Wirksam ab" / effective date, but only ever used it as metadata for the
-- employee_history/audit_log rows — the actual `update employees` always
-- ran immediately regardless of that date. A transfer or promotion dated
-- months in the future silently overwrote the *current* live record today.
-- This table backs the fix: when an effective date is in the future, the
-- mutating RPC stores the intended change here instead of writing it to
-- `employees` right away; a scheduled job (apply_due_pending_changes(),
-- see the following migration, invoked by a Vercel Cron route handler)
-- applies it once its date arrives. The employee_history/audit_log rows are
-- written immediately either way (dated with the effective date), which is
-- what already drives the "zukünftig" badge in the Historie tab.
create table pending_org_changes (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
change_type text not null check (change_type in (
'transfer', 'promotion', 'karenz_start', 'karenz_return', 'contract_change', 'reorg'
)),
effective_date date not null,
payload jsonb not null,
reorg_scenario_id uuid references reorg_scenarios(id) on delete cascade,
status text not null default 'pending' check (status in ('pending', 'applied', 'cancelled')),
created_by uuid references auth.users(id),
created_at timestamptz not null default now(),
applied_at timestamptz
);
create index on pending_org_changes (employee_id);
create index on pending_org_changes (status, effective_date);
create index on pending_org_changes (reorg_scenario_id);
alter table pending_org_changes enable row level security;
create policy "pending_org_changes_hr_all" on pending_org_changes for all
using (is_hr_user()) with check (is_hr_user());

View File

@@ -0,0 +1,125 @@
-- Salary out of MVP scope (spec §4)
--
-- Decision: do NOT drop employees.monthly_salary_gross. This is a live
-- Supabase project that may already hold seeded/real rows with values in
-- this column; a destructive drop is unrecoverable and unnecessary to
-- achieve the actual goal (removing salary from the product surface).
-- Instead: relax the column so the app can stop supplying it, mark it
-- deprecated, and stop every RPC from reading/writing it. A future
-- migration MAY drop the column outright once it's confirmed nothing in
-- any environment still depends on it (tracked in docs/decisions).
alter table employees alter column monthly_salary_gross drop not null;
alter table employees drop constraint if exists employees_monthly_salary_gross_check;
comment on column employees.monthly_salary_gross is
'DEPRECATED (2026 consolidation): salary is out of MVP scope. Column kept '
'only because it may hold pre-existing data; the application no longer '
'reads or writes it (see hire_employee/promote_employee). Candidate for '
'a future DROP COLUMN once confirmed unused across all environments.';
-- hire_employee: stop requiring/writing salary.
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
nullif(payload->>'contract_end_date', '')::date,
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
coalesce((payload->>'source')::source_type, 'Extern'),
(case when (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- promote_employee: no longer accepts/writes new_salary; paygrade remains
-- (it's an organizational/functional classification, not a derived salary
-- figure — see §4 point 7).
create or replace function promote_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old_paygrade paygrade_type;
v_name text;
v_details text;
begin
perform require_hr_admin();
select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name from employees where id = v_employee_id;
v_details := 'Neue Position: ' || (payload->>'new_title');
if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then
v_details := v_details || ', neue Paygrade: ' || (payload->>'new_paygrade');
end if;
if v_effective_date <= current_date then
update employees set
job_title = payload->>'new_title',
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'promotion', v_effective_date,
jsonb_build_object('new_title', payload->>'new_title', 'new_paygrade', payload->>'new_paygrade'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Beförderung', v_details);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details);
end;
$$;

View File

@@ -0,0 +1,387 @@
-- Fix effective-dating for Versetzung, Karenz (start + return), Daten
-- ändern, and Reorganisation (spec §3.3, §7.14).
--
-- Pattern used throughout: if the effective/return date is today or in the
-- past, behave exactly as before (immediate write). If it's in the future,
-- skip the `update employees` and instead record the intended change in
-- pending_org_changes; employee_history/audit_log are written immediately
-- either way, dated with the effective date (this is what already drives
-- the "zukünftig" badge in the Historie tab — unchanged). A separate
-- apply_due_pending_changes() function (called by a scheduled job) applies
-- due rows once their date arrives, re-resolving manager_id fresh at apply
-- time rather than trusting a value computed when the change was requested.
-- ── Versetzung ───────────────────────────────────────────────────
create or replace function transfer_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_new_team_id uuid := (payload->>'new_team_id')::uuid;
v_effective_date date := (payload->>'effective_date')::date;
v_division_id uuid;
v_manager uuid;
v_name text;
v_is_lead boolean;
begin
perform require_hr_admin();
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_new_team_id;
select is_lead, first_name || ' ' || last_name into v_is_lead, v_name from employees where id = v_employee_id;
if v_effective_date <= current_date then
v_manager := resolve_manager_for(v_new_team_id, v_is_lead, v_division_id);
update employees set
team_id = v_new_team_id,
job_title = coalesce(nullif(payload->>'new_title', ''), job_title),
manager_id = v_manager
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'transfer', v_effective_date,
jsonb_build_object('new_team_id', v_new_team_id, 'new_title', payload->>'new_title'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Versetzung',
'Versetzung, wirksam ab ' || (payload->>'effective_date') ||
case when payload->>'new_title' is not null and payload->>'new_title' <> '' then ', neue Position: ' || (payload->>'new_title') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || (payload->>'effective_date'));
end;
$$;
-- ── Karenz antreten ──────────────────────────────────────────────
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_start_date date := (payload->>'karenz_start_date')::date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_return_date = (payload->>'planned_return_date')::date
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_start_date, 'Karenz',
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;
-- ── Rückkehr aus Karenz ──────────────────────────────────────────
-- status/manager_id/karenz_return_date already correctly waited for the
-- return date; employment_type/weekly_hours did not (fixed here).
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead
into v_name, v_team_id, v_division_id, v_is_lead
from employees where id = v_employee_id;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
manager_id = v_manager,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
-- Only record the planned date now; the employment-mode change itself
-- (and the status/manager flip) waits for record_karenz_return's
-- effective date, applied later by apply_due_pending_changes().
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'return_date'));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
end;
$$;
-- ── Daten ändern ─────────────────────────────────────────────────
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_immediate boolean;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end
where id = v_employee_id;
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload);
end if;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── Reorganisation ───────────────────────────────────────────────
-- Immediate moves (effective_date <= today) behave exactly as before.
-- Future-dated scenarios write no employee mutations at all yet; one
-- pending_org_changes row per affected employee is queued instead, and
-- reorg_scenarios.applied stays false until every one of them is applied.
create or replace function apply_reorg(payload jsonb)
returns uuid language plpgsql as $$
declare
v_scenario_id uuid;
v_effective_date date := (payload->>'effective_date')::date;
v_immediate boolean := (payload->>'effective_date')::date <= current_date;
v_move jsonb;
v_employee_id text;
v_target_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_snapshot jsonb := '{}'::jsonb;
v_old record;
v_total_moves int := 0;
begin
perform require_hr_admin();
insert into reorg_scenarios (name, effective_date, created_by, applied, applied_at)
values (payload->>'name', v_effective_date, auth.uid(), v_immediate, case when v_immediate then now() else null end)
returning id into v_scenario_id;
for v_move in select * from jsonb_array_elements(payload->'moves')
loop
v_target_team_id := (v_move->>'target_team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_target_team_id;
insert into reorg_moves (scenario_id, kind, payload) values (v_scenario_id, v_move->>'kind', v_move);
for v_employee_id in select jsonb_array_elements_text(v_move->'employee_ids')
loop
select * into v_old from employees where id = v_employee_id::uuid;
v_snapshot := v_snapshot || jsonb_build_object(v_employee_id, jsonb_build_object(
'team_id', v_old.team_id, 'division_id', v_old.division_id, 'manager_id', v_old.manager_id
));
if v_immediate then
v_is_lead := v_old.is_lead;
v_manager := resolve_manager_for(v_target_team_id, v_is_lead, v_division_id);
update employees set team_id = v_target_team_id, manager_id = v_manager where id = v_employee_id::uuid;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload, reorg_scenario_id)
values (v_employee_id::uuid, 'reorg', v_effective_date,
jsonb_build_object('target_team_id', v_target_team_id), v_scenario_id);
end if;
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
values (v_employee_id::uuid, v_effective_date, 'Reorganisation',
'Reorganisation "' || (payload->>'name') || '": neues Team zugewiesen', v_scenario_id);
v_total_moves := v_total_moves + 1;
end loop;
end loop;
update reorg_scenarios set undo_snapshot = v_snapshot where id = v_scenario_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation', payload->>'name', v_total_moves || ' Mitarbeiter:innen betroffen');
return v_scenario_id;
end;
$$;
-- ── Applies every due pending change ─────────────────────────────
-- Invoked by the /api/cron/apply-pending-changes route handler (Vercel
-- Cron, daily) using the service-role client — this is a system process,
-- not a user action, so it does not go through require_hr_admin(); it is
-- SECURITY DEFINER precisely so it can run outside any HR user's session.
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_start' then
update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
manager_id = v_manager,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
-- This bypasses RLS (SECURITY DEFINER) by design so the daily cron job can
-- run it without any HR user session — which means it must NOT be callable
-- by ordinary app roles (an authenticated HR user calling it early would
-- force-apply not-yet-due changes ahead of their effective date).
revoke execute on function apply_due_pending_changes() from public;
revoke execute on function apply_due_pending_changes() from anon;
revoke execute on function apply_due_pending_changes() from authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,59 @@
-- Make reorg undo respect employee_history's append-only contract
-- (spec §6.1: "append-only, keine normale
-- Update-/Delete-Funktion").
--
-- The previous undo_reorg deleted the employee_history rows a reorg had
-- created (functions.sql:549), which required a narrow RLS carve-out
-- (functions_3.sql's "history_delete_admin_reorg_undo" policy) allowing
-- hr_admin to delete reorg-tagged history rows. That is the one place in
-- the whole schema where history was not actually immutable. Fixed by
-- appending a compensating "Reorganisation rückgängig" history entry per
-- affected employee instead of deleting anything — the original
-- Reorganisation rows stay in the record, exactly like every other history
-- event type. The now-unused delete policy is dropped.
drop policy if exists "history_delete_admin_reorg_undo" on employee_history;
create or replace function undo_reorg(payload jsonb)
returns void language plpgsql as $$
declare
v_scenario record;
v_key text;
v_val jsonb;
v_name text;
v_count int := 0;
begin
perform require_hr_admin();
select * into v_scenario from reorg_scenarios where id = (payload->>'scenario_id')::uuid and applied = true;
if not found or v_scenario.undo_snapshot is null then
raise exception 'Reorganisation kann nicht rückgängig gemacht werden (kein Snapshot vorhanden).';
end if;
-- Any pending (not-yet-applied) deferred moves belonging to this scenario
-- are cancelled rather than left to fire later against a since-reverted
-- state.
update pending_org_changes set status = 'cancelled'
where reorg_scenario_id = v_scenario.id and status = 'pending';
for v_key, v_val in select * from jsonb_each(v_scenario.undo_snapshot)
loop
update employees set
team_id = nullif(v_val->>'team_id','')::uuid,
division_id = (v_val->>'division_id')::uuid,
manager_id = nullif(v_val->>'manager_id','')::uuid
where id = v_key::uuid;
select first_name || ' ' || last_name into v_name from employees where id = v_key::uuid;
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
values (v_key::uuid, current_date, 'Reorganisation',
'Reorganisation "' || v_scenario.name || '" rückgängig gemacht — vorheriges Team wiederhergestellt', v_scenario.id);
v_count := v_count + 1;
end loop;
update reorg_scenarios set applied = false where id = v_scenario.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation rückgängig', v_scenario.name, v_count || ' Mitarbeiter:innen zurückgesetzt');
end;
$$;

View File

@@ -0,0 +1,33 @@
-- Performance indexes (spec §15).
--
-- Existing indexes already cover employees(team_id/division_id/manager_id/
-- status), employee_history(employee_id, event_date desc), positions(team_id/
-- status), audit_log(occurred_at desc), and the new pending_org_changes
-- table's own indexes (see its migration). This adds the gaps: location-based
-- filtering, entry/exit date range queries (dashboard "upcoming" widget +
-- Eintritte/Austritte reports), personnel-number lookup (also enforces the
-- uniqueness a personnel number should have, which the identity column
-- alone did not), reorg/audit foreign-key lookups, and trigram search
-- support for the employee list's free-text search box.
create unique index if not exists idx_employees_personnel_number on employees (personnel_number);
create index if not exists idx_employees_location_id on employees (location_id);
create index if not exists idx_employees_entry_date on employees (entry_date);
create index if not exists idx_employees_exit_date on employees (exit_date) where exit_date is not null;
create index if not exists idx_employees_karenz_return_date on employees (karenz_return_date) where karenz_return_date is not null;
create index if not exists idx_audit_log_target_employee_id on audit_log (target_employee_id) where target_employee_id is not null;
create index if not exists idx_employee_history_reorg_scenario_id on employee_history (reorg_scenario_id) where reorg_scenario_id is not null;
create index if not exists idx_employee_history_event_type on employee_history (event_type);
create index if not exists idx_reorg_scenarios_applied on reorg_scenarios (applied, applied_at desc);
create index if not exists idx_positions_division_id on positions (division_id);
-- Free-text search over name/title (Mitarbeiter:innen list search box) —
-- trigram index so ILIKE '%term%' queries can use an index instead of a
-- sequential scan.
create extension if not exists pg_trgm;
create index if not exists idx_employees_name_trgm
on employees using gin ((first_name || ' ' || last_name) gin_trgm_ops);
create index if not exists idx_employees_job_title_trgm
on employees using gin (job_title gin_trgm_ops);

View File

@@ -0,0 +1,25 @@
-- Explicit schema/table/sequence/routine grants for anon/authenticated/
-- service_role.
--
-- Found while standing up a local Supabase instance to run the integration
-- test suite (spec §17): every table in this schema
-- relies on RLS policies to restrict access, but RLS only ever *narrows*
-- what an already-GRANTed role may do — Postgres still checks ordinary
-- object privileges first, independent of a role's BYPASSRLS flag. On
-- Supabase's hosted platform this GRANT setup is applied automatically
-- during project provisioning, so it was invisible here: every previous
-- migration worked fine against the already-provisioned hosted project, but
-- the exact same migrations against a *fresh* Postgres (a new local dev
-- instance, a disaster-recovery restore, or CI) fail with "permission
-- denied for table X" even for the service role, since that grant was never
-- actually part of this repo's own migrations. Making it explicit here
-- makes the schema fully self-contained and reprovisionable from scratch.
grant usage on schema public to anon, authenticated, service_role;
grant all on all tables in schema public to anon, authenticated, service_role;
grant all on all sequences in schema public to anon, authenticated, service_role;
grant all on all routines in schema public to anon, authenticated, service_role;
alter default privileges in schema public grant all on tables to anon, authenticated, service_role;
alter default privileges in schema public grant all on sequences to anon, authenticated, service_role;
alter default privileges in schema public grant all on routines to anon, authenticated, service_role;

View File

@@ -0,0 +1,248 @@
-- Two data-integrity rules named explicitly in the spec (§3.7, §11 test scenarios #14 and #17) that had no enforcement
-- anywhere — not in the UI, not in a Server Action, not as a DB constraint:
--
-- 1. "Rückkehr darf nicht vor Beginn der Karenz liegen" — there was no
-- column tracking when a Karenz period actually started (only a
-- free-text history row), so this could not be validated at all.
-- 2. "Historieneinträge dürfen nicht vor dem Eintritt liegen" — nothing
-- stopped an employee_history row from being inserted with an
-- event_date earlier than that employee's entry_date.
-- ── 1. Track karenz_start_date; validate return against it ──────────
alter table employees add column if not exists karenz_start_date date;
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_start_date date := (payload->>'karenz_start_date')::date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_start_date = v_start_date,
karenz_return_date = (payload->>'planned_return_date')::date
where id = v_employee_id;
else
update employees set karenz_start_date = v_start_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_start_date, 'Karenz',
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;
create or replace function adjust_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_new_return_date date := (payload->>'new_return_date')::date;
v_karenz_start date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date into v_name, v_karenz_start from employees where id = v_employee_id;
if v_karenz_start is not null and v_new_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Karenzbeginn (%) liegen.', v_karenz_start;
end if;
update employees set karenz_return_date = v_new_return_date where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Karenz',
'Rückkehrdatum angepasst auf ' || (payload->>'new_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Neues Rückkehrdatum: ' || (payload->>'new_return_date'));
end;
$$;
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead, karenz_start_date
into v_name, v_team_id, v_division_id, v_is_lead, v_karenz_start
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Karenzbeginn (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'return_date'));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
end;
$$;
-- apply_due_pending_changes: clear karenz_start_date once the deferred
-- return actually applies (mirrors the immediate branch above).
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_start' then
update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;
-- ── 2. History entries may never predate the employee's entry date ───
create or replace function fn_check_history_not_before_entry()
returns trigger
language plpgsql
as $$
declare
v_entry_date date;
begin
select entry_date into v_entry_date from employees where id = new.employee_id;
if v_entry_date is not null and new.event_date < v_entry_date then
raise exception 'Historieneintrag (%) darf nicht vor dem Eintrittsdatum (%) liegen.', new.event_date, v_entry_date;
end if;
return new;
end;
$$;
drop trigger if exists trg_history_not_before_entry on employee_history;
create trigger trg_history_not_before_entry
before insert on employee_history
for each row execute function fn_check_history_not_before_entry();

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) => {

View File

@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it } from "vitest";
import {
adminClient,
createBareAuthUser,
createHrUser,
deleteTestUser,
signInAs,
type TestUser,
} from "./helpers";
// HR-only access model (supabase/migrations/20260714120000_hr_only_access.sql):
// nobody except an active, explicitly-provisioned HR user may read or write
// anything beyond their own profile row.
describe("HR-only access (is_hr_user gate)", () => {
const createdUsers: TestUser[] = [];
afterEach(async () => {
while (createdUsers.length) {
const user = createdUsers.pop()!;
await deleteTestUser(user);
}
});
it("a bare authenticated user (no profile row) reads zero employees, not an error", async () => {
const user = await createBareAuthUser();
createdUsers.push(user);
const client = await signInAs(user);
const { data, error } = await client.from("employees").select("id");
expect(error).toBeNull();
expect(data).toEqual([]);
});
it("a bare authenticated user cannot insert into org reference tables", async () => {
const user = await createBareAuthUser();
createdUsers.push(user);
const client = await signInAs(user);
const { error } = await client.from("divisions").insert({ org_number: "20999999", name: `Test-${user.id}` });
expect(error).not.toBeNull();
});
it("an inactive HR profile can read its own profile row", async () => {
const user = await createHrUser({ active: false });
createdUsers.push(user);
const client = await signInAs(user);
const { data, error } = await client.from("profiles").select("id, is_active").eq("id", user.id);
expect(error).toBeNull();
expect(data).toHaveLength(1);
expect(data?.[0].is_active).toBe(false);
});
it("an inactive HR profile reads zero employees and cannot call mutation RPCs", async () => {
const user = await createHrUser({ active: false });
createdUsers.push(user);
const client = await signInAs(user);
const { data: employees, error: readError } = await client.from("employees").select("id");
expect(readError).toBeNull();
expect(employees).toEqual([]);
const { error: rpcError } = await client.rpc("hire_employee", {
payload: { first_name: "X", last_name: "Y", gender: "m", birth_date: "1990-01-01", entry_date: "2020-01-01" },
});
expect(rpcError?.message).toMatch(/Nicht berechtigt/);
});
it("an active HR user reads the seeded employee roster", async () => {
const user = await createHrUser({ active: true });
createdUsers.push(user);
const client = await signInAs(user);
const { data, error } = await client.from("employees").select("id").limit(1);
expect(error).toBeNull();
expect((data ?? []).length).toBeGreaterThan(0);
});
it("apply_due_pending_changes is revoked from authenticated users, even active HR", async () => {
const user = await createHrUser({ active: true });
createdUsers.push(user);
const client = await signInAs(user);
const { error } = await client.rpc("apply_due_pending_changes");
expect(error).not.toBeNull();
});
it("apply_due_pending_changes is callable by the service-role client", async () => {
const { data, error } = await adminClient.rpc("apply_due_pending_changes");
expect(error).toBeNull();
expect(typeof data).toBe("number");
});
});

View File

@@ -0,0 +1,123 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
adminClient,
createHrUser,
deleteTestEmployee,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededTeam,
signInAs,
type TestUser,
} from "./helpers";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/types";
// Two guards added by supabase/migrations/20260714120600_data_integrity_guards.sql
// that previously had no enforcement anywhere: a Karenz return date may not
// precede its own Karenz start, and a history entry may not predate the
// employee's entry date.
describe("data integrity guards", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let teamA: { id: string };
const employeeIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
teamA = await pickSeededTeam();
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> {
const employeeId = await hireTestEmployee(hrClient, teamA.id);
employeeIds.push(employeeId);
const karenzStartDate = isoDateOffset(-5);
const { error } = await hrClient.rpc("start_karenz", {
payload: { employee_id: employeeId, karenz_start_date: karenzStartDate, planned_return_date: isoDateOffset(100) },
});
if (error) throw new Error(`start_karenz setup failed: ${error.message}`);
return { employeeId, karenzStartDate };
}
it("adjust_karenz_return rejects a return date on or before karenz_start_date", async () => {
const { employeeId, karenzStartDate } = await freshEmployeeOnKarenz();
const { error } = await hrClient.rpc("adjust_karenz_return", {
payload: { employee_id: employeeId, new_return_date: karenzStartDate },
});
expect(error?.message).toMatch(/Rückkehrdatum muss nach dem Karenzbeginn/);
});
it("adjust_karenz_return accepts a return date after karenz_start_date", async () => {
const { employeeId } = await freshEmployeeOnKarenz();
const newReturnDate = isoDateOffset(50);
const { error } = await hrClient.rpc("adjust_karenz_return", {
payload: { employee_id: employeeId, new_return_date: newReturnDate },
});
expect(error).toBeNull();
const { data: employee } = await adminClient.from("employees").select("karenz_return_date").eq("id", employeeId).single();
expect(employee?.karenz_return_date).toBe(newReturnDate);
});
it("record_karenz_return rejects a return date on or before karenz_start_date", async () => {
const { employeeId, karenzStartDate } = await freshEmployeeOnKarenz();
const { error } = await hrClient.rpc("record_karenz_return", {
payload: { employee_id: employeeId, return_date: karenzStartDate, employment_mode: "unverändert" },
});
expect(error?.message).toMatch(/Rückkehrdatum muss nach dem Karenzbeginn/);
});
it("record_karenz_return with a valid date flips status back to Aktiv and clears karenz_start_date", async () => {
const { employeeId } = await freshEmployeeOnKarenz();
const { error } = await hrClient.rpc("record_karenz_return", {
payload: { employee_id: employeeId, return_date: isoDateOffset(0), employment_mode: "unverändert" },
});
expect(error).toBeNull();
const { data: employee } = await adminClient
.from("employees")
.select("status, karenz_start_date, karenz_return_date")
.eq("id", employeeId)
.single();
expect(employee?.status).toBe("Aktiv");
expect(employee?.karenz_start_date).toBeNull();
expect(employee?.karenz_return_date).toBeNull();
});
it("rejects an employee_history row dated before the employee's entry_date", async () => {
const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: isoDateOffset(-10) });
employeeIds.push(employeeId);
const { error } = await adminClient.from("employee_history").insert({
employee_id: employeeId,
event_date: isoDateOffset(-11),
event_type: "Vertragsänderung",
description: "Sollte vom Trigger abgelehnt werden",
});
expect(error?.message).toMatch(/darf nicht vor dem Eintrittsdatum/);
});
it("accepts an employee_history row dated exactly on the entry_date", async () => {
const entryDate = isoDateOffset(-10);
const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: entryDate });
employeeIds.push(employeeId);
const { error } = await adminClient.from("employee_history").insert({
employee_id: employeeId,
event_date: entryDate,
event_type: "Vertragsänderung",
description: "Am Eintrittsdatum selbst, sollte erlaubt sein",
});
expect(error).toBeNull();
});
});

View File

@@ -0,0 +1,163 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
adminClient,
createHrUser,
deleteTestEmployee,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededTeam,
signInAs,
teamLeadId,
type TestUser,
} from "./helpers";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/types";
// Deferred/effective-dated changes (supabase/migrations/20260714120200_
// effective_dating_rpcs.sql): a future "wirksam ab" date must queue a
// pending_org_changes row instead of writing to `employees` immediately;
// apply_due_pending_changes() applies it once due.
describe("effective-dated mutations", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let teamA: { id: string };
let teamB: { id: string };
const employeeIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
teamA = await pickSeededTeam();
teamB = await pickSeededTeam(teamA.id);
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function freshEmployee(teamId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, teamId);
employeeIds.push(id);
return id;
}
it("transfer_employee with today's date writes immediately", async () => {
const employeeId = await freshEmployee(teamA.id);
const newLead = await teamLeadId(teamB.id);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
});
expect(error).toBeNull();
const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
expect(employee?.team_id).toBe(teamB.id);
expect(employee?.manager_id).toBe(newLead);
});
it("transfer_employee with a future date defers the write and applies it once due", async () => {
const employeeId = await freshEmployee(teamA.id);
const newLead = await teamLeadId(teamB.id);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), new_team_id: teamB.id },
});
expect(error).toBeNull();
// Not written yet — this is the exact bug the migration fixes: a
// future-dated transfer must not overwrite the live record today.
const { data: unchanged } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
expect(unchanged?.team_id).toBe(teamA.id);
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("id, status, payload")
.eq("employee_id", employeeId)
.eq("change_type", "transfer")
.single();
expect(pending?.status).toBe("pending");
expect(pending?.payload.new_team_id).toBe(teamB.id);
// Fast-forward: simulate the effective date having arrived, then run
// the same function the daily cron route calls.
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
const { data: appliedCount, error: applyError } = await adminClient.rpc("apply_due_pending_changes");
expect(applyError).toBeNull();
expect(appliedCount).toBeGreaterThanOrEqual(1);
const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
expect(employee?.team_id).toBe(teamB.id);
expect(employee?.manager_id).toBe(newLead);
const { data: appliedRow } = await adminClient
.from("pending_org_changes")
.select("status, applied_at")
.eq("id", pending!.id)
.single();
expect(appliedRow?.status).toBe("applied");
expect(appliedRow?.applied_at).not.toBeNull();
});
it("promote_employee with a future date does not change job_title/paygrade until applied", async () => {
const employeeId = await freshEmployee(teamA.id);
const { error } = await hrClient.rpc("promote_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" },
});
expect(error).toBeNull();
const { data: unchanged } = await adminClient.from("employees").select("job_title, paygrade").eq("id", employeeId).single();
expect(unchanged?.job_title).not.toBe("Senior Testperson");
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("id")
.eq("employee_id", employeeId)
.eq("change_type", "promotion")
.single();
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
await adminClient.rpc("apply_due_pending_changes");
const { data: employee } = await adminClient.from("employees").select("job_title, paygrade").eq("id", employeeId).single();
expect(employee?.job_title).toBe("Senior Testperson");
expect(employee?.paygrade).toBe("D");
});
it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => {
const employeeId = await freshEmployee(teamA.id);
const startDate = isoDateOffset(20);
const returnDate = isoDateOffset(200);
const { error } = await hrClient.rpc("start_karenz", {
payload: { employee_id: employeeId, karenz_start_date: startDate, planned_return_date: returnDate },
});
expect(error).toBeNull();
const { data: employee } = await adminClient
.from("employees")
.select("status, karenz_start_date")
.eq("id", employeeId)
.single();
expect(employee?.status).toBe("Aktiv");
expect(employee?.karenz_start_date).toBe(startDate);
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("id")
.eq("employee_id", employeeId)
.eq("change_type", "karenz_start")
.single();
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
await adminClient.rpc("apply_due_pending_changes");
const { data: afterApply } = await adminClient
.from("employees")
.select("status, karenz_return_date")
.eq("id", employeeId)
.single();
expect(afterApply?.status).toBe("Karenz");
expect(afterApply?.karenz_return_date).toBe(returnDate);
});
});

View File

@@ -0,0 +1,146 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { randomUUID } from "node:crypto";
import type { Database, EmploymentStatus } from "@/lib/supabase/types";
const URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!URL || !ANON_KEY || !SERVICE_ROLE_KEY) {
throw new Error(
"Missing Supabase env vars for integration tests. Start local Supabase (`npx supabase start`) and run " +
"`npm run test:integration`, which loads .env.test.local automatically. See README.md."
);
}
export const adminClient: SupabaseClient<Database> = createClient(URL, SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
});
export function anonClient(): SupabaseClient<Database> {
return createClient(URL!, ANON_KEY!, { auth: { autoRefreshToken: false, persistSession: false } });
}
export type TestUser = { id: string; email: string; password: string };
// Creates a real auth.users row (via the admin API) with no profiles row —
// this is exactly the "signed up but never provisioned" state §2.3
// describes: authenticated, but not HR.
export async function createBareAuthUser(): Promise<TestUser> {
const email = `test-${randomUUID()}@example.test`;
const password = `Test-${randomUUID()}!`;
const { data, error } = await adminClient.auth.admin.createUser({ email, password, email_confirm: true });
if (error || !data.user) throw new Error(`createBareAuthUser failed: ${error?.message}`);
return { id: data.user.id, email, password };
}
export async function createHrUser(opts: { active: boolean }): Promise<TestUser> {
const user = await createBareAuthUser();
const { error } = await adminClient
.from("profiles")
.insert({ id: user.id, email: user.email, full_name: "Integrationstest HR", role: "hr", is_active: opts.active });
if (error) throw new Error(`createHrUser profile insert failed: ${error.message}`);
return user;
}
export async function deleteTestUser(user: TestUser): Promise<void> {
await adminClient.from("profiles").delete().eq("id", user.id);
await adminClient.auth.admin.deleteUser(user.id);
}
export async function signInAs(user: TestUser): Promise<SupabaseClient<Database>> {
const client = anonClient();
const { error } = await client.auth.signInWithPassword({ email: user.email, password: user.password });
if (error) throw new Error(`signInAs(${user.email}) failed: ${error.message}`);
return client;
}
// Pulled from the seeded dataset (supabase/seed.ts) — any active, non-lead
// employee works for read/mutation tests that don't care which one.
export async function pickSeededEmployee(
filter: Partial<{ status: EmploymentStatus; is_lead: boolean }> = {}
): Promise<{
id: string;
team_id: string | null;
division_id: string;
manager_id: string | null;
status: string;
}> {
let q = adminClient.from("employees").select("id, team_id, division_id, manager_id, status").limit(1);
if (filter.status) q = q.eq("status", filter.status);
if (filter.is_lead !== undefined) q = q.eq("is_lead", filter.is_lead);
const { data, error } = await q.maybeSingle();
if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`);
return data;
}
export async function pickSeededTeam(excludeTeamId?: string): Promise<{ id: string }> {
const q = adminClient.from("teams").select("id").limit(2);
const { data, error } = await q;
if (error || !data?.length) throw new Error(`pickSeededTeam failed: ${error?.message}`);
const match = data.find((t) => t.id !== excludeTeamId) ?? data[0];
return match;
}
export async function pickSeededLocation(): Promise<{ id: string }> {
const { data, error } = await adminClient.from("locations").select("id").limit(1).maybeSingle();
if (error || !data) throw new Error(`pickSeededLocation failed: ${error?.message ?? "no rows"}`);
return data;
}
// The seeded org guarantees exactly one active team lead per team (§2's
// "reports-to" rule) — resolve_manager_for() relies on the same query.
export async function teamLeadId(teamId: string): Promise<string | null> {
const { data, error } = await adminClient
.from("employees")
.select("id")
.eq("team_id", teamId)
.eq("is_lead", true)
.neq("status", "Ausgetreten")
.maybeSingle();
if (error) throw new Error(`teamLeadId(${teamId}) failed: ${error.message}`);
return data?.id ?? null;
}
// YYYY-MM-DD, offset from today — for building "wirksam ab" test payloads
// without hardcoding dates that eventually go stale.
export function isoDateOffset(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
// Hires a throwaway employee into `teamId` via the real hire_employee RPC
// (not a raw insert) so every mutation test starts from a state the app
// itself can produce. Caller must clean up with deleteTestEmployee.
export async function hireTestEmployee(
hrClient: SupabaseClient<Database>,
teamId: string,
overrides: Partial<Record<string, unknown>> = {}
): Promise<string> {
const location = await pickSeededLocation();
const payload = {
first_name: "Integrationstest",
last_name: `Person-${randomUUID().slice(0, 8)}`,
gender: "w",
birth_date: "1990-01-01",
location_id: location.id,
team_id: teamId,
job_title: "Integrationstest-Rolle",
entry_date: isoDateOffset(-30),
source: "Extern",
...overrides,
};
const { data, error } = await hrClient.rpc("hire_employee", { payload });
if (error || !data) throw new Error(`hireTestEmployee failed: ${error?.message ?? "no id returned"}`);
return data;
}
// employees has no cascade from audit_log (target_employee_id is a plain
// FK, immutable log by design) — clear those rows first so the employee
// delete itself doesn't fail with a foreign key violation. employee_history
// and pending_org_changes do cascade on employee_id.
export async function deleteTestEmployee(employeeId: string): Promise<void> {
await adminClient.from("audit_log").delete().eq("target_employee_id", employeeId);
await adminClient.from("employees").delete().eq("id", employeeId);
}

View File

@@ -0,0 +1,147 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
adminClient,
createHrUser,
deleteTestEmployee,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededTeam,
signInAs,
teamLeadId,
type TestUser,
} from "./helpers";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/types";
// Reorg scenarios: immediate apply/undo must respect employee_history's
// append-only contract (20260714120300_reorg_undo_append_only.sql), and a
// future-dated scenario must defer every move via pending_org_changes until
// its effective date, flipping reorg_scenarios.applied only once every move
// has landed (20260714120200_effective_dating_rpcs.sql).
describe("reorg scenarios", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let teamA: { id: string };
let teamB: { id: string };
const employeeIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
teamA = await pickSeededTeam();
teamB = await pickSeededTeam(teamA.id);
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function freshEmployee(teamId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, teamId);
employeeIds.push(id);
return id;
}
it("applies an immediate reorg now, and undo appends a compensating history row instead of deleting", async () => {
const employeeId = await freshEmployee(teamA.id);
const teamBLead = await teamLeadId(teamB.id);
const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", {
payload: {
name: `Integrationstest Reorg ${employeeId.slice(0, 8)}`,
effective_date: isoDateOffset(0),
moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }],
},
});
expect(applyError).toBeNull();
expect(scenarioId).toBeTruthy();
const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
expect(movedEmployee?.team_id).toBe(teamB.id);
expect(movedEmployee?.manager_id).toBe(teamBLead);
const { data: scenario } = await adminClient
.from("reorg_scenarios")
.select("applied, applied_at")
.eq("id", scenarioId as string)
.single();
expect(scenario?.applied).toBe(true);
expect(scenario?.applied_at).not.toBeNull();
const { count: historyBeforeUndo } = await adminClient
.from("employee_history")
.select("id", { count: "exact", head: true })
.eq("employee_id", employeeId)
.eq("event_type", "Reorganisation");
expect(historyBeforeUndo).toBe(1);
const { error: undoError } = await hrClient.rpc("undo_reorg", { payload: { scenario_id: scenarioId } });
expect(undoError).toBeNull();
const { data: revertedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
expect(revertedEmployee?.team_id).toBe(teamA.id);
const { data: undoneScenario } = await adminClient.from("reorg_scenarios").select("applied").eq("id", scenarioId as string).single();
expect(undoneScenario?.applied).toBe(false);
// The original "Reorganisation" row must still be there — undo appends
// a compensating entry, it never deletes (the bug the migration fixed).
const { count: historyAfterUndo } = await adminClient
.from("employee_history")
.select("id", { count: "exact", head: true })
.eq("employee_id", employeeId)
.eq("event_type", "Reorganisation");
expect(historyAfterUndo).toBe(2);
});
it("defers a future-dated reorg and only flips reorg_scenarios.applied once its pending change lands", async () => {
const employeeId = await freshEmployee(teamA.id);
const teamBLead = await teamLeadId(teamB.id);
const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", {
payload: {
name: `Integrationstest Reorg (zukünftig) ${employeeId.slice(0, 8)}`,
effective_date: isoDateOffset(30),
moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }],
},
});
expect(applyError).toBeNull();
const { data: unchangedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
expect(unchangedEmployee?.team_id).toBe(teamA.id);
const { data: scenarioBefore } = await adminClient
.from("reorg_scenarios")
.select("applied")
.eq("id", scenarioId as string)
.single();
expect(scenarioBefore?.applied).toBe(false);
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("id")
.eq("employee_id", employeeId)
.eq("reorg_scenario_id", scenarioId as string)
.eq("status", "pending")
.single();
expect(pending).not.toBeNull();
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
const { error: cronError } = await adminClient.rpc("apply_due_pending_changes");
expect(cronError).toBeNull();
const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
expect(movedEmployee?.team_id).toBe(teamB.id);
expect(movedEmployee?.manager_id).toBe(teamBLead);
const { data: scenarioAfter } = await adminClient
.from("reorg_scenarios")
.select("applied, applied_at")
.eq("id", scenarioId as string)
.single();
expect(scenarioAfter?.applied).toBe(true);
expect(scenarioAfter?.applied_at).not.toBeNull();
});
});

29
tests/unit/colors.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { actionBadgeStyle, avatarColorFor, STATUS_STYLES } from "@/lib/colors";
describe("avatarColorFor", () => {
it("is deterministic for the same seed", () => {
expect(avatarColorFor("Maria Gruber")).toBe(avatarColorFor("Maria Gruber"));
});
it("returns a value from the fixed palette (a hex color)", () => {
expect(avatarColorFor("Anna Huber")).toMatch(/^#[0-9a-f]{6}$/i);
});
});
describe("STATUS_STYLES", () => {
it("has an entry for every employment status", () => {
expect(Object.keys(STATUS_STYLES).sort()).toEqual(["Aktiv", "Ausgetreten", "Geplant", "Karenz"].sort());
});
});
describe("actionBadgeStyle", () => {
it("maps known audit actions to their category style", () => {
expect(actionBadgeStyle("Austritt")).toBe(STATUS_STYLES.Ausgetreten);
expect(actionBadgeStyle("Neueinstellung")).toBe(STATUS_STYLES.Aktiv);
});
it("falls back to the brand style for an unknown action", () => {
expect(actionBadgeStyle("Irgendwas Unbekanntes")).toContain("bg-brand-100");
});
});

78
tests/unit/format.test.ts Normal file
View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { daysBetween, fmtAge, fmtDate, initials, tenure } from "@/lib/format";
describe("fmtDate", () => {
it("formats an ISO date string in de-AT order", () => {
expect(fmtDate("2026-03-05")).toBe("05.03.2026");
});
it("returns an em dash for null/undefined/empty input", () => {
expect(fmtDate(null)).toBe("");
expect(fmtDate(undefined)).toBe("");
expect(fmtDate("")).toBe("");
});
it("returns an em dash for an invalid date string", () => {
expect(fmtDate("not-a-date")).toBe("");
});
});
describe("initials", () => {
it("uppercases the first letter of each name", () => {
expect(initials("maria", "gruber")).toBe("MG");
});
it("trims surrounding whitespace before taking the first letter", () => {
expect(initials(" Anna", "Huber ")).toBe("AH");
});
});
describe("fmtAge", () => {
it("computes age correctly when the birthday has already passed this year", () => {
const today = new Date();
const birthDate = new Date(today.getFullYear() - 30, 0, 1); // Jan 1, definitely passed
expect(fmtAge(birthDate)).toBe(30);
});
it("subtracts one year when the birthday has not yet occurred this year", () => {
const today = new Date();
const future = new Date(today.getFullYear() - 30, 11, 31); // Dec 31, likely not yet passed
if (today.getMonth() === 11 && today.getDate() === 31) return; // skip on the one day this'd be flaky
expect(fmtAge(future)).toBe(29);
});
});
describe("tenure", () => {
it("formats whole years and months since entry", () => {
const start = new Date(2020, 0, 15);
const end = new Date(2023, 2, 15); // exactly 3 years, 2 months later
expect(tenure(start, end)).toBe("3 Jahre, 2 Monate");
});
it("uses singular Jahr/Monat for exactly 1", () => {
const start = new Date(2020, 0, 1);
const end = new Date(2021, 1, 1); // 1 year, 1 month
expect(tenure(start, end)).toBe("1 Jahr, 1 Monat");
});
it("falls back to 'unter 1 Monat' for less than a month", () => {
const start = new Date(2024, 0, 1);
const end = new Date(2024, 0, 15);
expect(tenure(start, end)).toBe("unter 1 Monat");
});
it("defaults the end date to today when no end date is given", () => {
const start = new Date();
expect(tenure(start)).toBe("unter 1 Monat");
});
});
describe("daysBetween", () => {
it("computes whole days between two dates", () => {
expect(daysBetween("2026-01-01", "2026-01-11")).toBe(10);
});
it("returns a negative number when the second date precedes the first", () => {
expect(daysBetween("2026-01-11", "2026-01-01")).toBe(-10);
});
});

52
tests/unit/org.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { breadcrumbFor, breadcrumbLabel, type OrgMaps } from "@/lib/org";
import type { Database } from "@/lib/supabase/types";
type Division = Database["public"]["Tables"]["divisions"]["Row"];
type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
const division: Division = { id: "div-1", org_number: "20100000", name: "Produktion" };
const department: Department = { id: "dept-1", org_number: "21100000", name: "Fertigung", division_id: "div-1" };
const team: Team = { id: "team-1", org_number: "22010000", name: "Montage", department_id: "dept-1" };
const location: Location = { id: "loc-1", name: "Wien-Hernals", country: "Österreich" };
const orgMaps: OrgMaps = {
divisions: new Map([[division.id, division]]),
departments: new Map([[department.id, department]]),
teams: new Map([[team.id, team]]),
locations: new Map([[location.id, location]]),
divisionList: [division],
locationList: [location],
};
describe("breadcrumbFor", () => {
it("resolves division, department (via team), and team from ids", () => {
const result = breadcrumbFor(orgMaps, division.id, team.id);
expect(result.division?.name).toBe("Produktion");
expect(result.department?.name).toBe("Fertigung");
expect(result.team?.name).toBe("Montage");
});
it("has no team/department for a division head with no team (team_id null)", () => {
const result = breadcrumbFor(orgMaps, division.id, null);
expect(result.division?.name).toBe("Produktion");
expect(result.team).toBeUndefined();
expect(result.department).toBeUndefined();
});
});
describe("breadcrumbLabel", () => {
it("joins division department team with the SAP-OM breadcrumb separator", () => {
expect(breadcrumbLabel(orgMaps, division.id, team.id)).toBe("Produktion Fertigung Montage");
});
it("omits missing segments instead of producing empty separators", () => {
expect(breadcrumbLabel(orgMaps, division.id, null)).toBe("Produktion");
});
it("falls back to a dash when nothing resolves", () => {
expect(breadcrumbLabel(orgMaps, null, null)).toBe("");
});
});

164
tests/unit/reports.test.ts Normal file
View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import {
aggregateReport,
groupKeyFor,
measureValue,
MEASURE_LABELS,
type Measure,
type OrgLookups,
type ReportEmployee,
} from "@/lib/reports";
function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
return {
id: "1",
first_name: "Maria",
last_name: "Gruber",
job_title: "Maschinenbediener:in",
division_id: "div-1",
team_id: "team-1",
location_id: "loc-1",
status: "Aktiv",
employment_type: "Vollzeit",
contract_type: "unbefristet",
entry_date: "2020-01-01",
exit_date: null,
weekly_hours: 38.5,
source: "Extern",
paygrade: "B",
birth_date: "1990-01-01",
gender: "w",
...overrides,
};
}
const lookups: OrgLookups = {
divisionName: new Map([["div-1", "Produktion"]]),
departmentNameByTeam: new Map([["team-1", "Fertigung"]]),
teamName: new Map([["team-1", "Montage"]]),
locationName: new Map([["loc-1", "Wien-Hernals"]]),
};
describe("no salary measure in scope (consolidation §4/§8)", () => {
it("does not expose an avg_salary measure", () => {
expect(Object.keys(MEASURE_LABELS)).not.toContain("avg_salary");
});
});
describe("groupKeyFor", () => {
it("resolves division/department/team/location names via lookups", () => {
const e = emp();
expect(groupKeyFor(e, "division", lookups)).toBe("Produktion");
expect(groupKeyFor(e, "department", lookups)).toBe("Fertigung");
expect(groupKeyFor(e, "team", lookups)).toBe("Montage");
expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals");
});
it("falls back to a dash for department/team when the employee has no team", () => {
const e = emp({ team_id: null });
expect(groupKeyFor(e, "department", lookups)).toBe("");
expect(groupKeyFor(e, "team", lookups)).toBe("");
});
it("falls back to 'Unbekannt' for an unresolvable division/location id", () => {
const e = emp({ division_id: "ghost", location_id: "ghost" });
expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt");
expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt");
});
it("derives entry_year from entry_date", () => {
expect(groupKeyFor(emp({ entry_date: "2019-06-01" }), "entry_year", lookups)).toBe("2019");
});
});
describe("measureValue", () => {
it("returns 0 for an empty row set regardless of measure", () => {
expect(measureValue([], "headcount")).toBe(0);
});
it("counts headcount/hires/exits as row length", () => {
const rows = [emp(), emp({ id: "2" }), emp({ id: "3" })];
(["headcount", "hires", "exits"] as Measure[]).forEach((m) => expect(measureValue(rows, m)).toBe(3));
});
it("sums FTE as weekly_hours / 38.5", () => {
const rows = [emp({ weekly_hours: 38.5 }), emp({ weekly_hours: 19.25 })];
expect(measureValue(rows, "fte")).toBeCloseTo(1.5, 5);
});
it("computes parttime_rate as a percentage of Teilzeit employees", () => {
const rows = [
emp({ employment_type: "Vollzeit" }),
emp({ employment_type: "Teilzeit" }),
emp({ employment_type: "Teilzeit" }),
emp({ employment_type: "Teilzeit" }),
];
expect(measureValue(rows, "parttime_rate")).toBe(75);
});
it("computes female_share as a percentage", () => {
const rows = [emp({ gender: "w" }), emp({ gender: "w" }), emp({ gender: "m" })];
expect(measureValue(rows, "female_share")).toBeCloseTo(66.67, 1);
});
it("computes avg_age from birth_date", () => {
const today = new Date();
const thirtyYearsAgo = `${today.getFullYear() - 30}-01-01`;
const fortyYearsAgo = `${today.getFullYear() - 40}-01-01`;
const rows = [emp({ birth_date: thirtyYearsAgo }), emp({ birth_date: fortyYearsAgo })];
expect(measureValue(rows, "avg_age")).toBeCloseTo(35, 0);
});
it("computes avg_tenure in years using exit_date when present (deterministic)", () => {
const rows = [
emp({ entry_date: "2020-01-01", exit_date: "2022-01-01" }), // ~2 years
emp({ entry_date: "2020-01-01", exit_date: "2024-01-01" }), // ~4 years
];
expect(measureValue(rows, "avg_tenure")).toBeCloseTo(3, 0);
});
});
describe("aggregateReport", () => {
it("groups rows, sorts groups descending by value, and never includes a salary field", () => {
const employees = [
emp({ id: "1", division_id: "div-1" }),
emp({ id: "2", division_id: "div-1" }),
emp({ id: "3", division_id: "div-2" }),
];
const multiDivisionLookups: OrgLookups = {
...lookups,
divisionName: new Map([
["div-1", "Produktion"],
["div-2", "IT"],
]),
};
const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups);
expect(rows[0]).toMatchObject({ key: "Produktion", value: 2, count: 2 });
expect(rows[1]).toMatchObject({ key: "IT", value: 1, count: 1 });
for (const row of rows) {
for (const person of row.people) {
expect(person).not.toHaveProperty("monthly_salary_gross");
}
}
});
it("caps drilldown people list source data at the full group (UI caps display, not aggregation)", () => {
const employees = Array.from({ length: 15 }, (_, i) => emp({ id: String(i) }));
const rows = aggregateReport(employees, "headcount", "division", null, lookups);
expect(rows[0].people).toHaveLength(15);
});
it("adds a split breakdown per group when a split dimension is given", () => {
const employees = [
emp({ id: "1", employment_type: "Vollzeit" }),
emp({ id: "2", employment_type: "Teilzeit" }),
];
const rows = aggregateReport(employees, "headcount", "division", "employment_type", lookups);
expect(rows[0].split).toEqual(
expect.arrayContaining([
{ key: "Vollzeit", value: 1, count: 1 },
{ key: "Teilzeit", value: 1, count: 1 },
])
);
});
});

8
vercel.json Normal file
View File

@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/cron/apply-pending-changes",
"schedule": "0 3 * * *"
}
]
}

14
vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
test: {
environment: "node",
include: ["tests/unit/**/*.test.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
});

View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
// Runs against a local Supabase instance (`npx supabase start`, then
// `node --env-file=.env.test.local supabase/seed.ts` once) — see
// docs/security.md and README.md "Tests" section for the full setup. These
// tests exercise real RLS policies and RPC functions; they intentionally do
// not run against the hosted project.
export default defineConfig({
test: {
environment: "node",
include: ["tests/integration/**/*.test.ts"],
testTimeout: 20000,
hookTimeout: 20000,
fileParallelism: false,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
});