Yesterday's version had it the other way — everyone visible, untick to
hide. The decision from the business side is the opposite: you see your
own notes, and you tick the colleagues you also want. So note_mutes
becomes note_subscriptions and the predicate flips from `not exists` to
`exists`.
The existing rows are not carried over. The meaning inverts rather than
the sign: converting faithfully ("everyone except the muted") would write
almost the whole roster into the new table and reproduce exactly the state
the change is meant to end. Anyone opening the setting tomorrow would
think it had not taken effect. The table is a day old; what is lost is a
few ticks from trying it out.
What this costs is worth saying plainly: the silent case that could not
happen under exceptions can happen now. Do not tick a colleague and you
will not see her follow-ups — not while she is on holiday either. That is
the flip side of the decision, and it is written down in the migration
rather than discovered later.
Each note now says who wrote it. Own notes read "von mir" rather than
repeating your own name, which would sit on every second line and tell
nobody anything. The flag is computed on the server: the user id is
already there, and threading it through four components for one word is a
poor trade. The counter on the button follows the same turn — "+2" for
what you added, nothing when you added nothing.
Verified: 21 tests, five mutation-checked (restoring `not exists`,
dropping the own-notes clause, inverting the default, hiding the author,
and printing your own name instead of "von mir" each turn them red). 489
tests, typecheck, lint, schema drift and build clean. The migration is
reviewed but not run — no reachable database here.
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 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 (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<SqlBool> {
|
|
const verfasser = sql.ref(spalte);
|
|
return sql<SqlBool>`(
|
|
${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<OpenNote[]> {
|
|
const { notes } = await tx
|
|
.selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb, userId)).as("notes")])
|
|
.executeTakeFirstOrThrow();
|
|
return baueOffeneNotizen(notes as NotizZeile[], userId);
|
|
}
|