import { sql, type Expression, type SqlBool } from "kysely"; import type { Tx } from "./db"; import { jsonArrayFrom, zeitstempel } from "./db/json"; import { fmtName } from "./format"; 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 & { 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 (note_subscriptions). // // 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. * `note_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 { const verfasser = sql.ref(spalte); return sql`( ${verfasser} is null or ${verfasser} = ${userId} or exists ( select 1 from note_subscriptions s where s.user_id = ${userId} and s.author_user_id = ${verfasser}))`; } /** * 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 { const { notes } = await tx .selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb, userId)).as("notes")]) .executeTakeFirstOrThrow(); return baueOffeneNotizen(notes as NotizZeile[], userId); }