Put the onboarding checklist where the file is
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m24s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m51s

The list existed on paper: one printed sheet per entry, twenty-five
boxes. What is on it is known only to whoever holds the sheet — it
cannot be searched, cannot be covered for while someone is away, and
says nothing about who ticked what.

Not every box on the sheet is a checkbox, and the differences carry
meaning, so the field kind is derived from the thing rather than
flattened:

  Haken   — the normal case. The Meldezettel is there or it is not.
  Ja/Nein — Prämienanspruch had *two* boxes on the sheet, and that is
            not decoration: "nein" is a finding, "not asked yet" is not.
            One checkbox cannot say both.
  Text    — shoe, shirt and trouser size. The value is the point;
            ticked off it would be worthless.

Every item takes a comment, and every item records who last touched it
and when — the part the sheet could never do.

Saved on click, not on submit. A checklist is worked through over days,
between other things; a save button at the end is where half a morning
goes missing.

The items live in lib/onboarding.ts, not in a table: a checklist is a
company process, not a master record. Stored per person is only the
answer, under the item's key — so an item dropped later leaves its old
answers standing instead of taking them along, and a file from back then
stays readable.

A list is created by hire and rehire, in the same transaction as the
hire itself: a hire without a checklist would be a half-recorded hire.
Rehire only adds what is missing and never clears an old tick — what
genuinely has to be redone is HR's call, and a program deciding it would
be guessing. People hired before this feature have no list and get a
button to start one.

Checked against the real database end to end: hire creates 25 open
items; checkbox, ja/nein, size and comment all land; a comment-only edit
leaves the tick alone; rehire tops the list up and keeps what was done.
The probe employee was removed afterwards — audit rows first, since the
log has no delete policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:40:02 +02:00
parent bd990b7f2c
commit 82d07f0d95
9 changed files with 800 additions and 8 deletions

View File

@@ -0,0 +1,171 @@
-- Onboarding-Checkliste je Person
--
-- Bisher lief das über ein Blatt neben der Anwendung: eine Tabelle mit
-- Kästchen, je Eintritt einmal ausgedruckt. Was darauf steht, weiss dann nur,
-- wer das Blatt hat — und ob der Meldezettel schon da ist, lässt sich weder
-- suchen noch auswerten noch vertreten.
--
-- Gespeichert wird **je Person und Punkt eine Zeile**, unter dem Schlüssel des
-- Punktes. Die Punkte selbst stehen in lib/onboarding.ts, nicht hier: eine
-- Checkliste ist ein Firmenprozess und kein Stammdatum. Der Schlüssel als Text
-- statt als Fremdschlüssel hat einen Preis (die Datenbank kennt die gültigen
-- Werte nicht) und einen Grund: ein später gestrichener Punkt lässt die alten
-- Antworten stehen, statt sie mitzureissen. Eine Akte von damals bleibt so
-- lesbar, auch wenn die Liste heute anders aussieht.
--
-- Drei Arten von Antwort in einer Tabelle, weil es dieselbe Sache ist:
-- • Haken → erledigt
-- • Ja/Nein → wert ('ja'/'nein'); offen ist etwas anderes als nein
-- • Text → wert (Grössen)
-- `erledigt` trägt bei allen dreien die Frage „abgehakt?", damit der
-- Fortschritt eine Spalte hat und keine Fallunterscheidung.
create table if not exists onboarding_tasks (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
item_key text not null,
erledigt boolean not null default false,
wert text,
kommentar text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
updated_by uuid references profiles(id) on delete set null,
-- Der Name mitgeschrieben, nicht nur die Kennung: wer eine Checkliste von
-- vor zwei Jahren aufschlägt, will lesen, wer abgehakt hat, auch wenn die
-- Person längst nicht mehr im Verzeichnis steht.
updated_by_name text,
constraint onboarding_tasks_eine_zeile unique (employee_id, item_key)
);
create index if not exists onboarding_tasks_employee_id_idx on onboarding_tasks (employee_id);
-- Für „was ist noch offen": der Teilindex liest nur die unerledigten.
create index if not exists onboarding_tasks_offen_idx on onboarding_tasks (employee_id) where not erledigt;
drop policy if exists onboarding_tasks_hr_all on onboarding_tasks;
create policy onboarding_tasks_hr_all on onboarding_tasks
for all using (is_hr_user()) with check (is_hr_user());
-- ── Einen Punkt festhalten ─────────────────────────────────────────────
--
-- Ein Aufruf für alle drei Arten: gesetzt wird, was mitkommt. Wer nur den
-- Kommentar ändert, schickt nur den Kommentar — sonst würde ein Tippfehler im
-- Kommentarfeld den Haken mitlöschen.
create or replace function set_onboarding_task(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_item_key text := nullif(trim(payload->>'item_key'), '');
v_name text;
v_vorher onboarding_tasks%rowtype;
v_erledigt boolean;
v_wert text;
v_kommentar text;
begin
perform require_hr_admin();
if v_item_key is null then
raise exception 'Es wurde kein Punkt angegeben.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_name is null then
raise exception 'Die Person existiert nicht.';
end if;
select * into v_vorher from onboarding_tasks
where employee_id = v_employee_id and item_key = v_item_key for update;
-- Weggelassen heisst „unverändert", nicht „leeren". Der Unterschied ist der
-- Grund, warum hier `payload ? 'feld'` steht und nicht coalesce: eine
-- ausdrückliche null muss löschen können.
v_erledigt := case when payload ? 'erledigt' then (payload->>'erledigt')::boolean
else coalesce(v_vorher.erledigt, false) end;
v_wert := case when payload ? 'wert' then nullif(trim(payload->>'wert'), '') else v_vorher.wert end;
v_kommentar := case when payload ? 'kommentar' then nullif(trim(payload->>'kommentar'), '')
else v_vorher.kommentar end;
insert into onboarding_tasks (employee_id, item_key, erledigt, wert, kommentar, updated_by, updated_by_name)
values (v_employee_id, v_item_key, v_erledigt, v_wert, v_kommentar,
app_current_user_id(), current_actor_name())
on conflict (employee_id, item_key) do update
set erledigt = excluded.erledigt,
wert = excluded.wert,
kommentar = excluded.kommentar,
updated_at = now(),
updated_by = excluded.updated_by,
updated_by_name = excluded.updated_by_name;
-- Kein Eintrag in employee_history: das ist keine Änderung an der Person,
-- sondern der Stand einer Aufgabe. In der Historie stünden sonst
-- fünfundzwanzig Zeilen zwischen Eintritt und Versetzung. Ins Protokoll
-- gehört es trotzdem — es ist eine Handlung mit Urheber.
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Onboarding-Punkt', v_name, v_employee_id,
v_item_key || ': ' ||
case when v_wert is not null then v_wert
when v_erledigt then 'erledigt'
else 'offen' end ||
coalesce(' — ' || v_kommentar, ''));
end;
$function$;
revoke all on function set_onboarding_task(jsonb) from public;
grant execute on function set_onboarding_task(jsonb) to public;
-- ── Die Liste anlegen ──────────────────────────────────────────────────
--
-- Eine Checkliste entsteht mit dem Eintritt. Dass sie *existiert*, ist die
-- Aussage „diese Person ist im Onboarding" — deshalb wird sie angelegt und
-- nicht bloss beim ersten Klick nebenbei erzeugt: eine leere Liste, auf der
-- noch nichts steht, ist der eigentliche Anfangszustand und muss sichtbar
-- sein.
--
-- Die Punkte kommen aus dem Aufruf, nicht aus der Datenbank: sie stehen in
-- lib/onboarding.ts, und zwei Listen nebeneinander liefen auseinander.
create or replace function start_onboarding(payload jsonb)
returns integer
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_keys jsonb := coalesce(payload->'item_keys', '[]'::jsonb);
v_name text;
v_angelegt integer;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_name is null then
raise exception 'Die Person existiert nicht.';
end if;
if jsonb_array_length(v_keys) = 0 then
raise exception 'Es wurden keine Punkte übergeben.';
end if;
insert into onboarding_tasks (employee_id, item_key, updated_by, updated_by_name)
select v_employee_id, k, app_current_user_id(), current_actor_name()
from jsonb_array_elements_text(v_keys) k
on conflict (employee_id, item_key) do nothing;
get diagnostics v_angelegt = row_count;
if v_angelegt > 0 then
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Onboarding-Checkliste angelegt', v_name, v_employee_id,
v_angelegt || ' Punkte');
end if;
return v_angelegt;
end;
$function$;
revoke all on function start_onboarding(jsonb) from public;
grant execute on function start_onboarding(jsonb) to public;