diff --git a/actions/employees.ts b/actions/employees.ts index 3034921..3687f25 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -21,6 +21,15 @@ export async function hireEmployee(payload: { gender: "m" | "w"; birth_date: string; sv_nummer?: string; + /** + * Pflicht, weil employees.email NOT NULL ist. + * + * Der Assistent hat die Adresse immer erhoben und in der Zusammenfassung + * angezeigt — sie fehlte nur in dieser Signatur und wurde deshalb + * stillschweigend verworfen. Jede Einstellung scheiterte danach an der + * Spaltenbedingung. + */ + email: string; phone?: string; position_id?: string; team_id?: string; diff --git a/components/hire/HireWizard.tsx b/components/hire/HireWizard.tsx index d79aa21..be73ae5 100644 --- a/components/hire/HireWizard.tsx +++ b/components/hire/HireWizard.tsx @@ -58,7 +58,11 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf isValidSvnr(draft.svNummer, draft.birthDate || null); const stepValid = [ - Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk, + // E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne + // die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten + // Schritts, nach allen Eingaben. + Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId && draft.email.trim()) && + svNummerOk, Boolean(draft.positionId && draft.besetzung), Boolean(draft.entryDate && draft.workDays.length > 0), true, @@ -86,6 +90,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf gender: draft.gender, birth_date: draft.birthDate, sv_nummer: draft.svNummer || undefined, + email: draft.email.trim(), phone: draft.phone || undefined, position_id: draft.positionId, location_id: draft.locationId, diff --git a/components/hire/StepPerson.tsx b/components/hire/StepPerson.tsx index 66e0f72..44d3a02 100644 --- a/components/hire/StepPerson.tsx +++ b/components/hire/StepPerson.tsx @@ -37,7 +37,7 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) { birthDate={draft.birthDate || null} />
- update({ email })} /> + update({ email })} /> update({ phone })} />
const [orgMaps, open] = await Promise.all([ loadOrgMaps(tx), - // Unbesetzt heisst: keine am Stichtag laufende Zuordnung. Als NOT EXISTS - // in der Datenbank statt als Filter über alle Planstellen im Speicher. + // Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die + // erst beginnt.** + // + // Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am + // 24.09. anfängt, belegt die Planstelle heute schon; sie steht nur noch + // nicht besetzt da. Die frühere Fassung fragte „sitzt heute jemand + // darauf?" und listete solche Stellen als offen — mit „seit 2 Tagen + // unbesetzt" daneben. Aus dieser Liste speist sich auch die Auswahl im + // Einstellungsassistenten, also lud sie dazu ein, dieselbe Stelle ein + // zweites Mal zu besetzen. Aufgefallen wäre das erst am Teilindex der + // Datenbank, nach dem Gespräch mit der zweiten Person. + // + // Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle + // dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to. + // + // Als NOT EXISTS in der Datenbank statt als Filter über alle Planstellen + // im Speicher. tx .selectFrom("om_positions as p") .innerJoin("jobs as j", "j.id", "p.job_id") @@ -51,7 +66,6 @@ export async function loadOpenPositions(tx: Tx): Promise .selectFrom("position_assignments as a") .select("a.id") .whereRef("a.position_id", "=", "p.id") - .where("a.valid_from", "<=", asOf) .where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)])) ) ) diff --git a/supabase/migrations/20260805100000_fix_stale_type_casts.sql b/supabase/migrations/20260805100000_fix_stale_type_casts.sql new file mode 100644 index 0000000..fe259b1 --- /dev/null +++ b/supabase/migrations/20260805100000_fix_stale_type_casts.sql @@ -0,0 +1,84 @@ +-- Zwei Funktionen casten auf Typen, die es nicht mehr gibt. +-- +-- hire_employee ::weekday[] +-- apply_due_pending_changes ::relationship_type +-- +-- Beide Typen waren einmal Aufzählungen und wurden später durch `text` mit +-- einer CHECK-Bedingung ersetzt (chk_work_days_valid). Die Funktionen wurden +-- dabei nicht nachgezogen. +-- +-- PL/pgSQL löst Typen in eingebetteten SQL-Anweisungen erst beim **Ausführen** +-- auf. Die Funktionen liessen sich deshalb anlegen und scheitern erst im +-- Betrieb — jede Neueinstellung mit „type weekday[] does not exist", und der +-- nächtliche Lauf, sobald eine fällige Angehörigen-Änderung darin vorkommt. +-- Genau deshalb hat es niemand beim Einspielen gemerkt. +-- +-- Ersetzt wird gezielt der Cast an der **aktuellen** Definition, statt beide +-- Funktionen abzuschreiben: 165 Zeilen fremden Code neu zu tippen, um zwei +-- Wörter zu ändern, ist die grössere Fehlerquelle. + +create or replace function app_cast_ersetzen(p_funktion text, p_alt text, p_neu text) +returns void +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_def text; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = p_funktion + limit 1; + + if v_def is null then + raise exception 'Funktion %() nicht gefunden.', p_funktion; + end if; + + if position(p_alt in v_def) = 0 then + raise notice '%(): % kommt nicht (mehr) vor — nichts zu tun.', p_funktion, p_alt; + return; + end if; + + execute replace(v_def, p_alt, p_neu); + raise notice '%(): % -> %', p_funktion, p_alt, p_neu; +end; +$$; + +select app_cast_ersetzen('hire_employee', '::weekday[]', '::text[]'); +select app_cast_ersetzen('apply_due_pending_changes', '::relationship_type', '::text'); + +-- Das Werkzeug wird nicht aufbewahrt: eine Funktion, die beliebigen Text in +-- eine Funktionsdefinition schreibt und ausführt, soll nicht dauerhaft im +-- Schema stehen. +drop function app_cast_ersetzen(text, text, text); + +-- ═══ Gegenprobe ══════════════════════════════════════════════════ +-- Keine Funktion im Schema darf mehr auf einen Typ casten, den es nicht +-- gibt. Das prüft nicht nur die zwei bekannten Stellen, sondern schliesst +-- aus, dass beim Ersetzen eine dritte übersehen wurde. +do $$ +declare + r record; + v_treffer text; +begin + for r in + select p.proname, pg_get_functiondef(p.oid) as def + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.prokind = 'f' + loop + for v_treffer in + select m[1] from regexp_matches(r.def, '::\s*([a-z_][a-z0-9_]*)\s*(?:\[\])?', 'gi') m + loop + if not exists (select 1 from pg_type where typname = lower(v_treffer)) + and lower(v_treffer) not in ( + 'text', 'int', 'integer', 'boolean', 'bool', 'date', 'uuid', 'jsonb', 'json', + 'numeric', 'timestamptz', 'varchar', 'bigint', 'smallint', 'real', 'interval', 'time' + ) then + raise exception 'Funktion %() castet auf unbekannten Typ „%".', r.proname, v_treffer; + end if; + end loop; + end loop; +end; +$$; diff --git a/supabase/migrations/20260805110000_replace_auth_uid_in_functions.sql b/supabase/migrations/20260805110000_replace_auth_uid_in_functions.sql new file mode 100644 index 0000000..7b3372b --- /dev/null +++ b/supabase/migrations/20260805110000_replace_auth_uid_in_functions.sql @@ -0,0 +1,113 @@ +-- auth.uid() aus den Geschäftsfunktionen entfernen. +-- +-- Die Anwendung verbindet sich als eigene Rolle ohne BYPASSRLS. Diese Rolle +-- hat kein Recht auf das Schema `auth` — und jede Funktion, die dort etwas +-- aufruft, scheitert mit „permission denied for schema auth". +-- +-- Das trifft **jede schreibende Aktion**: Einstellen, Versetzen, Befördern, +-- Austritt, Notizen, Planstellen. Aufgefallen ist es beim Nachstellen einer +-- Neueinstellung; zuvor lief alles über eine Rolle, die das Schema sehen +-- durfte. +-- +-- app_current_user_id() liefert dasselbe und funktioniert auf jedem +-- PostgreSQL — es liest den transaktionslokalen Sitzungskontext, den lib/db +-- setzt. + +create or replace function app_uid_ersetzen(p_funktion text) +returns void +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_def text; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = p_funktion + limit 1; + + if v_def is null then + raise exception 'Funktion %() nicht gefunden.', p_funktion; + end if; + if position('auth.uid()' in v_def) = 0 then + raise notice '%(): bereits umgestellt.', p_funktion; + return; + end if; + + execute replace(v_def, 'auth.uid()', 'app_current_user_id()'); + raise notice '%(): umgestellt.', p_funktion; +end; +$$; + +select app_uid_ersetzen('add_employee_dependent'); +select app_uid_ersetzen('add_employee_note'); +select app_uid_ersetzen('adjust_karenz_return'); +select app_uid_ersetzen('complete_employee_note'); +select app_uid_ersetzen('create_position'); +select app_uid_ersetzen('delete_employee_dependent'); +select app_uid_ersetzen('delete_position'); +select app_uid_ersetzen('hire_employee'); +select app_uid_ersetzen('promote_employee'); +select app_uid_ersetzen('record_karenz_return'); +select app_uid_ersetzen('rehire_employee'); +select app_uid_ersetzen('start_karenz'); +select app_uid_ersetzen('terminate_employee'); +select app_uid_ersetzen('transfer_employee'); + +drop function app_uid_ersetzen(text); + +-- ═══ Den Rückfall selbst absichern ═══════════════════════════════ +-- app_current_user_id() behält seinen Übergangszweig auf auth.uid(), fängt +-- aber bisher nur „Funktion fehlt". Für eine Rolle ohne Recht auf das Schema +-- kommt stattdessen insufficient_privilege — und die Funktion warf, statt +-- null zu liefern. Das fiel nicht auf, weil der Zweig nur ohne +-- Sitzungskontext erreicht wird; genau dann soll aber „niemand angemeldet" +-- herauskommen und kein Fehler. +create or replace function app_current_user_id() +returns uuid +language plpgsql +stable +security definer +set search_path = public, pg_temp +as $$ +declare + v_id uuid; +begin + v_id := nullif(current_setting('app.user_id', true), '')::uuid; + if v_id is not null then + return v_id; + end if; + + begin + execute 'select auth.uid()' into v_id; + exception + when undefined_function or invalid_schema_name or undefined_table or insufficient_privilege then + v_id := null; + end; + return v_id; +end; +$$; + +-- ═══ Gegenprobe ══════════════════════════════════════════════════ +do $$ +declare r record; +begin + for r in + select p.proname + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.prokind = 'f' + and p.proname <> 'app_current_user_id' + and pg_get_functiondef(p.oid) like '%auth.uid()%' + loop + raise exception 'Funktion %() ruft weiterhin auth.uid() auf.', r.proname; + end loop; + + -- Ohne Kontext muss die Kennung null sein und darf nicht werfen. + perform set_config('app.user_id', '', true); + if app_current_user_id() is not null then + raise exception 'app_current_user_id() liefert ohne Kontext eine Kennung.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260805120000_fix_hire_employee_precedence.sql b/supabase/migrations/20260805120000_fix_hire_employee_precedence.sql new file mode 100644 index 0000000..1453e29 --- /dev/null +++ b/supabase/migrations/20260805120000_fix_hire_employee_precedence.sql @@ -0,0 +1,66 @@ +-- Klammern in hire_employee. +-- +-- Der Protokolleintrag baute den Namen so: +-- +-- payload->>'first_name' || ' ' || payload->>'last_name' +-- +-- In PostgreSQL bindet `||` **stärker** als `->>`. Gelesen wird also +-- +-- payload ->> ('first_name' || ' ' || payload) ->> 'last_name' +-- +-- und das endet in „operator does not exist: text ->> unknown". Jede +-- Neueinstellung scheiterte daran. +-- +-- Warum es niemandem auffiel: davor stand in derselben Anweisung ein Aufruf +-- von auth.uid(). Die Rechteprüfung auf das Schema `auth` schlug schon +-- während der Analyse fehl, und der Parser kam nie bis zu diesem Ausdruck. +-- Ein Fehler hat den anderen verdeckt — beide mussten weg, damit eine +-- Einstellung durchläuft. + +do $$ +declare + v_def text; + v_alt constant text := 'payload->>''first_name'' || '' '' || payload->>''last_name'''; + v_neu constant text := '(payload->>''first_name'') || '' '' || (payload->>''last_name'')'; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'hire_employee' + limit 1; + + if v_def is null then + raise exception 'hire_employee() nicht gefunden.'; + end if; + + if position(v_alt in v_def) = 0 then + raise notice 'hire_employee(): bereits geklammert.'; + else + execute replace(v_def, v_alt, v_neu); + raise notice 'hire_employee(): geklammert.'; + end if; +end; +$$; + +-- ═══ Gegenprobe ══════════════════════════════════════════════════ +-- Der Ausdruck selbst, in beiden Lesarten. Ohne Klammern wirft er; mit +-- Klammern kommt der Name heraus. Das hält die Regel fest, damit sie beim +-- nächsten Mal nicht neu entdeckt werden muss. +do $$ +declare + v_payload jsonb := '{"first_name": "Anna", "last_name": "Berger"}'::jsonb; + v_name text; +begin + v_name := (v_payload->>'first_name') || ' ' || (v_payload->>'last_name'); + if v_name <> 'Anna Berger' then + raise exception 'Geklammert ergibt „%" statt „Anna Berger".', v_name; + end if; + + begin + execute $probe$ select ('{"a":"x"}'::jsonb)->>'a' || ' ' || ('{"a":"x"}'::jsonb)->>'a' $probe$; + raise exception 'Ungeklammert wirft nicht mehr — die Vorrangregel hat sich geändert, die Prüfung ist wertlos geworden.'; + exception + when undefined_function then null; -- erwartet: text ->> unknown + end; +end; +$$;