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:
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";
|
||||
}
|
||||
Reference in New Issue
Block a user