Let colleagues finish each other's drafts, one at a time
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>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { entwurfFreigeben, entwurfSperren } from "@/actions/hireDrafts";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Entwurf } from "@/lib/entwuerfe";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { HireWizard } from "./HireWizard";
|
||||
|
||||
type Location = { id: string; name: string; country: string };
|
||||
type HireDraft = { id: string; step: number; payload: Record<string, unknown> };
|
||||
|
||||
type OpenWizardOptions = { draftId?: string; positionId?: string };
|
||||
|
||||
@@ -15,6 +18,16 @@ type HireWizardContextValue = {
|
||||
|
||||
const HireWizardContext = createContext<HireWizardContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Wie oft die Sperre aufgefrischt wird, solange der Assistent offen ist.
|
||||
*
|
||||
* Deutlich kürzer als die Frist in der Datenbank (15 Minuten,
|
||||
* app_entwurf_sperrfrist): zwischen zwei Takten darf eine Antwort ausfallen,
|
||||
* ohne dass die Sperre wegläuft. Wer eine halbe Stunde an einem Entwurf
|
||||
* sitzt, soll ihn nicht auf halbem Weg an eine Kollegin verlieren.
|
||||
*/
|
||||
const TAKT_MS = 5 * 60 * 1000;
|
||||
|
||||
export function HireWizardProvider({
|
||||
children,
|
||||
openPositions,
|
||||
@@ -24,10 +37,12 @@ export function HireWizardProvider({
|
||||
children: ReactNode;
|
||||
openPositions: OpenPositionResolved[];
|
||||
locations: Location[];
|
||||
drafts: HireDraft[];
|
||||
drafts: Entwurf[];
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [resumeDraft, setResumeDraft] = useState<HireDraft | null>(null);
|
||||
const [resumeDraft, setResumeDraft] = useState<Entwurf | null>(null);
|
||||
const [initialPositionId, setInitialPositionId] = useState<string | undefined>(undefined);
|
||||
// Forces HireWizard to remount fresh each time it's opened, so its
|
||||
// internal draft/step state is (re-)initialized directly from the current
|
||||
@@ -35,20 +50,73 @@ export function HireWizardProvider({
|
||||
// effect needed inside HireWizard itself.
|
||||
const [openKey, setOpenKey] = useState(0);
|
||||
|
||||
function openWizard(options?: OpenWizardOptions) {
|
||||
// Die Kennung des Entwurfs, dessen Sperre wir halten. Als Ref, weil sie im
|
||||
// Aufräumen des Effekts gebraucht wird — über den Zustand gelesen wäre es
|
||||
// dort der Stand von vorhin.
|
||||
const gesperrt = useRef<string | null>(null);
|
||||
|
||||
async function openWizard(options?: OpenWizardOptions) {
|
||||
// Ohne Entwurf gibt es nichts zu sperren: eine neue Einstellung entsteht
|
||||
// erst beim Speichern.
|
||||
if (options?.draftId) {
|
||||
const result = await entwurfSperren(options.draftId);
|
||||
if (!result.success) {
|
||||
showToast(result.error ?? "Der Entwurf lässt sich gerade nicht öffnen.", "error");
|
||||
// Die Liste holt sich den Stand: wer die Sperre hält, steht danach
|
||||
// an der Zeile, statt dass nur eine Meldung aufblitzt.
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
gesperrt.current = options.draftId;
|
||||
}
|
||||
setResumeDraft(options?.draftId ? (drafts.find((d) => d.id === options.draftId) ?? null) : null);
|
||||
setInitialPositionId(options?.positionId);
|
||||
setOpen(true);
|
||||
setOpenKey((k) => k + 1);
|
||||
}
|
||||
|
||||
function closeWizard() {
|
||||
setOpen(false);
|
||||
freigeben();
|
||||
}
|
||||
|
||||
function freigeben() {
|
||||
const id = gesperrt.current;
|
||||
if (!id) return;
|
||||
gesperrt.current = null;
|
||||
void entwurfFreigeben(id);
|
||||
}
|
||||
|
||||
// Auffrischen, solange der Assistent offen ist. Ohne das liefe die Frist
|
||||
// einem langen Ausfüllen davon, und das Speichern am Ende fände seine
|
||||
// eigene Sperre abgelaufen.
|
||||
//
|
||||
// `!open` ist doppelt gemoppelt: freigeben() räumt beim Zumachen auch
|
||||
// gesperrt.current weg, und daran scheitert der Takt ohnehin. Es steht
|
||||
// trotzdem da, weil es die Bedingung ausspricht, um die es geht — dass
|
||||
// nach dem Zumachen nichts mehr aufgefrischt wird, hängt sonst an einer
|
||||
// Zuweisung drei Funktionen weiter oben.
|
||||
useEffect(() => {
|
||||
if (!open || !gesperrt.current) return;
|
||||
const timer = setInterval(() => {
|
||||
if (gesperrt.current) void entwurfSperren(gesperrt.current);
|
||||
}, TAKT_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [open, openKey]);
|
||||
|
||||
// Beim Verlassen der Seite: dieselbe Freigabe wie beim Schliessen. Sie
|
||||
// erreicht den Server nicht immer — ein zugeklappter Laptop schickt nichts
|
||||
// mehr. Deshalb ist sie die Höflichkeit und nicht die Absicherung; die
|
||||
// Absicherung ist die Frist in der Datenbank.
|
||||
useEffect(() => () => freigeben(), []);
|
||||
|
||||
return (
|
||||
<HireWizardContext.Provider value={{ openWizard }}>
|
||||
{children}
|
||||
<HireWizard
|
||||
key={openKey}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={closeWizard}
|
||||
openPositions={openPositions}
|
||||
locations={locations}
|
||||
resumeDraft={resumeDraft}
|
||||
|
||||
Reference in New Issue
Block a user