Let the same choice open notes and drafts
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s

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>
This commit is contained in:
2026-09-10 16:05:40 +02:00
parent e8e675fd07
commit eeaf210e78
17 changed files with 650 additions and 54 deletions

View File

@@ -16,13 +16,22 @@ export async function saveHireDraft(payload: {
try {
const id = await withUser(userId, async (tx) => {
if (payload.id) {
// Ob die Zeile der aufrufenden Person gehört, entscheidet die
// Policy hire_drafts_owner — nicht eine Prüfung hier.
await tx
// Ob die Zeile der aufrufenden Person gehört, entscheidet die Regel
// hire_drafts_update — nicht eine Prüfung hier.
//
// Die Zahl der geänderten Zeilen wird trotzdem gelesen, und zwar
// seit fremde Entwürfe sichtbar sind: eine Regel weist ein UPDATE
// nicht mit einem Fehler ab, sie lässt es ins Leere laufen. Ohne
// diese Prüfung meldete die Anwendung „gespeichert", und gespeichert
// wäre nichts.
const ergebnis = await tx
.updateTable("hire_drafts")
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
.where("id", "=", payload.id)
.execute();
.executeTakeFirst();
if (ergebnis.numUpdatedRows === 0n) {
throw new Error("Der Entwurf liess sich nicht speichern: er gehört jemand anderem oder wurde inzwischen gelöscht.");
}
return payload.id;
}
@@ -43,7 +52,15 @@ export async function saveHireDraft(payload: {
export async function deleteHireDraft(id: string): Promise<ActionResult> {
try {
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
// Auch hier die Zeilenzahl: die Regel hire_drafts_delete lässt ein
// fremdes Löschen leer laufen statt es abzuweisen. „Entwurf gelöscht"
// über einem Entwurf, der noch dasteht, wäre die schlechtere Auskunft.
await withUser(await currentUserId(), async (tx) => {
const ergebnis = await tx.deleteFrom("hire_drafts").where("id", "=", id).executeTakeFirst();
if (ergebnis.numDeletedRows === 0n) {
throw new Error("Der Entwurf liess sich nicht löschen: er gehört jemand anderem oder war schon weg.");
}
});
revalidatePath("/");
return { success: true };
} catch (err) {