Seeing a colleague's draft turned out to be half a feature: the point of sharing it is to finish it while they are away. So writing is allowed now -- but never by two people at once. A draft is a single JSONB field. Whoever saves writes the whole state, not the changed field, so two open wizards overwrite each other completely and the second person sees nothing wrong: their own state is right there on screen. That is why writing stayed with the owner until now, and a lock is what makes giving that up safe. The lock lives in the row (locked_by, locked_at) and is enforced by the update and delete policies, not by the application. It expires, and that is the important half: releasing happens when the wizard closes, and a closed laptop never closes a wizard. Without expiry one crashed tab would take a draft away for good -- worse than the problem being solved. The wizard refreshes its lock while open so a long form does not lose it mid-way. Delete had to widen too, which reads like more than was asked for: the wizard deletes the draft once the person is hired. Without it the hire would go through and the draft would sit there forever. The card still only offers delete on your own drafts. Four of five mutations against the lock go red. The fifth -- dropping `!open` from the refresh guard -- does not, because freigeben() already nulls the ref the interval checks. The condition stays as the readable statement of intent, now with a comment saying so. Not run against a live database here; the CI migration job is the first real execution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
140 lines
5.2 KiB
TypeScript
140 lines
5.2 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { currentUserId, requireUserId } from "@/lib/auth/session";
|
|
import { withUser } from "@/lib/db";
|
|
import type { ActionResult } from "@/lib/db/rpc";
|
|
|
|
/**
|
|
* Den Entwurf für mich belegen — beim Öffnen und danach im Takt.
|
|
*
|
|
* Die Regel hire_drafts_update lässt die Zeile nur durch, wenn die Sperre
|
|
* frei, meine oder abgelaufen ist. Trifft das Schreiben keine Zeile, hat sie
|
|
* jemand anderes offen; das ist kein Fehler, sondern die Auskunft.
|
|
*
|
|
* Dieselbe Funktion frischt die Sperre auf: sie schreibt locked_at neu,
|
|
* solange sie mir gehört. Der Assistent ruft sie darum im Takt, sonst liefe
|
|
* die Frist einem langen Ausfüllen davon.
|
|
*
|
|
* updated_at bleibt unberührt. Eine Sperre ist keine Änderung am Entwurf —
|
|
* bewegte sie den Zeitstempel, sortierte sich die Liste um und „Gespeichert
|
|
* am" logge.
|
|
*/
|
|
export async function entwurfSperren(id: string): Promise<ActionResult> {
|
|
const userId = await requireUserId();
|
|
try {
|
|
const ergebnis = await withUser(userId, (tx) =>
|
|
tx
|
|
.updateTable("hire_drafts")
|
|
.set({ locked_by: userId, locked_at: new Date().toISOString() })
|
|
.where("id", "=", id)
|
|
.executeTakeFirst()
|
|
);
|
|
if (ergebnis.numUpdatedRows === 0n) {
|
|
return { success: false, error: "Dieser Entwurf wird gerade von jemand anderem bearbeitet." };
|
|
}
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
|
}
|
|
revalidatePath("/");
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Die Sperre zurückgeben.
|
|
*
|
|
* Die Bedingung auf locked_by steht hier und nicht nur in der Regel: die
|
|
* Regel liesse auch das Freigeben einer *abgelaufenen fremden* Sperre zu,
|
|
* und das wäre ein stiller Griff in die Arbeit von jemandem, der gerade
|
|
* wieder aufgefrischt hat.
|
|
*
|
|
* Ein Scheitern bleibt ohne Meldung: die Frist räumt die Sperre ohnehin ab,
|
|
* und es gäbe nichts, was die Person daraufhin tun könnte.
|
|
*/
|
|
export async function entwurfFreigeben(id: string): Promise<ActionResult> {
|
|
const userId = await requireUserId();
|
|
try {
|
|
await withUser(userId, (tx) =>
|
|
tx
|
|
.updateTable("hire_drafts")
|
|
.set({ locked_by: null, locked_at: null })
|
|
.where("id", "=", id)
|
|
.where("locked_by", "=", userId)
|
|
.execute()
|
|
);
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
|
}
|
|
revalidatePath("/");
|
|
return { success: true };
|
|
}
|
|
|
|
export async function saveHireDraft(payload: {
|
|
id?: string;
|
|
step: number;
|
|
data: Record<string, unknown>;
|
|
}): Promise<ActionResult & { id?: string }> {
|
|
const userId = await currentUserId();
|
|
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
|
|
|
try {
|
|
const id = await withUser(userId, async (tx) => {
|
|
if (payload.id) {
|
|
// Wer schreiben darf, entscheidet die Regel hire_drafts_update:
|
|
// der Entwurf muss meiner oder von einer hinzugewählten Person sein,
|
|
// **und** die Sperre muss mir gehören.
|
|
//
|
|
// Die Zahl der geänderten Zeilen wird trotzdem gelesen: 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 — der ärgerlichste Fall überhaupt, weil die
|
|
// Person den Assistenten daraufhin beruhigt zumacht.
|
|
const ergebnis = await tx
|
|
.updateTable("hire_drafts")
|
|
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
|
.where("id", "=", payload.id)
|
|
.executeTakeFirst();
|
|
if (ergebnis.numUpdatedRows === 0n) {
|
|
throw new Error(
|
|
"Der Entwurf liess sich nicht speichern: er wird gerade von jemand anderem bearbeitet oder wurde inzwischen gelöscht."
|
|
);
|
|
}
|
|
return payload.id;
|
|
}
|
|
|
|
const row = await tx
|
|
.insertInto("hire_drafts")
|
|
.values({ created_by: userId, step: payload.step, payload: payload.data })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
return row.id;
|
|
});
|
|
|
|
revalidatePath("/");
|
|
return { success: true, id };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
|
}
|
|
}
|
|
|
|
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
|
try {
|
|
// Auch hier die Zeilenzahl: die Regel hire_drafts_delete lässt ein
|
|
// Löschen gegen eine fremde Sperre 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 wird gerade von jemand anderem bearbeitet oder war schon weg."
|
|
);
|
|
}
|
|
});
|
|
revalidatePath("/");
|
|
return { success: true };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
|
}
|
|
}
|