Commit Graph

8 Commits

Author SHA1 Message Date
35f17858d8 Build the org tree in the OM model as a tested pure function
Constructing the tree is where parent links, chief positions and number
ranges get wired up wrongly without anyone noticing — a team under the wrong
Bereich looks perfectly plausible in the org chart. So the construction is a
pure function taking an id generator, and the checks that matter are asserted
rather than eyeballed: exactly one root, every unit's parent of the expected
type, every unit reaching the root, one chief position per unit, unique
numbers in the right ranges, and every position pointing at a real unit and
a real job.

Also introduces the job catalogue this model needs. job_title was free text
per person, so "Schlosser:in" and "Schlosser" could coexist and no breakdown
by occupation was possible; jobs are now deduplicated by title and shared
across positions.

Every Abteilung gets a chief position, which is the level the old three-table
model had no room for.

Correction to the previous commit message: it claimed the cut-over could
follow later while the old tables kept working. It cannot. The legacy
resolve_manager_for() finds a Bereichsleitung by "division_id = X and
team_id is null and org_level = 1", and an Abteilungsleitung satisfies the
same predicate — its LIMIT 1 would then pick one of the two arbitrarily. The
two models cannot both be correct once the new level is populated, so the
remaining work is a single cut across the 11 RPCs that read the legacy
columns, not a gradual migration.
2026-07-27 12:52:02 +02:00
4b9c23472c SAP OM: org units, jobs, positions, and a derived reporting line
The org structure was three fixed tables — divisions -> departments ->
teams — with people hanging directly off them and a hand-maintained
manager_id. The depth was therefore wired into the schema: an Abteilungsleitung
could not exist without a migration, and a team directly under a Bereich not
at all. That is what this replaces.

The SAP OM object types, one table each:

  O  org_units             recursive over parent_id
  C  jobs                  catalogue, so many positions can share a job
  S  om_positions          belongs to exactly one org unit
  P  employees             existing table
  A012 om_positions.is_chief          "ist Leiter von"
  A008 position_assignments           "Inhaber ist", time-dependent

Two consequences worth stating, because they are the point of the exercise:

- GF/Bereich/Abteilung/Team are now a label (unit_type), not a structure.
  Adding a fifth level, or hanging a team straight off a Bereich, becomes a
  data question rather than a migration.
- Nobody hangs off an org unit any more: person -> position -> unit. A
  vacancy stops being its own concept — it is a position with no current
  assignment.

The reporting line is derived rather than stored: an ordinary position
reports to the chief of its own unit, a chief to the chief of the parent
unit, and if that chief is vacant or on a long-term absence it keeps
climbing. An unfilled Abteilungsleitung therefore needs no special case —
it is simply skipped. Both ids come back, formal and acting, so the UI can
show a stand-in as a stand-in instead of passing it off as the real manager.

The rule exists twice, as om_reporting_lines() in SQL and
resolveReportingLines() in TypeScript, because the as-of chart computes it
per date in the app and a round trip per date change would buy nothing. Two
copies drift silently — the org chart would just show a different manager
than the export — so an integration test runs both over the whole roster and
requires identical answers, plus that every line terminates at the top.

Unit tests cover the rule itself: unfilled levels, several absent levels in
a row, nobody above, a chief who also leads the parent unit, and a cycle in
parent_id, which is an ordinary column an import could get wrong.

Additive so far. The old tables still stand and the app still reads them;
the cut-over follows.
2026-07-27 12:47:14 +02:00
2776c33d08 Roll reporting up past an absent manager, and say so on both sides
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m17s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m18s
While somebody is on a long-term absence their reports report to the next
management level, and it keeps rolling up until it reaches somebody present.
Derived at read time in lib/acting-manager.ts rather than written to
employees.manager_id: the absent person stays formally in charge, so the
stand-in has to be visible as a stand-in rather than quietly replacing them.
Both ids therefore travel to the UI, and both sides carry a badge — the
absent person ("Abwesend · Vertretung: X") and anyone now reporting
elsewhere ("Vertretung für Y").

Three cases the walk has to survive, all covered by tests:
- Several absent levels in a row — it keeps climbing, and still names the
  *recorded* manager as the one being covered for, not the level skipped.
- Everyone above absent — it stops and keeps the recorded manager. Re-rooting
  a team to the top of the chart would distort more than showing an absent
  manager whose absence is labelled anyway.
- A manager_id cycle, which nothing in the schema forbids.

An absent lead needs no separate deputy field: their stand-in is simply
their own acting manager, the same one their reports moved to.
2026-07-27 12:03:34 +02:00
8282d7f581 Rename Karenz to Langzeitabwesenheit and record its type
Karenz was doing duty as the name for every kind of extended absence, but
the cases behave differently in payroll and reporting — Wochenhilfe, a
Präsenzdienst, a long sick leave and a sabbatical are not the same thing.
The concept is now called Langzeitabwesenheit and carries which kind it is.

- employees.absence_type, constrained to the thirteen kinds. start_karenz
  stores it on both paths (written straight away, or parked in the
  pending_org_changes payload when the absence starts later);
  record_karenz_return and the karenz_return branch of
  apply_due_pending_changes clear it, so a returned employee does not keep
  looking like they are still away. It also reaches employee_history, the
  audit log and the employee export.
- The status enum value stays 'Karenz'. Postgres can rename an enum value in
  place, but every stored function body that spells it would then reference
  a value that no longer exists — a dozen functions across fifteen
  migrations, rewritten for a label. The mapping lives in lib/absence.ts
  instead, which is the single place the UI reads the display name from.
- Where a kind is recorded the chip shows it — "Bildungskarenz" says more
  than "Langzeitabwesenheit". Absences predating the field have none and
  fall back to the generic name rather than to a guess, and a value outside
  the list is dropped rather than echoed into the UI.
- The export prints the display name, not the raw enum: a payroll hand-off
  reading "Karenz" for what the app calls Langzeitabwesenheit only causes
  questions. Audit filter options keep their stored values and change only
  their labels.
- The seed spreads the twelve absences across the kinds; all of them being
  Karenz would leave any breakdown by kind invisible.
2026-07-25 14:34:28 +02:00
8d978981b0 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).
2026-07-25 11:13:10 +02:00
79f0e19bf8 Org assignment history, mobile support, and a correctness pass
Data model
- employee_assignments records org placement over time (valid_from/valid_to),
  written by a trigger on `employees` rather than inside each RPC: ~70
  `update employees` statements spread over fifteen migrations mean per-call
  bookkeeping would miss paths today and again with every future RPC. A
  partial unique index enforces the one-open-interval invariant the trigger
  relies on when closing the current row.
- The Organigramm gains a Stichtag (default today). Membership comes from
  entry/exit/karenz, past placement from the new history, future placement
  projected from pending_org_changes. Placements predating the migration are
  backfilled with today's values and flagged as such in the UI, since
  employee_history only ever stored free text and cannot be reconstructed.

Correctness
- Reports and exports silently truncated at PostgREST's 1000-row cap
  (db.max_rows); employee_history is already past it at ~800 staff. Every
  whole-table read now pages explicitly.
- XLSX date cells were a day early: ExcelJS converts a Date to an Excel
  serial straight off getTime(), so a Date built at local midnight lands on
  the previous day's serial in any positive-offset zone.
- Date handling is pinned to Europe/Vienna throughout, and date-only strings
  are formatted without a Date round-trip. The dashboard's YTD window was
  built by round-tripping a local Date through toISOString(), which shifted
  it a day early and dropped 31 December entirely.
- Export routes parsed measure/group/split/eventType with unchecked `as`
  casts, so an unknown value reached column headers as `undefined` and the
  Content-Disposition filename. Parsed against the label maps now, with the
  filename slugged as a backstop.
- toXlsx keyed columns by header text, silently dropping the second of any
  two columns sharing a name — split columns take their header from data.
- The org chart tree walks had no cycle guard; nothing in the schema forbids
  a manager_id cycle, and one would hang the tab rather than misreport.
- The login page reflected ?error= verbatim, letting anyone put arbitrary
  text on the real sign-in screen; messages are looked up by code now.
- React Flow needs elementsSelectable on, or it sets pointer-events:none on
  the whole node and the expand control stops responding.

UI
- Mobile: the shell was unusable below lg — a fixed 236px margin pushed
  content off-screen with no mobile navigation at all. The sidebar is now a
  drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so
  iOS stops zooming on focus, and form grids stack.
- Org chart nodes redesigned: per-kind accent stripes and icons, vacant
  roles called out, expand control moved to the bottom edge carrying the
  child count.
- Pagination is windowed; it previously rendered one link per page (54 for
  the employee list, unbounded for the audit log).
- Positions page reduced to open positions with a single "Besetzen" action.
- The employee Organisation tab links into the org chart focused on that
  person, reusing the chart's existing search-match highlighting.

Also included, uncommitted until now
- Dependants, HR notes, academic titles, split address fields, position
  validity and role/employment fields, with their migrations and UI.
- Docker/compose deployment setup, data-model and security-review docs.
2026-07-24 23:38:10 +02:00
f96773da0f Reports/Export builder (CSV/XLSX), plus a security fix pass
Adds the Berichte export pipeline (/api/export/{report,events,employees})
with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts.

Security pass alongside it: sanitize .or() search terms against PostgREST
filter injection, sanitize spreadsheet cells against CSV/Excel formula
injection, stop leaking raw DB error messages to clients, harden the
service-role client with server-only, add baseline security headers, and
bump the vulnerable nested postcss via an override.
2026-07-15 20:34:27 +02:00
901c5c426e Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite
Reworks the app from a two-role (hr_admin/manager) model to a single
HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/
reorg RPCs to actually defer future-dated changes via a new
pending_org_changes table instead of writing them immediately (applied
by a daily Vercel Cron route), makes reorg undo append-only instead of
deleting history, adds Karenz-return and history-date integrity guards,
deprecates the salary column, and adds explicit schema grants + perf
indexes needed to run against a fresh (non-hosted) Postgres instance.

Adds vitest unit + integration test suites (the latter against a real
local Supabase instance) covering all of the above, plus lint/typecheck/
build wiring (`npm run check`).
2026-07-14 20:32:20 +02:00