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:
90
.github/workflows/ci.yml
vendored
Normal file
90
.github/workflows/ci.yml
vendored
Normal 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
4
.gitignore
vendored
@@ -46,6 +46,10 @@ next-env.d.ts
|
|||||||
/supabase/.temp
|
/supabase/.temp
|
||||||
/supabase/snippets
|
/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
|
# per-machine (permission allowlists) and must stay out of the repo
|
||||||
|
|
||||||
# playwright
|
# playwright
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type ActionResult = { success: boolean; error?: string };
|
|||||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||||
|
|
||||||
async function callRpc(
|
async function callRpc(
|
||||||
fn: "create_position" | "delete_position" | "staff_position_internally",
|
fn: "create_position" | "delete_position",
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
revalidate: string[]
|
revalidate: string[]
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
@@ -34,13 +34,6 @@ export async function deletePosition(positionId: string): Promise<ActionResult>
|
|||||||
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
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 };
|
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
|
// For "Position ausschreiben": superior lookup, filtered to team-leads when
|
||||||
|
|||||||
@@ -151,7 +151,13 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
/>
|
/>
|
||||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||||
<KarenzPanel open={panel === "karenz"} 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} />
|
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
||||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
59
components/employees/SvNummerField.tsx
Normal file
59
components/employees/SvNummerField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ import { useState } from "react";
|
|||||||
import { changeEmployeeData } from "@/actions/employees";
|
import { changeEmployeeData } from "@/actions/employees";
|
||||||
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
|
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
|
||||||
import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields";
|
import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields";
|
||||||
|
import { SvNummerField } from "@/components/employees/SvNummerField";
|
||||||
import { TitleFields, type TitleValue } from "@/components/employees/TitleFields";
|
import { TitleFields, type TitleValue } from "@/components/employees/TitleFields";
|
||||||
import { CountryPicker } from "@/components/ui/CountryPicker";
|
import { CountryPicker } from "@/components/ui/CountryPicker";
|
||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { UN_COUNTRIES } from "@/lib/countries";
|
import { UN_COUNTRIES } from "@/lib/countries";
|
||||||
import { fmtFullName, todayIso } from "@/lib/format";
|
import { fmtFullName, todayIso } from "@/lib/format";
|
||||||
|
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||||
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
|
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
|
||||||
|
|
||||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
@@ -21,15 +23,19 @@ export function DatenAendernPanel({
|
|||||||
onClose,
|
onClose,
|
||||||
employee,
|
employee,
|
||||||
dependents,
|
dependents,
|
||||||
|
locationCountry,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
employee: EmployeeRow;
|
employee: EmployeeRow;
|
||||||
dependents: Dependent[];
|
dependents: Dependent[];
|
||||||
|
locationCountry: string | null | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [pending, setPending] = useState(false);
|
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 [effectiveDate, setEffectiveDate] = useState(todayIso);
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState(employee.first_name);
|
const [firstName, setFirstName] = useState(employee.first_name);
|
||||||
@@ -41,6 +47,8 @@ export function DatenAendernPanel({
|
|||||||
const [gender, setGender] = useState<GenderType>(employee.gender);
|
const [gender, setGender] = useState<GenderType>(employee.gender);
|
||||||
const [birthDate, setBirthDate] = useState(employee.birth_date);
|
const [birthDate, setBirthDate] = useState(employee.birth_date);
|
||||||
const [svNummer, setSvNummer] = useState(employee.sv_nummer ?? "");
|
const [svNummer, setSvNummer] = useState(employee.sv_nummer ?? "");
|
||||||
|
const svNummerOk =
|
||||||
|
!svNummer.trim() || !requiresAustrianSvnr(locationCountry) || isValidSvnr(svNummer, birthDate || null);
|
||||||
const [nationality, setNationality] = useState(employee.nationality);
|
const [nationality, setNationality] = useState(employee.nationality);
|
||||||
const [address, setAddress] = useState(employee.address ?? "");
|
const [address, setAddress] = useState(employee.address ?? "");
|
||||||
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
|
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
|
||||||
@@ -148,7 +156,8 @@ export function DatenAendernPanel({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
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"
|
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Speichern
|
Speichern
|
||||||
@@ -199,10 +208,13 @@ export function DatenAendernPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<SvNummerField
|
||||||
<label className="mb-1 block text-xs font-semibold text-ink-muted">SV-Nummer</label>
|
value={svNummer}
|
||||||
<input value={svNummer} onChange={(e) => setSvNummer(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
onChange={setSvNummer}
|
||||||
</div>
|
locationCountry={locationCountry}
|
||||||
|
birthDate={birthDate || null}
|
||||||
|
labelClassName="mb-1 block text-xs font-semibold text-ink-muted"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-ink-muted">Staatsbürgerschaft</label>
|
<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…" />
|
<CountryPicker value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
|||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import type { OpenPositionResolved } from "@/lib/positions";
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
|
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||||
import { StepPerson } from "./StepPerson";
|
import { StepPerson } from "./StepPerson";
|
||||||
import { StepPosition } from "./StepPosition";
|
import { StepPosition } from "./StepPosition";
|
||||||
import { StepSummary } from "./StepSummary";
|
import { StepSummary } from "./StepSummary";
|
||||||
@@ -48,8 +49,15 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
setDraft((prev) => ({ ...prev, ...patch }));
|
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 = [
|
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.positionId && draft.besetzung),
|
||||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SvNummerField } from "@/components/employees/SvNummerField";
|
||||||
import { TitleFields } from "@/components/employees/TitleFields";
|
import { TitleFields } from "@/components/employees/TitleFields";
|
||||||
import type { HireDraftData } from "./types";
|
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" />
|
<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>
|
</div>
|
||||||
<div>
|
<SvNummerField
|
||||||
<label className="mb-1 block text-sm font-semibold text-ink">SV-Nummer</label>
|
value={draft.svNummer}
|
||||||
<input value={draft.svNummer} onChange={(e) => update({ svNummer: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
onChange={(svNummer) => update({ svNummer })}
|
||||||
</div>
|
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 className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-semibold text-ink">E-Mail (privat)</label>
|
<label className="mb-1 block text-sm font-semibold text-ink">E-Mail (privat)</label>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useToast } from "@/components/ui/Toast";
|
|||||||
import { fmtDate, todayIso } from "@/lib/format";
|
import { fmtDate, todayIso } from "@/lib/format";
|
||||||
import type { OpenPositionResolved } from "@/lib/positions";
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
import { CreatePositionModal } from "./CreatePositionModal";
|
import { CreatePositionModal } from "./CreatePositionModal";
|
||||||
import { StaffInternallyModal } from "./StaffInternallyModal";
|
|
||||||
|
|
||||||
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
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 [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
|
|
||||||
@@ -76,15 +74,6 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</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>}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -93,15 +82,6 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -429,6 +429,7 @@ export type Database = {
|
|||||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||||
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
staff_position_internally: { 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 };
|
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||||
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||||
|
|||||||
88
lib/svnr.ts
Normal file
88
lib/svnr.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -1,14 +1,47 @@
|
|||||||
import type { NextConfig } from "next";
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
// Emits a self-contained .next/standalone server (only the deps actually
|
// Emits a self-contained .next/standalone server (only the deps actually
|
||||||
// used at runtime, no full node_modules) - what the Dockerfile copies in.
|
// used at runtime, no full node_modules) - what the Dockerfile copies in.
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
// Baseline security headers (clickjacking, MIME-sniffing, referrer leakage,
|
// Baseline security headers (clickjacking, MIME-sniffing, referrer leakage,
|
||||||
// browser feature access). No Content-Security-Policy yet: this app has no
|
// browser feature access) plus the report-only CSP described above.
|
||||||
// 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.
|
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -19,6 +52,7 @@ const nextConfig: NextConfig = {
|
|||||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||||
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
|
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
|
||||||
|
{ key: "Content-Security-Policy-Report-Only", value: contentSecurityPolicy() },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
1119
package-lock.json
generated
1119
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -11,32 +11,40 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:integration": "node --env-file=.env.test.local node_modules/vitest/vitest.mjs run --config vitest.integration.config.ts",
|
"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"
|
"check": "npm run lint && npm run typecheck && npm run test && npm run build"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22 <25"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dagrejs/dagre": "^3.0.0",
|
"@dagrejs/dagre": "^3.0.0",
|
||||||
"@supabase/ssr": "^0.12.1",
|
"@supabase/ssr": "^0.12.3",
|
||||||
"@supabase/supabase-js": "^2.110.5",
|
"@supabase/supabase-js": "^2.110.8",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"lucide-react": "^1.24.0",
|
"lucide-react": "^1.26.0",
|
||||||
"next": "16.2.10",
|
"next": "^16.2.11",
|
||||||
"react": "19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "^19.2.8",
|
||||||
"server-only": "^0.0.1"
|
"server-only": "^0.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-next": "16.2.10",
|
"eslint-config-next": "^16.2.11",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"postcss": "^8.5.19",
|
"postcss": "^8.5.19",
|
||||||
"supabase": "^2.109.1",
|
"supabase": "^2.109.1",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
|
|||||||
158
scripts/check-schema-types.mjs
Normal file
158
scripts/check-schema-types.mjs
Normal 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.`);
|
||||||
99
supabase/migrations/20260725120000_svnr_validation.sql
Normal file
99
supabase/migrations/20260725120000_svnr_validation.sql
Normal 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();
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import { randomUUID } from "node:crypto";
|
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 SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
@@ -365,12 +368,22 @@ function makeEmail(firstName: string, lastName: string): string {
|
|||||||
return email;
|
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 {
|
function makeSvNummer(birthDate: Date): string {
|
||||||
const dd = String(birthDate.getDate()).padStart(2, "0");
|
const dd = String(birthDate.getDate()).padStart(2, "0");
|
||||||
const mm = String(birthDate.getMonth() + 1).padStart(2, "0");
|
const mm = String(birthDate.getMonth() + 1).padStart(2, "0");
|
||||||
const yy = String(birthDate.getFullYear()).slice(-2);
|
const yy = String(birthDate.getFullYear()).slice(-2);
|
||||||
const prefix = String(randInt(1000, 9999));
|
const tail = `${dd}${mm}${yy}`;
|
||||||
return `${prefix} ${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 {
|
function birthDateForAge(age: number): Date {
|
||||||
|
|||||||
98
tests/components/GraphOrgChart.test.tsx
Normal file
98
tests/components/GraphOrgChart.test.tsx
Normal 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
39
tests/components/setup.ts
Normal 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 }) });
|
||||||
122
tests/integration/svnr-validation.test.ts
Normal file
122
tests/integration/svnr-validation.test.ts
Normal 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
111
tests/unit/svnr.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2022",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"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,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
import path from "node:path";
|
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({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
projects: [
|
||||||
include: ["tests/unit/**/*.test.ts"],
|
{
|
||||||
|
resolve: { alias },
|
||||||
|
test: { name: "unit", environment: "node", include: ["tests/unit/**/*.test.ts"] },
|
||||||
},
|
},
|
||||||
resolve: {
|
{
|
||||||
alias: {
|
resolve: { alias },
|
||||||
"@": path.resolve(__dirname, "."),
|
test: {
|
||||||
|
name: "components",
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["tests/components/setup.ts"],
|
||||||
|
include: ["tests/components/**/*.test.tsx"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user