Two reports, four defects, all of them in the way of ordinary use.
A position with a signed starter is not vacant. loadOpenPositions asked "is
anyone on it today?", so three positions whose new holders begin in
September and October were listed as open, labelled "vacant for 2 days".
That same list feeds the hire wizard, so it invited filling a position a
second time — discovered at the partial unique index, after the second
interview. Vacancy now means no assignment that still stands, including one
that has not started. An assignment that ended still frees the position.
Hiring was broken three times over, each fault hidden behind the previous
one:
1. hire_employee cast to ::weekday[], a type that no longer exists — it
was replaced by text plus a CHECK constraint and the function was never
updated. apply_due_pending_changes had the same problem with
::relationship_type, which would have broken the nightly run.
PL/pgSQL resolves types in embedded statements at execution time, so
both functions were created without complaint and failed only in use.
2. Fourteen functions called auth.uid(). The application connects as a
role with no rights on the auth schema, so every write — hire,
transfer, promote, exit, notes, positions — failed with "permission
denied for schema auth". They now use app_current_user_id(), which is
where #23 was heading anyway. Its own fallback also caught only
"function missing" and now catches the privilege error too, so a call
without session context returns null instead of raising.
3. The audit line built a name as `payload->>'a' || ' ' || payload->>'b'`.
`||` binds tighter than `->>`, so Postgres reads
`payload ->> ('a' || ' ' || payload) ->> 'b'`. The ACL failure above
had aborted analysis before the parser ever reached it.
And the wizard collected an email, showed it in the summary, and dropped it:
the server action's signature had no such field. employees.email is NOT
NULL, so every hire that got past the three faults above would have failed
there. It is now passed through and required in step one, rather than
refused by the database at the end of step four.
Verified against the live database, each rolled back: a hire now creates the
employee, the assignment, the history entry and an audit line reading "Probe
Einstellung"; open positions drop from 13 to 10, and the three that
disappear are exactly the ones with a starter.
Migrations rewrite the affected functions in place rather than restating
them — retyping 165 lines of working PL/pgSQL to change two words is the
larger risk. Each one asserts the result afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
import type { Tx } from "./db";
|
|
import { todayIso } from "./format";
|
|
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
|
|
|
|
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
|
|
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
|
|
// zweites Objekt daneben, das mit der Organisation synchron gehalten werden
|
|
// müsste.
|
|
|
|
export type OpenPositionResolved = {
|
|
id: string;
|
|
position_number: string;
|
|
title: string;
|
|
org_unit_id: string;
|
|
is_chief: boolean;
|
|
valid_from: string;
|
|
/** Wer die Stelle nach der Berichtslinie führen wird. */
|
|
managerName: string | null;
|
|
orgLabel: string;
|
|
/** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */
|
|
vacantSince: string;
|
|
};
|
|
|
|
/**
|
|
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
|
|
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
|
|
* Regel wie in om_reporting_lines(), nur ohne Inhaber:in, für die sie gälte.
|
|
*/
|
|
function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): string | null {
|
|
if (!isChief) return orgUnitId;
|
|
return maps.units.get(orgUnitId)?.parent_id ?? null;
|
|
}
|
|
|
|
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
|
|
const asOf = todayIso();
|
|
|
|
const [orgMaps, open] = await Promise.all([
|
|
loadOrgMaps(tx),
|
|
// 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")
|
|
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "j.title"])
|
|
.where("p.valid_from", "<=", asOf)
|
|
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
|
.where((eb) =>
|
|
eb.not(
|
|
eb.exists(
|
|
eb
|
|
.selectFrom("position_assignments as a")
|
|
.select("a.id")
|
|
.whereRef("a.position_id", "=", "p.id")
|
|
.where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)]))
|
|
)
|
|
)
|
|
)
|
|
.orderBy("p.position_number")
|
|
.execute(),
|
|
]);
|
|
|
|
if (open.length === 0) return [];
|
|
|
|
const positionIds = open.map((p) => p.id);
|
|
|
|
// Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen
|
|
// würde.
|
|
const [ended, chiefs] = await Promise.all([
|
|
tx
|
|
.selectFrom("position_assignments")
|
|
.select(["position_id", "valid_to"])
|
|
.where("position_id", "in", positionIds)
|
|
.where("valid_to", "is not", null)
|
|
.execute(),
|
|
(async () => {
|
|
const chiefUnitIds = Array.from(
|
|
new Set(
|
|
open
|
|
.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief))
|
|
.filter((id): id is string => Boolean(id))
|
|
)
|
|
);
|
|
if (chiefUnitIds.length === 0) return [];
|
|
return tx
|
|
.selectFrom("om_positions as p")
|
|
.innerJoin("position_assignments as a", "a.position_id", "p.id")
|
|
.innerJoin("employees as e", "e.id", "a.employee_id")
|
|
.select(["p.org_unit_id", "e.first_name", "e.last_name"])
|
|
.where("p.is_chief", "=", true)
|
|
.where("p.valid_to", "is", null)
|
|
.where("a.valid_to", "is", null)
|
|
.where("p.org_unit_id", "in", chiefUnitIds)
|
|
.execute();
|
|
})(),
|
|
]);
|
|
|
|
const lastEndByPosition = new Map<string, string>();
|
|
for (const e of ended) {
|
|
const prev = lastEndByPosition.get(e.position_id);
|
|
if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to);
|
|
}
|
|
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`]));
|
|
|
|
return open.map((p) => {
|
|
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
|
|
return {
|
|
id: p.id,
|
|
position_number: p.position_number,
|
|
title: p.title,
|
|
org_unit_id: p.org_unit_id,
|
|
is_chief: p.is_chief,
|
|
valid_from: p.valid_from,
|
|
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
|
|
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
|
|
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,
|
|
};
|
|
});
|
|
}
|