Files
alpenwerk-hr/lib/notes.ts
Maximilian Stubhan eeaf210e78
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Let the same choice open notes and drafts
The picker in the bell now governs both lists, so note_subscriptions is
renamed to colleague_subscriptions -- a name that only mentions notes would
mislead the next reader.

Reading and writing a draft now reach differently far. hire_drafts_owner
(for all) is split into four policies: select lets in your own drafts and
those of the people you added, while insert/update/delete stay with the
owner. A draft is unfinished work with no lock and no history; two people
writing into the same row would overwrite each other silently.

That split forces a change in the actions: a policy does not reject a write,
it lets it hit no rows. saveHireDraft and deleteHireDraft now read the row
count instead of reporting success over a row that never changed.

The card shows a foreign draft with its author and without Fortsetzen or
Loeschen -- offering a button that reliably ends in a database error is a
promise without cover.

check-schema-types.mjs learns `alter table ... rename to`; without it the
drift check reports one rename as two errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:05:40 +02:00

130 lines
5.0 KiB
TypeScript

import { sql, type Expression, type SqlBool } from "kysely";
import type { Tx } from "./db";
import { jsonArrayFrom, zeitstempel } from "./db/json";
import { fmtName } from "./format";
import { istHinzugewaehlt } from "./kollegen";
import type { OrgEb } from "./org";
import type { Database } from "./types";
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
employeeName: string;
/**
* Ob die Notiz von der angemeldeten Person stammt.
*
* Hier bestimmt und nicht in der Komponente: die Kennung liegt auf dem
* Server, und sie nur für ein Etikett durch vier Komponenten zu reichen
* wäre ein hoher Preis für ein Wort.
*/
vonMir: boolean;
};
/** Was die Teilabfrage liefert: die Notiz plus den Namen aus dem Join. */
export type NotizZeile = Omit<Database["public"]["Tables"]["employee_notes"]["Row"], "created_at" | "done_at"> & {
created_at: string;
done_at: string | null;
first_name: string | null;
last_name: string | null;
};
// „Meine Notizen" (Topbar-Glocke): die noch nicht erledigten HR-Notizen —
// mitarbeiterübergreifend, unabhängig davon, zu welcher Person sie gehören.
//
// **Wessen** Notizen erscheinen, entscheidet jede Person selbst: die eigenen
// immer, dazu die hinzugewählten Kolleg:innen (colleague_subscriptions) —
// dieselbe Auswahl, die auch die Entwürfe steuert.
//
// Bis September 2026 war es ein gemeinsamer Topf — jede HR-Person sah jede
// Notiz. Der Wunsch, sich standardmässig nur selbst zu sehen, kam aus dem
// Betrieb; die Kehrseite steht in der Migration.
//
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
// direkten Zugang ist es schlicht ein Join.
/**
* Wessen Notizen diese Person sieht.
*
* Die Vorgabe ist die eigene Person. Wer mehr sehen will, wählt Kolleg:innen
* hinzu — die Einstellung öffnet, sie engt nicht ein.
*
* Drei Fälle kommen durch:
*
* 1. **Die eigenen, immer.** Sie sind der Grund, warum es die Glocke gibt.
* `colleague_subscriptions` lässt einen Selbstbezug nicht zu; die Bedingung
* steht hier trotzdem, damit die Zusage nicht davon abhängt, dass eine
* Prüfbedingung an anderer Stelle bestehen bleibt.
*
* 2. **Die hinzugewählten.** Ohne Auswahl bleibt es bei den eigenen — das
* ist die Vorgabe, nicht ein leerer Zustand, der noch zu füllen wäre.
*
* 3. **Notizen ohne Verfasser.** `author_user_id` kann leer sein. Sie
* niemandem zu zeigen, weil sie niemandem gehören, hiesse sie
* verschwinden zu lassen.
*
* Steht als eigener Ausdruck hier und nicht in der jeweiligen Seite, weil
* ihn zwei Stellen brauchen: die Glocke in der Kopfzeile und die Karte
* „Anstehend" auf der Übersicht. Zwei Fassungen derselben Regel driften
* auseinander, und die Übersicht zeigte dann etwas anderes als die Glocke.
*
* Der Vorgabewert für `spalte` setzt voraus, dass die Notiztabelle als `n`
* eingebunden ist — so machen es beide Aufrufer. Wer anders aliasiert, gibt
* den Namen mit.
*/
export function sichtbareNotizen(userId: string, spalte = "n.author_user_id"): Expression<SqlBool> {
const verfasser = sql.ref(spalte);
return sql<SqlBool>`(
${verfasser} is null
or ${verfasser} = ${userId}
or ${istHinzugewaehlt(userId, spalte)})`;
}
/**
* Die offenen Notizen als *Teilabfrage* — zum Einhängen in die eine Abfrage,
* die eine Seite ohnehin stellt (lib/db/json.ts).
*
* Die beiden Zeitstempel gehen durch zeitstempel(): innerhalb von JSON
* formatiert Postgres sie anders als der Treiber es sonst täte, und der
* Unterschied fällt erst beim Vergleichen auf.
*/
export function offeneNotizenAbfrage(eb: OrgEb, userId: string) {
return eb
.selectFrom("employee_notes as n")
.leftJoin("employees as e", "e.id", "n.employee_id")
.select([
"n.id",
"n.employee_id",
"n.author_user_id",
"n.author_name",
"n.category",
"n.note_text",
"n.due_date",
"n.done",
"n.done_by",
"e.first_name",
"e.last_name",
])
.select((x) => [zeitstempel(x.ref("n.created_at")).as("created_at"), zeitstempel(x.ref("n.done_at")).as("done_at")])
.where("n.done", "=", false)
.where(sichtbareNotizen(userId))
.orderBy("n.created_at", "desc");
}
/** Der reine Teil: aus den Zeilen die Notizen mit lesbarem Namen. */
export function baueOffeneNotizen(rows: NotizZeile[], userId: string): OpenNote[] {
return rows.map((row) => {
const { first_name, last_name, ...note } = row;
return {
...(note as Database["public"]["Tables"]["employee_notes"]["Row"]),
employeeName: first_name && last_name ? fmtName(first_name, last_name) : "Unbekannt",
vonMir: row.author_user_id === userId,
};
});
}
export async function loadOpenNotes(tx: Tx, userId: string): Promise<OpenNote[]> {
const { notes } = await tx
.selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb, userId)).as("notes")])
.executeTakeFirstOrThrow();
return baueOffeneNotizen(notes as NotizZeile[], userId);
}