Files
alpenwerk-hr/lib/supabase/types.ts
Maximilian Stubhan 6297288c13 Let an entry be taken back, along with what it did
HR can now delete a history entry, but only where deleting one is an
honest thing to do — and deleting it also undoes it.

The rule they asked for is the interesting part: the last valid change
wins. Deleting an entry walks its fields one at a time. If a later entry
touched the same field, the current value stays — that later change is
the one in force. Otherwise the field goes back to what the deleted
entry recorded as its "before". So the middle of three entries can be
removed without an old value overwriting a newer one.

Four kinds of entry refuse to be deleted, each saying why in the place
the button would have been. Eintritt anchors the timeline. Transfers,
promotions, absences and exits moved positions and status — they have
proper operations for that, and guessing backwards is how you corrupt an
org chart. Anything not yet effective hangs off a planned change, and
that link is not trustworthy: there is no key between a history row and
its pending row, only a person and a date, and the data already has an
Eintritt and a Vertragsänderung sharing one. Matching on the date would
eventually cancel a change nobody meant. And entries from before the
history carried values have nothing to fall back to.

Confirmation is not "are you sure" — that question gets a reflex yes by
the third time. The dialog says what will be different afterwards: which
field goes back to which value, and which one stays because something
later claimed it.

employee_history keeps its append-only policies; delete_history_entry is
SECURITY DEFINER and checks the permission itself in its first line. The
audit log keeps the deletion with the values that were removed, and the
audit log genuinely cannot be edited.

The rule lives twice — in SQL and in lib/history.ts. The database is the
authority; the copy exists so the UI can hide a button that would fail
and print the reason instead. Rehearsed against real data in a
rolled-back transaction first: the later change held, the untouched
field reverted, all four refusals fired.

Also corrected in the data catalogue: I had written that
require_hr_admin was called by nothing. It guards all sixteen mutating
functions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:56:48 +02:00

529 lines
19 KiB
TypeScript

// Hand-written to match supabase/schema.sql + supabase/migrations/*.sql (no DB
// connection string available to run `supabase gen types typescript` in this
// environment — regenerate from the live project once you have the Supabase
// CLI linked).
export type EmploymentStatus = "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten";
export type EmploymentType = "Vollzeit" | "Teilzeit";
export type ContractType = "unbefristet" | "befristet";
export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F";
export type SourceType = "Intern" | "Extern";
export type GenderType = "m" | "w";
export type WorkerType = "Angestellte:r" | "Arbeiter:in";
export type CollectiveAgreement = "Handel" | "Süßwaren";
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
/**
* Antriebsart des Dienstwagens.
*
* Bewusst keine dritte Möglichkeit „unbekannt": die Angabe hängt per CHECK
* an has_dienstwagen, und wer einen Dienstwagen hat, weiss auch, ob er lädt
* oder tankt.
*/
export type DienstwagenArt = "Verbrenner" | "Elektro";
/**
* Verhältnis zum Notfallkontakt.
*
* Auswahlliste statt Freitext, damit sich danach auswerten lässt und nicht
* „Gattin", „Ehefrau" und „Frau" nebeneinander stehen. „Sonstige" ist der
* Ausweg für alles, was hier fehlt — ohne ihn wäre die Liste eine Anmassung.
*
* In der Datenbank bleibt die Spalte `text`: eine Aufzählung dort würde jede
* Ergänzung zu einer Migration machen, und diese Liste wird sich ändern.
*/
export const EMERGENCY_RELATIONS = [
"Gattin/Gatte",
"Lebensgefährtin/Lebensgefährte",
"Mutter",
"Vater",
"Tochter",
"Sohn",
"Schwester",
"Bruder",
"Freundin/Freund",
"Sonstige",
] as const;
export type EmergencyRelation = (typeof EMERGENCY_RELATIONS)[number];
/**
* Eine einzelne Feldänderung im Protokoll.
*
* `vorher` und `nachher` sind bewusst Text: die Datenbank stellt jeden Typ
* über ::text dar, damit ein Datum, eine Zahl und eine Liste von Arbeitstagen
* in derselben Spalte nebeneinander stehen können. Für die Anzeige reicht
* das; gerechnet wird damit nicht.
*/
export type AuditChange = { feld: string; vorher: string | null; nachher: string | null };
export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige";
export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung";
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
// union (not a string literal) so a future hr_admin/hr_user split, if ever
// technically required, is a type-level addition, not a rewrite.
export type ProfileRole = "hr";
/** Etikett einer Organisationseinheit; die Struktur steckt in parent_id. */
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
export type HistoryEventType =
| "Eintritt"
| "Beförderung"
| "Versetzung"
| "Karenz"
| "Vertragsänderung"
| "Stammdatenänderung"
| "Austritt"
| "Wiedereintritt"
| "Reorganisation"
| "Gehaltsanpassung"
| "Rückkehr";
export type PendingChangeType =
| "transfer"
| "promotion"
| "karenz_start"
| "karenz_return"
| "contract_change"
| "reorg";
export type PendingChangeStatus = "pending" | "applied" | "cancelled";
// @supabase/postgrest-js requires every table/view to carry a Relationships
// array (used for typed embedded selects) — left empty since no code in this
// app relies on nested/embedded resource selects.
type NoRelationships = { Relationships: [] };
export type Database = {
public: {
Tables: {
locations: NoRelationships & {
Row: { id: string; name: string; country: string };
Insert: { id?: string; name: string; country: string };
Update: Partial<{ id: string; name: string; country: string }>;
};
profiles: NoRelationships & {
Row: {
id: string;
email: string;
full_name: string | null;
role: ProfileRole;
is_active: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id: string;
email: string;
full_name?: string | null;
role?: ProfileRole;
is_active?: boolean;
created_by?: string | null;
created_at?: string;
updated_at?: string;
};
Update: Partial<{
id: string;
email: string;
full_name: string | null;
role: ProfileRole;
is_active: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
}>;
};
employees: NoRelationships & {
Row: {
id: string;
personnel_number: number;
first_name: string;
last_name: string;
gender: GenderType;
birth_date: string;
sv_nummer: string | null;
nationality: string;
address: string | null;
postal_code: string | null;
city: string | null;
address_country: string | null;
/** Private Adresse, freiwillig — eindeutig, wenn angegeben. */
email: string | null;
phone: string | null;
job_title: string;
location_id: string;
employment_type: EmploymentType;
weekly_hours: number;
/** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */
monthly_salary_gross: number | null;
contract_type: ContractType;
contract_end_date: string | null;
paygrade: PaygradeType;
source: SourceType;
status: EmploymentStatus;
entry_date: string;
exit_date: string | null;
exit_reason: string | null;
karenz_start_date: string | null;
karenz_return_date: string | null;
absence_type: string | null;
avatar_color: string | null;
worker_type: WorkerType;
collective_agreement: CollectiveAgreement;
work_days: Weekday[];
is_betriebsrat: boolean;
has_dienstwagen: boolean;
/** Null genau dann, wenn kein Dienstwagen vorhanden ist — chk_dienstwagen_art. */
dienstwagen_art: DienstwagenArt | null;
/** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */
emergency_contact_name: string | null;
emergency_contact_phone: string | null;
emergency_contact_relation: string | null;
is_laterale_fuehrung: boolean;
is_c_level: boolean;
title_prefix: string[];
title_suffix: string[];
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
first_name: string;
last_name: string;
gender: GenderType;
birth_date: string;
sv_nummer?: string | null;
nationality?: string;
address?: string | null;
postal_code?: string | null;
city?: string | null;
address_country?: string | null;
email?: string | null;
phone?: string | null;
job_title: string;
location_id: string;
employment_type?: EmploymentType;
weekly_hours?: number;
contract_type?: ContractType;
contract_end_date?: string | null;
paygrade?: PaygradeType;
source?: SourceType;
status?: EmploymentStatus;
entry_date: string;
exit_date?: string | null;
exit_reason?: string | null;
karenz_start_date?: string | null;
karenz_return_date?: string | null;
absence_type?: string | null;
avatar_color?: string | null;
worker_type?: WorkerType;
collective_agreement?: CollectiveAgreement;
work_days?: Weekday[];
is_betriebsrat?: boolean;
has_dienstwagen?: boolean;
dienstwagen_art?: DienstwagenArt | null;
emergency_contact_name?: string | null;
emergency_contact_phone?: string | null;
emergency_contact_relation?: string | null;
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
title_prefix?: string[];
title_suffix?: string[];
created_at?: string;
updated_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employees"]["Insert"]>;
};
employee_history: NoRelationships & {
Row: {
id: string;
employee_id: string;
event_date: string;
event_type: HistoryEventType;
description: string;
/** Feldweise Änderungen — dieselbe Form wie audit_log.changes. */
changes: AuditChange[] | null;
created_at: string;
};
Insert: {
id?: string;
employee_id: string;
event_date: string;
event_type: HistoryEventType;
description: string;
changes?: AuditChange[] | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
};
employee_dependents: NoRelationships & {
Row: {
id: string;
employee_id: string;
first_name: string;
last_name: string;
relationship: RelationshipType;
sv_nummer: string | null;
birth_date: string;
created_at: string;
};
Insert: {
id?: string;
employee_id: string;
first_name: string;
last_name: string;
relationship: RelationshipType;
sv_nummer?: string | null;
birth_date: string;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_dependents"]["Insert"]>;
};
employee_notes: NoRelationships & {
Row: {
id: string;
employee_id: string;
author_user_id: string | null;
author_name: string;
category: NoteCategory;
note_text: string;
due_date: string | null;
done: boolean;
done_at: string | null;
done_by: string | null;
created_at: string;
};
Insert: {
id?: string;
employee_id: string;
author_user_id?: string | null;
author_name: string;
category?: NoteCategory;
note_text: string;
due_date?: string | null;
done?: boolean;
done_at?: string | null;
done_by?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>;
};
hire_drafts: NoRelationships & {
Row: { id: string; created_by: string | null; step: number; payload: Record<string, unknown>; updated_at: string };
Insert: { id?: string; created_by?: string | null; step?: number; payload: Record<string, unknown>; updated_at?: string };
Update: Partial<Database["public"]["Tables"]["hire_drafts"]["Insert"]>;
};
saved_reports: NoRelationships & {
Row: { id: string; created_by: string | null; name: string; config: Record<string, unknown>; created_at: string };
Insert: { id?: string; created_by?: string | null; name: string; config: Record<string, unknown>; created_at?: string };
Update: Partial<Database["public"]["Tables"]["saved_reports"]["Insert"]>;
};
audit_log: NoRelationships & {
Row: {
id: string;
occurred_at: string;
actor_user_id: string | null;
actor_name: string;
action: string;
target_label: string;
target_employee_id: string | null;
details: string | null;
/**
* Feldweise Änderungen. Null bei Einträgen aus der Zeit vor
* 20260803140000_audit_changes_detail.sql — dort wurden nur die
* Feldnamen behalten, nicht die Werte, und das lässt sich nicht
* nachliefern.
*/
changes: AuditChange[] | null;
};
Insert: {
id?: string;
occurred_at?: string;
actor_user_id?: string | null;
actor_name: string;
action: string;
target_label: string;
target_employee_id?: string | null;
details?: string | null;
changes?: AuditChange[] | null;
};
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
};
pending_org_changes: NoRelationships & {
Row: {
id: string;
employee_id: string;
change_type: PendingChangeType;
effective_date: string;
payload: Record<string, unknown>;
status: PendingChangeStatus;
created_by: string | null;
created_at: string;
applied_at: string | null;
};
Insert: {
id?: string;
employee_id: string;
change_type: PendingChangeType;
effective_date: string;
payload: Record<string, unknown>;
status?: PendingChangeStatus;
created_by?: string | null;
created_at?: string;
applied_at?: string | null;
};
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
};
// ── SAP-OM-Modell ──────────────────────────────────────────
// O: rekursiv über parent_id, unit_type ist nur ein Etikett.
org_units: {
Relationships: [
{
foreignKeyName: "org_units_parent_id_fkey";
columns: ["parent_id"];
referencedRelation: "org_units";
referencedColumns: ["id"];
},
];
Row: {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: OrgUnitType;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: {
id?: string;
org_number: string;
name: string;
parent_id?: string | null;
unit_type: OrgUnitType;
valid_from?: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["org_units"]["Insert"]>;
};
// C: Katalog der Tätigkeiten.
jobs: NoRelationships & {
Row: { id: string; code: string; title: string; created_at: string };
Insert: { id?: string; code: string; title: string; created_at?: string };
Update: Partial<Database["public"]["Tables"]["jobs"]["Insert"]>;
};
// S: Planstelle. Der Name om_positions stammt aus der Zeit, in der die
// alte positions-Tabelle noch danebenstand; sie ist inzwischen weg.
om_positions: {
Relationships: [
{
foreignKeyName: "om_positions_org_unit_id_fkey";
columns: ["org_unit_id"];
referencedRelation: "org_units";
referencedColumns: ["id"];
},
{
foreignKeyName: "om_positions_job_id_fkey";
columns: ["job_id"];
referencedRelation: "jobs";
referencedColumns: ["id"];
},
];
Row: {
id: string;
position_number: string;
org_unit_id: string;
job_id: string;
is_chief: boolean;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: {
id?: string;
position_number: string;
org_unit_id: string;
job_id: string;
is_chief?: boolean;
valid_from?: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>;
};
// A008: Person besetzt Planstelle, zeitabhängig.
position_assignments: {
Row: {
id: string;
position_id: string;
employee_id: string;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: {
id?: string;
position_id: string;
employee_id: string;
valid_from: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["position_assignments"]["Insert"]>;
// Beide Richtungen: über die Planstelle hängt die Verortung in der
// Organisation, über die Person die Verortung in der Akte. Die
// Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage.
Relationships: [
{
foreignKeyName: "position_assignments_position_id_fkey";
columns: ["position_id"];
referencedRelation: "om_positions";
referencedColumns: ["id"];
},
{
foreignKeyName: "position_assignments_employee_id_fkey";
columns: ["employee_id"];
referencedRelation: "employees";
referencedColumns: ["id"];
},
];
};
};
Views: Record<string, never>;
Functions: {
hire_employee: { Args: { payload: Record<string, unknown> }; Returns: string };
terminate_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
transfer_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
promote_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
start_karenz: { Args: { payload: Record<string, unknown> }; Returns: void };
adjust_karenz_return: { Args: { payload: Record<string, unknown> }; Returns: void };
record_karenz_return: { Args: { payload: Record<string, unknown> }; Returns: void };
change_employee_data: { Args: { payload: Record<string, unknown> }; Returns: void };
rehire_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
// Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige
// Weg an der fehlenden delete-Policy auf employee_history vorbei.
delete_history_entry: { Args: { payload: Record<string, unknown> }; Returns: void };
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
om_reporting_lines: {
Args: { p_as_of?: string };
Returns: {
employee_id: string;
position_id: string;
org_unit_id: string;
is_chief: boolean;
formal_manager_id: string | null;
acting_manager_id: string | null;
}[];
};
};
};
};