SVNR validation, CI, and a dependency/security pass

Positions
- Removed the "Besetzen" action, the StaffInternallyModal behind it and the
  now-unreachable staffPositionInternally server action: a position is filled
  through the hire process, not from the positions list. Note that
  transfer_employee has no position_id at all and never touched `positions`,
  so with staff_position_internally out of the UI, hire_employee is the only
  thing that closes a position — a transfer into an open one leaves it open.
  The RPC itself is still in the database and still covered by its tests.

SVNR
- Austrian social security numbers are now validated: ten digits, weighted
  check digit mod 11, and the TTMMJJ tail cross-checked against birth_date,
  which is what catches a transposed date that a valid check digit would let
  through. A serial whose weighted sum lands on 11 is rejected rather than
  wrapped — those are never issued.
- Applies to Austrian locations only; the German/Czech/Slovenian equivalents
  have their own formats and stay free-form.
- Enforced by a trigger, not inside hire_employee/change_employee_data, for
  the same reason as the assignment history: both have been redefined by
  half a dozen migrations. Only a *newly written* value is checked, so a
  legacy number never blocks an unrelated transfer or address change.
- The seed drew a random four-digit prefix, so its check digit was right
  only by chance and every seeded Austrian row would now be rejected;
  it computes the check digit properly now.

Tech stack
- next 16.2.11 closes nine advisories against 16.2.10, including a
  middleware/proxy bypass in App Router apps on Turbopack — proxy.ts is this
  app's entry gate. RLS remains the real boundary, so the blast radius was a
  blank page rather than data, but it is a patch-level fix. Also react
  19.2.8, tailwind 4.3.3, lucide-react 1.26, supabase-js/ssr, postcss.
- CI runs lint, typecheck, schema/type drift, tests and build; a second job
  replays every migration onto an empty database and runs the integration
  suite against it, so a migration that cannot be replayed from scratch
  fails here instead of during a restore.
- scripts/check-schema-types.mjs diffs the hand-written lib/supabase/types.ts
  against the migrations. Reading the SQL rather than a live database keeps
  Postgres out of the fast CI job. Verified in both directions.
- vitest now runs two projects: node for logic, jsdom for components. The
  first component test covers the org chart expand control, which broke
  earlier this session when elementsSelectable={false} made React Flow
  compute pointer-events:none for the whole node; re-introducing that prop
  fails three of these tests.
- Content-Security-Policy is emitted report-only. Enforcing a policy derived
  from inspection rather than from violation reports risks blanking the app;
  'unsafe-inline' on script-src is required until a nonce is threaded through
  proxy.ts, which is a separate change.
- Fixed supabase/seed.ts, which this session's SVNR change had broken: the
  extensionless "../lib/svnr" import does not resolve under Node's ESM
  loader, so the seed failed at startup.
- engines pinned to node >=22 <25, tsconfig target ES2022, and the dead
  test:e2e script removed (no Playwright is installed).
This commit is contained in:
2026-07-25 11:13:10 +02:00
parent 79f0e19bf8
commit 8d978981b0
24 changed files with 1956 additions and 335 deletions

90
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,90 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Lint, Typen, Tests, Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
# Catches the drift that `tsc` cannot: lib/supabase/types.ts is
# hand-written, so a migration adding a column leaves it silently stale.
- name: Schema/Typen-Abgleich
run: npm run types:check
- name: Unit- und Komponententests
run: npm test
- name: Build
run: npm run build
env:
# Read at module scope by the Supabase browser client, so the build
# needs them present — never the real project's values.
NEXT_PUBLIC_SUPABASE_URL: http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY: build-time-placeholder
integration:
name: Integrationstests (echtes Postgres)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- uses: supabase/setup-cli@v1
with:
version: latest
# Applies every migration to a fresh database — which also means a
# migration that cannot be replayed from scratch fails here rather than
# on a restore or a new environment.
- name: Supabase starten
run: supabase start
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects
# them under its own names.
- name: Testumgebung schreiben
run: |
supabase status -o env \
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY)=' \
| tr -d '"' > .env.test.local
- name: Seed
run: node --env-file=.env.test.local supabase/seed.ts
- name: Integrationstests
run: npm run test:integration
- name: Supabase-Logs bei Fehlschlag
if: failure()
run: supabase status && docker ps -a

4
.gitignore vendored
View File

@@ -46,6 +46,10 @@ next-env.d.ts
/supabase/.temp
/supabase/snippets
# scratch output of `npm run types:generate`, for comparing against the
# hand-written lib/supabase/types.ts — never itself imported
/lib/supabase/types.generated.ts
# per-machine (permission allowlists) and must stay out of the repo
# playwright

View File

@@ -9,7 +9,7 @@ type ActionResult = { success: boolean; error?: string };
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
async function callRpc(
fn: "create_position" | "delete_position" | "staff_position_internally",
fn: "create_position" | "delete_position",
payload: Record<string, unknown>,
revalidate: string[]
): Promise<ActionResult> {
@@ -34,13 +34,6 @@ export async function deletePosition(positionId: string): Promise<ActionResult>
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
}
export async function staffPositionInternally(payload: {
position_id: string;
employee_id: string;
}): Promise<ActionResult> {
return callRpc("staff_position_internally", payload, [...POSITION_PATHS, "/employees", `/employees/${payload.employee_id}`]);
}
export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string };
// For "Position ausschreiben": superior lookup, filtered to team-leads when

View File

@@ -151,7 +151,13 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
/>
<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} dependents={dependents} />
<DatenAendernPanel
open={panel === "daten"}
onClose={() => setPanel(null)}
employee={employee}
dependents={dependents}
locationCountry={location?.country}
/>
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
</div>

View File

@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import { formatSvnr, requiresAustrianSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
type SvNummerFieldProps = {
value: string;
onChange: (value: string) => void;
/** Country of the employee's work location — validation applies to Austria only. */
locationCountry: string | null | undefined;
/** ISO yyyy-mm-dd; enables the cross-check against the TTMMJJ tail. */
birthDate?: string | null;
labelClassName?: string;
};
/**
* Shared by the hire wizard and the "Daten ändern" panel so both apply the
* same rule. Errors are shown only once the field has been left, so the
* message does not flash while the ten digits are still being typed.
*/
export function SvNummerField({ value, onChange, locationCountry, birthDate, labelClassName }: SvNummerFieldProps) {
const [touched, setTouched] = useState(false);
const applies = requiresAustrianSvnr(locationCountry);
const error = applies && value.trim() !== "" ? validateSvnr(value, birthDate) : null;
const showError = touched && error !== null;
return (
<div>
<label htmlFor="sv-nummer" className={labelClassName ?? "mb-1 block text-sm font-semibold text-ink"}>
SV-Nummer
</label>
<input
id="sv-nummer"
value={value}
inputMode="numeric"
placeholder={applies ? "1237 010180" : undefined}
onChange={(e) => onChange(e.target.value)}
onBlur={() => {
setTouched(true);
// Normalise to the conventional "NNNN TTMMJJ" spacing once the
// value is complete; anything else is left exactly as typed.
const formatted = formatSvnr(value);
if (formatted !== value) onChange(formatted);
}}
aria-invalid={showError || undefined}
aria-describedby={showError ? "sv-nummer-error" : undefined}
className={`w-full rounded border px-3 py-2 text-sm ${showError ? "border-danger-solid" : "border-border"}`}
/>
{showError && (
<p id="sv-nummer-error" className="mt-1 text-xs font-semibold text-danger-text">
{svnrErrorMessage(error)}
</p>
)}
{applies && !showError && (
<p className="mt-1 text-xs text-ink-muted">10 Ziffern: laufende Nummer, Prüfziffer, Geburtsdatum (TTMMJJ).</p>
)}
</div>
);
}

View File

@@ -5,12 +5,14 @@ import { useState } from "react";
import { changeEmployeeData } from "@/actions/employees";
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields";
import { SvNummerField } from "@/components/employees/SvNummerField";
import { TitleFields, type TitleValue } from "@/components/employees/TitleFields";
import { CountryPicker } from "@/components/ui/CountryPicker";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { UN_COUNTRIES } from "@/lib/countries";
import { fmtFullName, todayIso } from "@/lib/format";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
@@ -21,15 +23,19 @@ export function DatenAendernPanel({
onClose,
employee,
dependents,
locationCountry,
}: {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
dependents: Dependent[];
locationCountry: string | null | undefined;
}) {
const { showToast } = useToast();
const router = useRouter();
const [pending, setPending] = useState(false);
// The DB trigger would reject a bad number anyway; catching it here keeps
// the whole effective-dated change from being thrown away on submit.
const [effectiveDate, setEffectiveDate] = useState(todayIso);
const [firstName, setFirstName] = useState(employee.first_name);
@@ -41,6 +47,8 @@ export function DatenAendernPanel({
const [gender, setGender] = useState<GenderType>(employee.gender);
const [birthDate, setBirthDate] = useState(employee.birth_date);
const [svNummer, setSvNummer] = useState(employee.sv_nummer ?? "");
const svNummerOk =
!svNummer.trim() || !requiresAustrianSvnr(locationCountry) || isValidSvnr(svNummer, birthDate || null);
const [nationality, setNationality] = useState(employee.nationality);
const [address, setAddress] = useState(employee.address ?? "");
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
@@ -148,7 +156,8 @@ export function DatenAendernPanel({
</button>
<button
onClick={handleSubmit}
disabled={pending}
disabled={pending || !svNummerOk}
title={svNummerOk ? undefined : "Die SV-Nummer ist ungültig."}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Speichern
@@ -199,10 +208,13 @@ export function DatenAendernPanel({
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">SV-Nummer</label>
<input value={svNummer} onChange={(e) => setSvNummer(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<SvNummerField
value={svNummer}
onChange={setSvNummer}
locationCountry={locationCountry}
birthDate={birthDate || null}
labelClassName="mb-1 block text-xs font-semibold text-ink-muted"
/>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Staatsbürgerschaft</label>
<CountryPicker value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />

View File

@@ -7,6 +7,7 @@ import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { StepPerson } from "./StepPerson";
import { StepPosition } from "./StepPosition";
import { StepSummary } from "./StepSummary";
@@ -48,8 +49,15 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
setDraft((prev) => ({ ...prev, ...patch }));
}
// Blocks step 1 rather than letting the hire fail at the RPC: the SVNR
// trigger rejects a bad number, and by then the user is three steps on.
const svNummerOk =
!draft.svNummer.trim() ||
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
isValidSvnr(draft.svNummer, draft.birthDate || null);
const stepValid = [
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId),
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
Boolean(draft.positionId && draft.besetzung),
Boolean(draft.entryDate && draft.workDays.length > 0),
true,

View File

@@ -1,3 +1,4 @@
import { SvNummerField } from "@/components/employees/SvNummerField";
import { TitleFields } from "@/components/employees/TitleFields";
import type { HireDraftData } from "./types";
@@ -38,10 +39,12 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
<input type="date" value={draft.birthDate} onChange={(e) => update({ birthDate: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">SV-Nummer</label>
<input value={draft.svNummer} onChange={(e) => update({ svNummer: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<SvNummerField
value={draft.svNummer}
onChange={(svNummer) => update({ svNummer })}
locationCountry={locations.find((l) => l.id === draft.locationId)?.country}
birthDate={draft.birthDate || null}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">E-Mail (privat)</label>

View File

@@ -8,7 +8,6 @@ import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import { CreatePositionModal } from "./CreatePositionModal";
import { StaffInternallyModal } from "./StaffInternallyModal";
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
@@ -21,7 +20,6 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
const { showToast } = useToast();
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false);
const [staffTarget, setStaffTarget] = useState<{ id: string; title: string; is_lead: boolean } | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const today = todayIso();
@@ -76,15 +74,6 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
</div>
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
<button
type="button"
onClick={() => setStaffTarget({ id: p.id, title: p.title, is_lead: p.is_lead })}
disabled={notYetValid}
title={notYetValid ? `Position ist erst ab ${fmtDate(p.valid_from)} gültig.` : undefined}
className="mt-3 w-full rounded bg-brand-500 px-3 py-2 text-xs font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
>
Besetzen
</button>
</div>
);
})}
@@ -93,15 +82,6 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
</div>
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
{staffTarget && (
<StaffInternallyModal
open={Boolean(staffTarget)}
onClose={() => setStaffTarget(null)}
positionId={staffTarget.id}
positionTitle={staffTarget.title}
isLead={staffTarget.is_lead}
/>
)}
</div>
);
}

View File

@@ -1,102 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { searchActiveEmployees, type EmployeeSearchResult } from "@/actions/employees";
import { staffPositionInternally } from "@/actions/positions";
import { Lookup } from "@/components/ui/Lookup";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
type StaffInternallyModalProps = {
open: boolean;
onClose: () => void;
positionId: string;
positionTitle: string;
isLead: boolean;
};
export function StaffInternallyModal({ open, onClose, positionId, positionTitle, isLead }: StaffInternallyModalProps) {
const { showToast } = useToast();
const router = useRouter();
const [employee, setEmployee] = useState<EmployeeSearchResult | null>(null);
const [pending, setPending] = useState(false);
async function handleSubmit() {
if (!employee) return;
setPending(true);
const result = await staffPositionInternally({ position_id: positionId, employee_id: employee.id });
setPending(false);
if (result.success) {
showToast(`${employee.first_name} ${employee.last_name} besetzt die Position.`);
router.refresh();
onClose();
setEmployee(null);
} else {
showToast(result.error ?? "Fehler beim Besetzen.", "error");
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Position besetzen"
footer={
<>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
Abbrechen
</button>
<button
onClick={handleSubmit}
disabled={pending || !employee}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Besetzen
</button>
</>
}
>
<div className="flex flex-col gap-4">
<p className="text-sm text-ink-body">
Position: <span className="font-semibold">{positionTitle}</span>
</p>
{isLead && (
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
Dies ist eine Führungsposition: Das gesamte Team wird der ausgewählten Person unterstellt.
</div>
)}
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Mitarbeiter:in*</label>
{!employee ? (
<Lookup<EmployeeSearchResult>
placeholder="Name oder Titel…"
onSearch={searchActiveEmployees}
onSelect={setEmployee}
renderResult={(e) => (
<div>
<div className="font-semibold text-ink">
{e.first_name} {e.last_name}
</div>
<div className="text-xs text-ink-muted">{e.job_title}</div>
</div>
)}
/>
) : (
<div className="flex items-center justify-between rounded border border-border bg-surface p-3">
<div>
<div className="text-sm font-semibold text-ink">
{employee.first_name} {employee.last_name}
</div>
<div className="text-xs text-ink-muted">{employee.job_title}</div>
</div>
<button type="button" onClick={() => setEmployee(null)} className="text-xs text-ink-muted hover:text-ink">
Ändern
</button>
</div>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -429,6 +429,7 @@ export type Database = {
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
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 };

88
lib/svnr.ts Normal file
View File

@@ -0,0 +1,88 @@
// Österreichische Sozialversicherungsnummer (SVNR).
//
// Ten digits: three-digit serial, one check digit, then the date of birth as
// TTMMJJ — e.g. "1237 010180". The check digit is the weighted sum of the
// other nine digits modulo 11; a serial whose sum yields 11 leaves no usable
// digit and is simply never issued, which is why 10 has to be rejected
// rather than wrapped.
//
// Only employees at an Austrian location have one. Everyone else keeps the
// field free-form, since the German/Czech/Slovenian equivalents have their
// own formats and are not what this validates.
const WEIGHTS = [3, 7, 9, 0, 5, 8, 4, 2, 1, 6] as const;
const CHECK_INDEX = 3;
export type SvnrError = "length" | "serial" | "checksum" | "date" | "birthDateMismatch";
const MESSAGES: Record<SvnrError, string> = {
length: "Die SV-Nummer muss aus 10 Ziffern bestehen (4 Ziffern, dann TTMMJJ).",
serial: "Die laufende Nummer darf nicht 000 sein.",
checksum: "Die Prüfziffer stimmt nicht. Bitte die Eingabe kontrollieren.",
date: "Die letzten 6 Stellen ergeben kein gültiges Geburtsdatum (TTMMJJ).",
birthDateMismatch: "Die SV-Nummer enthält ein anderes Geburtsdatum als im Stammdatensatz.",
};
export function svnrErrorMessage(error: SvnrError): string {
return MESSAGES[error];
}
/** Strips spaces and separators; keeps everything else so bad input still fails loudly. */
export function normalizeSvnr(input: string): string {
return input.replace(/[\s./-]/g, "");
}
/** "1237010180" → "1237 010180"; leaves anything non-canonical untouched. */
export function formatSvnr(input: string): string {
const n = normalizeSvnr(input);
return /^\d{10}$/.test(n) ? `${n.slice(0, 4)} ${n.slice(4)}` : input;
}
export function svnrCheckDigit(digits: string): number | null {
const n = normalizeSvnr(digits);
if (!/^\d{10}$/.test(n)) return null;
let sum = 0;
for (let i = 0; i < 10; i++) {
if (i === CHECK_INDEX) continue;
sum += Number(n[i]) * WEIGHTS[i];
}
const check = sum % 11;
return check === 10 ? null : check;
}
/**
* `birthDate` (ISO yyyy-mm-dd) is optional; when given, the TTMMJJ tail is
* cross-checked against it — the single most common data-entry slip is a
* transposed birth date, which the checksum alone will not catch.
*/
export function validateSvnr(input: string, birthDate?: string | null): SvnrError | null {
const n = normalizeSvnr(input);
if (!/^\d{10}$/.test(n)) return "length";
if (n.slice(0, 3) === "000") return "serial";
const day = Number(n.slice(4, 6));
const month = Number(n.slice(6, 8));
if (month < 1 || month > 12 || day < 1 || day > 31) return "date";
// Without a century the tail cannot be resolved to a real date, so the
// day-of-month bound is the generous one; a supplied birthDate settles it.
if (day > [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]) return "date";
const expected = svnrCheckDigit(n);
if (expected === null || expected !== Number(n[CHECK_INDEX])) return "checksum";
if (birthDate) {
const [y, m, d] = birthDate.split("-");
if (d !== n.slice(4, 6) || m !== n.slice(6, 8) || y?.slice(-2) !== n.slice(8, 10)) return "birthDateMismatch";
}
return null;
}
export function isValidSvnr(input: string, birthDate?: string | null): boolean {
return validateSvnr(input, birthDate) === null;
}
/** Countries whose employees this validation applies to. */
export function requiresAustrianSvnr(locationCountry: string | null | undefined): boolean {
return locationCountry === "Österreich";
}

View File

@@ -1,14 +1,47 @@
import type { NextConfig } from "next";
// Report-only rather than enforcing, deliberately: the policy is derived from
// what this app is known to load — its own bundle, the self-hosted Nunito
// files from next/font, and the Supabase project from
// NEXT_PUBLIC_SUPABASE_URL — but an unenforced policy that logs violations is
// worth more than a guessed one that blanks the app for every HR user.
// Promote it to `Content-Security-Policy` once the reports come back clean.
//
// 'unsafe-inline' on script-src is not laziness: Next.js inlines its
// bootstrap and hydration payload as inline <script> tags, so a nonce-based
// policy means threading a per-request nonce through proxy.ts — a separate
// change, and the reason this starts in report-only.
function contentSecurityPolicy(): string {
let supabaseOrigin = "";
try {
supabaseOrigin = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL ?? "").origin;
} catch {
supabaseOrigin = "";
}
// Supabase Auth refreshes tokens over https and Realtime opens a websocket
// against the same host.
const connect = ["'self'", supabaseOrigin, supabaseOrigin.replace(/^http/, "ws")].filter(Boolean).join(" ");
return [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
`connect-src ${connect}`,
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join("; ");
}
const nextConfig: NextConfig = {
// Emits a self-contained .next/standalone server (only the deps actually
// used at runtime, no full node_modules) - what the Dockerfile copies in.
output: "standalone",
// Baseline security headers (clickjacking, MIME-sniffing, referrer leakage,
// browser feature access). No Content-Security-Policy yet: this app has no
// inventory of its script/style/connect sources, and shipping a guessed
// CSP risks silently breaking Next.js hydration or the Supabase client —
// TODO revisit once the actual source list is audited.
// browser feature access) plus the report-only CSP described above.
async headers() {
return [
{
@@ -19,6 +52,7 @@ const nextConfig: NextConfig = {
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
{ key: "Content-Security-Policy-Report-Only", value: contentSecurityPolicy() },
],
},
];

1119
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,32 +11,40 @@
"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",
"types:generate": "supabase gen types typescript --local > lib/supabase/types.generated.ts",
"types:check": "node scripts/check-schema-types.mjs",
"check": "npm run lint && npm run typecheck && npm run test && npm run build"
},
"engines": {
"node": ">=22 <25"
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@supabase/ssr": "^0.12.1",
"@supabase/supabase-js": "^2.110.5",
"@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.8",
"@xyflow/react": "^12.11.2",
"exceljs": "^4.4.0",
"lucide-react": "^1.24.0",
"next": "16.2.10",
"react": "19.2.7",
"react-dom": "19.2.7",
"lucide-react": "^1.26.0",
"next": "^16.2.11",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"server-only": "^0.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@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",
"eslint-config-next": "^16.2.11",
"jsdom": "^29.1.1",
"postcss": "^8.5.19",
"supabase": "^2.109.1",
"tailwindcss": "^4.3.2",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vitest": "^4.1.10"
},

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env node
// Guards lib/supabase/types.ts against drifting away from the migrations.
//
// That file is maintained by hand (its own header explains why: no DB
// connection string is available to run `supabase gen types`), so a new
// column reaches the database without the TypeScript side noticing — and
// `tsc` stays perfectly happy while the app reads a field that is typed but
// absent, or writes one that exists but is not typed.
//
// Reads the migrations rather than a live database on purpose: CI then needs
// no Postgres, and the migrations are the actual source of truth for what
// gets deployed.
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const migrationsDir = join(root, "supabase", "migrations");
// Columns are compared, not their types: the hand-written file deliberately
// uses richer unions than the SQL (`EmploymentStatus` for a text column with
// a check constraint), and flagging those would be noise.
const NON_COLUMN_START =
/^(constraint|check|primary|unique|foreign|exclude|like|inherits|partition|)$/i;
function stripComments(sql) {
return sql.replace(/--[^\n]*/g, "");
}
/** Splits on top-level commas only, so `numeric(10,2)` stays in one piece. */
function splitTopLevel(body) {
const parts = [];
let depth = 0;
let current = "";
for (const ch of body) {
if (ch === "(") depth++;
if (ch === ")") depth--;
if (ch === "," && depth === 0) {
parts.push(current);
current = "";
continue;
}
current += ch;
}
if (current.trim()) parts.push(current);
return parts;
}
function parseMigrations() {
const tables = new Map();
const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
for (const file of files) {
const sql = stripComments(readFileSync(join(migrationsDir, file), "utf8"));
for (const match of sql.matchAll(/create table (?:if not exists )?(\w+)\s*\(/gi)) {
const name = match[1];
// Walk from the opening paren to its match so nested parens in column
// definitions do not end the table early.
let depth = 0;
let end = match.index + match[0].length - 1;
for (let i = end; i < sql.length; i++) {
if (sql[i] === "(") depth++;
else if (sql[i] === ")") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
const body = sql.slice(match.index + match[0].length, end);
const columns = new Set();
for (const part of splitTopLevel(body)) {
const first = part.trim().split(/\s+/)[0] ?? "";
if (!first || NON_COLUMN_START.test(first)) continue;
columns.add(first.toLowerCase());
}
tables.set(name, columns);
}
for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+add column (?:if not exists )?(\w+)/gi)) {
tables.get(m[1])?.add(m[2].toLowerCase());
}
for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+drop column (?:if exists )?(\w+)/gi)) {
tables.get(m[1])?.delete(m[2].toLowerCase());
}
for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+rename column (\w+) to (\w+)/gi)) {
const t = tables.get(m[1]);
if (t?.delete(m[2].toLowerCase())) t.add(m[3].toLowerCase());
}
for (const m of sql.matchAll(/drop table (?:if exists )?(\w+)/gi)) {
tables.delete(m[1]);
}
}
return tables;
}
function parseTypes() {
const source = readFileSync(join(root, "lib", "supabase", "types.ts"), "utf8");
const start = source.indexOf("Tables: {");
if (start === -1) throw new Error("Tables block not found in lib/supabase/types.ts");
const tables = new Map();
// Each entry looks like `name: NoRelationships & { Row: { … } … }`; the Row
// block is the one that has to match the database.
const entry = /^ {6}(\w+): /gm;
for (const m of source.slice(start).matchAll(entry)) {
const rest = source.slice(start + m.index);
const rowAt = rest.indexOf("Row: {");
if (rowAt === -1) continue;
let depth = 0;
let end = rowAt + "Row: ".length;
for (let i = end; i < rest.length; i++) {
if (rest[i] === "{") depth++;
else if (rest[i] === "}") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
const body = rest.slice(rowAt + "Row: {".length, end);
const columns = new Set();
for (const f of body.matchAll(/(\w+)\s*\??\s*:/g)) columns.add(f[1].toLowerCase());
tables.set(m[1], columns);
}
return tables;
}
const sqlTables = parseMigrations();
const tsTables = parseTypes();
const problems = [];
for (const [name, columns] of tsTables) {
const sqlColumns = sqlTables.get(name);
if (!sqlColumns) {
problems.push(`Tabelle "${name}" ist in types.ts typisiert, existiert aber in keiner Migration.`);
continue;
}
for (const c of columns) {
if (!sqlColumns.has(c)) problems.push(`${name}.${c}: in types.ts typisiert, in den Migrationen nicht vorhanden.`);
}
for (const c of sqlColumns) {
if (!columns.has(c)) problems.push(`${name}.${c}: in den Migrationen vorhanden, in types.ts nicht typisiert.`);
}
}
if (problems.length > 0) {
console.error("Schema-Drift zwischen supabase/migrations und lib/supabase/types.ts:\n");
for (const p of problems.sort()) console.error(" - " + p);
console.error(`\n${problems.length} Abweichung(en). types.ts entsprechend nachziehen.`);
process.exit(1);
}
console.log(`Kein Schema-Drift: ${tsTables.size} typisierte Tabellen stimmen mit den Migrationen überein.`);

View File

@@ -0,0 +1,99 @@
-- Validation of the Austrian Sozialversicherungsnummer (SVNR).
--
-- `sv_nummer` has been free text since the initial schema. For employees at
-- an Austrian location it follows a fixed standard — three-digit serial,
-- check digit, then TTMMJJ — and the check digit is verifiable, so typos
-- that would otherwise surface at the payroll interface can be caught on
-- entry. Employees at the German/Czech/Slovenian locations keep the field
-- free-form; their national equivalents have different formats.
--
-- Enforced by a trigger rather than inside hire_employee/change_employee_data
-- for the same reason as the assignment history: both functions have been
-- redefined by half a dozen migrations, and a check in the table catches
-- every write path including ones added later.
create or replace function is_valid_svnr(p_svnr text, p_birth_date date default null)
returns boolean
language plpgsql
immutable
as $$
declare
v text := regexp_replace(coalesce(p_svnr, ''), '[\s./-]', '', 'g');
v_weights int[] := array[3, 7, 9, 0, 5, 8, 4, 2, 1, 6];
v_days_in_month int[] := array[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
v_sum int := 0;
v_day int;
v_month int;
v_check int;
begin
if v !~ '^\d{10}$' then return false; end if;
-- 000 is never issued as a serial.
if substr(v, 1, 3) = '000' then return false; end if;
v_day := substr(v, 5, 2)::int;
v_month := substr(v, 7, 2)::int;
if v_month < 1 or v_month > 12 or v_day < 1 then return false; end if;
-- February is allowed 29 days: a two-digit year cannot tell us whether the
-- year was a leap year, so the generous bound is the correct one here.
if v_day > v_days_in_month[v_month] then return false; end if;
for i in 1..10 loop
if i <> 4 then
v_sum := v_sum + substr(v, i, 1)::int * v_weights[i];
end if;
end loop;
v_check := v_sum % 11;
-- A serial whose weighted sum lands on 11 leaves no single digit to use,
-- so that serial is skipped rather than wrapped around.
if v_check = 10 then return false; end if;
if v_check <> substr(v, 4, 1)::int then return false; end if;
if p_birth_date is not null and to_char(p_birth_date, 'DDMMYY') <> substr(v, 5, 6) then
return false;
end if;
return true;
end;
$$;
comment on function is_valid_svnr(text, date) is
'Prüft eine österreichische SV-Nummer (10 Ziffern, Prüfziffer mod 11). Mit p_birth_date wird zusätzlich der TTMMJJ-Teil gegen das Geburtsdatum geprüft.';
create or replace function fn_validate_employee_svnr()
returns trigger
language plpgsql
as $$
declare
v_country text;
begin
if new.sv_nummer is null or btrim(new.sv_nummer) = '' then
return new;
end if;
-- Only a *newly written* value is checked. Rows that predate this
-- migration keep whatever they hold, so an unrelated edit — a transfer, a
-- promotion, an address change — is never blocked by a legacy value the
-- user is not touching.
if tg_op = 'UPDATE' and new.sv_nummer is not distinct from old.sv_nummer then
return new;
end if;
select country into v_country from locations where id = new.location_id;
if v_country is distinct from 'Österreich' then
return new;
end if;
if not is_valid_svnr(new.sv_nummer, new.birth_date) then
raise exception 'Ungültige SV-Nummer: %. Erwartet werden 10 Ziffern (laufende Nummer, Prüfziffer, TTMMJJ) mit gültiger Prüfziffer und dem Geburtsdatum des/der Mitarbeiter:in.', new.sv_nummer
using errcode = '23514';
end if;
return new;
end;
$$;
drop trigger if exists trg_validate_employee_svnr on employees;
create trigger trg_validate_employee_svnr
before insert or update on employees
for each row execute function fn_validate_employee_svnr();

View File

@@ -8,6 +8,9 @@
import { createClient } from "@supabase/supabase-js";
import { randomUUID } from "node:crypto";
// Explicit .ts extension: this file is run directly by Node (type-stripping,
// ESM), where an extensionless relative import does not resolve.
import { svnrCheckDigit } from "../lib/svnr.ts";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
@@ -365,12 +368,22 @@ function makeEmail(firstName: string, lastName: string): string {
return email;
}
// Used a random four-digit prefix before, so the check digit was right only
// by chance — which the SVNR validation trigger now rejects outright. The
// serial is still random; only the check digit is computed for it.
function makeSvNummer(birthDate: Date): string {
const dd = String(birthDate.getDate()).padStart(2, "0");
const mm = String(birthDate.getMonth() + 1).padStart(2, "0");
const yy = String(birthDate.getFullYear()).slice(-2);
const prefix = String(randInt(1000, 9999));
return `${prefix} ${dd}${mm}${yy}`;
const tail = `${dd}${mm}${yy}`;
// Not every serial yields a usable check digit (a weighted sum of 11 is
// skipped rather than wrapped), so draw until one does.
for (;;) {
const serial = String(randInt(1, 999)).padStart(3, "0");
const check = svnrCheckDigit(`${serial}0${tail}`);
if (check !== null) return `${serial}${check} ${tail}`;
}
}
function birthDateForAge(age: number): Date {

View File

@@ -0,0 +1,98 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useCallback, useState } from "react";
import { describe, expect, it } from "vitest";
import { GraphOrgChart } from "@/components/orgchart/GraphOrgChart";
import type { ChartNode } from "@/components/orgchart/types";
// Regression cover for the graphical org chart. The expand control lives
// inside a React Flow custom node, and React Flow decides whether that node
// receives pointer events at all from the props passed to <ReactFlow>:
// setting elementsSelectable={false} alongside nodesDraggable={false} made it
// compute pointer-events:none for the whole card, so neither the toggle nor
// the employee link responded. Nothing in the type system catches that, and
// no logic test would either — it only shows up when the tree is rendered
// and actually clicked.
function person(id: string, name: string, children: ChartNode[] = []): ChartNode {
return {
id,
kind: "person",
label: name,
sublabel: "Testrolle",
href: `/employees/${id}`,
avatar: { firstName: name.split(" ")[0], lastName: name.split(" ")[1] ?? "X" },
totalReports: children.length,
children,
};
}
const TREE: ChartNode[] = [
person("1", "Johanna Wiesinger", [
person("2", "Markus Steinbacher", [person("4", "Katharina Aigner"), person("5", "Kurt Aigner")]),
person("3", "Simon Aigner"),
]),
];
function Harness({ initial = ["1"] }: { initial?: string[] }) {
const [expanded, setExpanded] = useState<Set<string>>(new Set(initial));
const onToggle = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
return <GraphOrgChart tree={TREE} isExpanded={isExpanded} onToggle={onToggle} matchedIds={null} />;
}
describe("GraphOrgChart", () => {
it("renders the root and its expanded children", async () => {
render(<Harness />);
expect(await screen.findByText("Johanna Wiesinger")).toBeInTheDocument();
expect(screen.getByText("Markus Steinbacher")).toBeInTheDocument();
expect(screen.getByText("Simon Aigner")).toBeInTheDocument();
// Grandchildren stay hidden until their parent is expanded.
expect(screen.queryByText("Katharina Aigner")).not.toBeInTheDocument();
});
it("expands a collapsed branch when its control is clicked", async () => {
render(<Harness />);
await userEvent.click(await screen.findByRole("button", { name: /Markus Steinbacher aufklappen/i }));
expect(await screen.findByText("Katharina Aigner")).toBeInTheDocument();
expect(screen.getByText("Kurt Aigner")).toBeInTheDocument();
});
it("collapses again", async () => {
render(<Harness initial={["1", "2"]} />);
expect(await screen.findByText("Katharina Aigner")).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: /Johanna Wiesinger zuklappen/i }));
expect(screen.queryByText("Markus Steinbacher")).not.toBeInTheDocument();
});
it("keeps the node's pointer events enabled", () => {
// The direct assertion on the regression: React Flow writes
// pointer-events onto the node wrapper as an inline style, and 'none'
// there silently disables everything inside the card.
const { container } = render(<Harness />);
const nodes = container.querySelectorAll<HTMLElement>(".react-flow__node");
expect(nodes.length).toBeGreaterThan(0);
for (const node of nodes) {
expect(node.style.pointerEvents).not.toBe("none");
}
});
it("shows the child count on a collapsed node and links to the employee", async () => {
render(<Harness />);
expect(await screen.findByRole("button", { name: /Markus Steinbacher aufklappen \(2\)/i })).toHaveTextContent("2");
expect(screen.getByRole("link", { name: /Johanna Wiesinger/i })).toHaveAttribute("href", "/employees/1");
});
it("gives a leaf no expand control", async () => {
render(<Harness />);
await screen.findByText("Simon Aigner");
expect(screen.queryByRole("button", { name: /Simon Aigner (auf|zu)klappen/i })).not.toBeInTheDocument();
});
});

39
tests/components/setup.ts Normal file
View File

@@ -0,0 +1,39 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
afterEach(cleanup);
// React Flow keeps a node at visibility:hidden until ResizeObserver has
// reported its size, and Testing Library excludes hidden elements from the
// accessibility tree — so an inert stub makes every node unqueryable by
// role. This one reports a size, which is what actually makes the nodes
// visible and the chart behave as it does in a browser.
const NODE_WIDTH = 268;
const NODE_HEIGHT = 80;
class ResizeObserverStub {
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
const entry = {
target,
contentRect: { width: NODE_WIDTH, height: NODE_HEIGHT, top: 0, left: 0, bottom: NODE_HEIGHT, right: NODE_WIDTH, x: 0, y: 0 },
} as unknown as ResizeObserverEntry;
queueMicrotask(() => this.callback([entry], this as unknown as ResizeObserver));
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal("ResizeObserver", ResizeObserverStub);
if (!("DOMMatrixReadOnly" in globalThis)) {
class DOMMatrixReadOnlyStub {
m22 = 1;
}
vi.stubGlobal("DOMMatrixReadOnly", DOMMatrixReadOnlyStub);
}
// jsdom reports 0 for every layout box; React Flow reads these to size nodes.
Object.defineProperty(HTMLElement.prototype, "offsetWidth", { configurable: true, value: NODE_WIDTH });
Object.defineProperty(HTMLElement.prototype, "offsetHeight", { configurable: true, value: NODE_HEIGHT });
Object.defineProperty(SVGElement.prototype, "getBBox", { configurable: true, value: () => ({ x: 0, y: 0, width: 0, height: 0 }) });

View File

@@ -0,0 +1,122 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Database } from "@/lib/supabase/types";
import { adminClient, createHrUser, deleteTestEmployee, deleteTestUser, hireTestEmployee, pickSeededTeam, signInAs, type TestUser } from "./helpers";
// SVNR validation (supabase/migrations/20260725120000_svnr_validation.sql).
// Enforced by a trigger, so these drive it through the real write paths and
// through a direct update — both must be covered by the same rule.
describe("SVNR validation", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let team: { id: string };
const employeeIds: string[] = [];
// 3·1 + 7·2 + 9·3 = 44; 010180 contributes 18; 62 mod 11 = 7
const VALID = "1237 010180";
const BIRTH_DATE = "1980-01-01";
async function locationIn(country: string): Promise<string> {
const { data } = await adminClient.from("locations").select("id").eq("country", country).limit(1).maybeSingle();
if (!data) throw new Error(`no seeded location in ${country}`);
return data.id;
}
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
team = await pickSeededTeam();
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function hireAt(country: string, overrides: Record<string, unknown> = {}): Promise<string> {
const id = await hireTestEmployee(hrClient, team.id, {
location_id: await locationIn(country),
birth_date: BIRTH_DATE,
...overrides,
});
employeeIds.push(id);
return id;
}
describe("is_valid_svnr", () => {
async function check(svnr: string, birthDate: string | null = null): Promise<boolean> {
const { data, error } = await adminClient.rpc("is_valid_svnr", { p_svnr: svnr, p_birth_date: birthDate });
if (error) throw new Error(error.message);
return data as unknown as boolean;
}
it("matches the TypeScript implementation on the documented cases", async () => {
expect(await check(VALID)).toBe(true);
expect(await check("1237010180")).toBe(true);
expect(await check("1234 010180")).toBe(false); // wrong check digit
expect(await check("0007 010180")).toBe(false); // 000 serial
expect(await check("0040 010180")).toBe(false); // check digit would be 10
expect(await check("1237 011380")).toBe(false); // month 13
expect(await check("123701018")).toBe(false); // nine digits
});
it("cross-checks the birth date when one is given", async () => {
expect(await check(VALID, BIRTH_DATE)).toBe(true);
expect(await check(VALID, "1980-01-02")).toBe(false);
});
});
describe("at an Austrian location", () => {
it("accepts a hire carrying a valid number", async () => {
const id = await hireAt("Österreich", { sv_nummer: VALID });
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
expect(data?.sv_nummer).toBe(VALID);
});
it("rejects a hire carrying an invalid number", async () => {
await expect(hireAt("Österreich", { sv_nummer: "1234 010180" })).rejects.toThrow(/SV-Nummer/i);
});
it("rejects a number whose birth date disagrees with the employee record", async () => {
await expect(hireAt("Österreich", { sv_nummer: VALID, birth_date: "1975-06-30" })).rejects.toThrow(/SV-Nummer/i);
});
it("allows the field to stay empty", async () => {
const id = await hireAt("Österreich");
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
expect(data?.sv_nummer ?? null).toBeNull();
});
it("rejects an update to an invalid number", async () => {
const id = await hireAt("Österreich", { sv_nummer: VALID });
const { error } = await adminClient.from("employees").update({ sv_nummer: "9999 010180" }).eq("id", id);
expect(error?.message ?? "").toMatch(/SV-Nummer/i);
});
});
describe("outside Austria", () => {
it("leaves the field free-form", async () => {
// The German equivalent has a different format entirely; validating it
// against the Austrian standard would reject correct data.
const id = await hireAt("Deutschland", { sv_nummer: "12 345678 A 901" });
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
expect(data?.sv_nummer).toBe("12 345678 A 901");
});
});
describe("legacy rows", () => {
it("does not block an unrelated edit when the stored number is invalid", async () => {
// Rows predating the migration hold unvalidated values. A transfer or
// a name change must not fail because of a number nobody is touching.
const id = await hireAt("Deutschland", { sv_nummer: "0000 000000" });
const { error: moveError } = await adminClient
.from("employees")
.update({ location_id: await locationIn("Österreich") })
.eq("id", id);
expect(moveError).toBeNull();
const { error: nameError } = await adminClient.from("employees").update({ phone: "+43 1 9999999" }).eq("id", id);
expect(nameError).toBeNull();
});
});
});

111
tests/unit/svnr.test.ts Normal file
View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { formatSvnr, isValidSvnr, normalizeSvnr, requiresAustrianSvnr, svnrCheckDigit, validateSvnr } from "@/lib/svnr";
// Weights 3,7,9 on the serial and 5,8,4,2,1,6 on TTMMJJ; sum mod 11 is the
// check digit. Worked through by hand for "123? 010180":
// 3·1 + 7·2 + 9·3 = 44 (serial)
// 5·0 + 8·1 + 4·0 + 2·1 + 1·8 + 6·0 = 18 (010180)
// 62 mod 11 = 7 → 1237 010180
const VALID = "1237 010180";
describe("svnrCheckDigit", () => {
it("computes the documented check digit", () => {
expect(svnrCheckDigit("1230010180")).toBe(7);
});
it("computes it independently of the digit already in that slot", () => {
// The check position is skipped, so a wrong digit there cannot influence
// the result — otherwise the check would validate itself.
expect(svnrCheckDigit("1234010180")).toBe(7);
expect(svnrCheckDigit("1239010180")).toBe(7);
});
it("computes 3 for serial 432 on the same date", () => {
// 3·4 + 7·3 + 9·2 = 51; 51 + 18 = 69; 69 mod 11 = 3
expect(svnrCheckDigit("4320010180")).toBe(3);
});
it("returns null for a serial that would need a check digit of 10", () => {
// 9·4 = 36; 36 + 18 = 54; 54 mod 11 = 10 — never issued, since there is
// no single digit for it.
expect(svnrCheckDigit("0040010180")).toBeNull();
});
});
describe("validateSvnr", () => {
it("accepts a well-formed number", () => {
expect(validateSvnr(VALID)).toBeNull();
expect(isValidSvnr(VALID)).toBe(true);
});
it("accepts it regardless of spacing or separators", () => {
for (const variant of ["1237010180", "1237 010180", "1237-010180", "1237.010180", " 1237 010180 "]) {
expect(validateSvnr(variant), variant).toBeNull();
}
});
it("rejects a wrong check digit", () => {
expect(validateSvnr("1234 010180")).toBe("checksum");
});
it("rejects a serial whose check digit would be 10", () => {
expect(validateSvnr("0040 010180")).toBe("checksum");
});
it("rejects anything that is not ten digits", () => {
for (const bad of ["", "123701018", "12370101801", "1237 01018X", "abcdefghij"]) {
expect(validateSvnr(bad), bad).toBe("length");
}
});
it("rejects the unissued 000 serial", () => {
expect(validateSvnr("0007 010180")).toBe("serial");
});
it("rejects an impossible date tail", () => {
// Month 13 and day 32 never occur; the checksum is irrelevant once the
// tail cannot be a date at all.
expect(validateSvnr("1237 011380")).toBe("date");
expect(validateSvnr("1237 320180")).toBe("date");
expect(validateSvnr("1237 310280")).toBe("date");
});
it("allows 29 February, which a two-digit year cannot disambiguate", () => {
// 3·1+7·2+9·3 = 44; 5·2+8·9+4·0+2·2+1·8+6·0 = 10+72+0+4+8 = 94; 138 mod 11 = 6
expect(validateSvnr("1236 290280")).toBeNull();
});
});
describe("cross-check against the stored birth date", () => {
it("passes when the tail matches", () => {
expect(validateSvnr(VALID, "1980-01-01")).toBeNull();
});
it("catches a transposed birth date the checksum cannot", () => {
expect(validateSvnr(VALID, "1980-01-02")).toBe("birthDateMismatch");
expect(validateSvnr(VALID, "1981-01-01")).toBe("birthDateMismatch");
});
it("skips the cross-check when no birth date is known", () => {
expect(validateSvnr(VALID, null)).toBeNull();
expect(validateSvnr(VALID, undefined)).toBeNull();
});
});
describe("helpers", () => {
it("normalises and formats", () => {
expect(normalizeSvnr("1237 010180")).toBe("1237010180");
expect(formatSvnr("1237010180")).toBe("1237 010180");
});
it("leaves non-canonical input alone when formatting", () => {
expect(formatSvnr("12")).toBe("12");
});
it("applies only to Austrian locations", () => {
expect(requiresAustrianSvnr("Österreich")).toBe(true);
for (const c of ["Deutschland", "Tschechien", "Slowenien", null, undefined]) {
expect(requiresAustrianSvnr(c)).toBe(false);
}
});
});

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
@@ -9,6 +9,9 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
// supabase/seed.ts is executed directly by Node, which resolves relative
// imports as ESM and therefore needs the explicit .ts extension.
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",

View File

@@ -1,14 +1,27 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
import { defineConfig } from "vitest/config";
const alias = { "@": path.resolve(__dirname, ".") };
// Two projects in one run: pure logic keeps the node environment, component
// tests get jsdom. Splitting them keeps the ~100 logic tests from paying for
// a DOM they never touch.
export default defineConfig({
test: {
environment: "node",
include: ["tests/unit/**/*.test.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
projects: [
{
resolve: { alias },
test: { name: "unit", environment: "node", include: ["tests/unit/**/*.test.ts"] },
},
{
resolve: { alias },
test: {
name: "components",
environment: "jsdom",
setupFiles: ["tests/components/setup.ts"],
include: ["tests/components/**/*.test.tsx"],
},
},
],
},
});