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>
130 lines
5.0 KiB
TypeScript
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);
|
|
}
|