From 1289b5697b20d852f370c596f0070f21cb5ccb91 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:43:38 +0000 Subject: [PATCH] Terminplaner: Bestaetigung prueft vorher, dass die Zellen der Haltung ihr noch gehoeren bestaetige zieht unter der Buchungssperre fehlende eigene Zellen nach (belegeZellen mit ausser). Hat ein anderer Gast sie uebernommen, wird die Buchung verfallen und es kommt dieselbe 410-Antwort wie bei abgelaufener Haltung, ohne Mails. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/ablauf.js | 34 +++++++++++++++++++++++- test/termineAblaufGast.test.js | 43 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/server/services/termine/ablauf.js b/server/services/termine/ablauf.js index 43318da..e38bdb0 100644 --- a/server/services/termine/ablauf.js +++ b/server/services/termine/ablauf.js @@ -476,12 +476,43 @@ export async function vorschau(token, now) { // ---------------------------------------------------------------- Bestaetigen +/** + * Gehoeren die Zellen der Haltung ihr noch? Ein anderer Gast, dessen Anfrage mit einem spaeteren `now` lief, darf die + * Zellen einer schon abgelaufenen Haltung geloescht und selbst belegt haben (zellen.js: eine abgelaufene Haltung gilt + * als frei), waehrend diese Anfrage noch `heldUntil > now` sieht. Darum wird vor der Bestaetigung (unter der + * Buchungssperre) nachgezogen, was von den eigenen Zellen fehlt (belegeZellen mit ausser = eigene ID). Gelingt das nicht + * (SLOT_VERGEBEN), ist die Zeit weg: Buchung `verfallen` und dieselbe Antwort wie bei einer abgelaufenen Haltung (410), + * ohne Mails. Andere Fehler (z. B. Datenbank) gehen durch, die Buchung bleibt `gehalten`. + * Sperrenfolge termine-buchung -> termine-res (in belegeZellen); in umgekehrter Richtung nimmt niemand beide. + */ +async function sichereZellen(buchung, ctx, now) { + try { + await belegeZellen({ + featureId: buchung.featureId, + ressourceId: buchung.ressourceId, + bookingId: buchung.$id, + start: new Date(zeitMs(buchung.startsAt)), + blockEnde: new Date(zeitMs(buchung.blockEndsAt)), + now, + ausser: buchung.$id, + }) + } catch (err) { + if (err?.code !== 'SLOT_VERGEBEN') throw err + try { + await aendere(buchung.$id, { status: 'verfallen', updatedAt: iso(now) }) + } catch (fehler) { + protokolliere('Haltung nicht als verfallen vermerkt', buchung.$id, fehler) + } + throw abgelaufenMit(410, ctx) + } +} + /** * E-Mail bestaetigt (Klick auf den Knopf der Gaesteseite): `gehalten` mit heldUntil > now wird bei einer sofort * buchbaren Leistung `bestaetigt` (bestaetigtAt, G02 an den Gast mit Einladung, K20 an den Betrieb), bei einer Anfrage * `angefragt` (G03 an den Gast, K20-Block anfrage); in beiden Faellen wird der Hash des Verwaltungstokens gespeichert. * Ein weiterer Klick aendert nichts und meldet `schon`. Die Haltung selbst laesst der Minutenjob verfallen; hier gilt - * nur heldUntil > now. + * nur heldUntil > now, und die Zellen der Haltung muessen ihr noch gehoeren (sichereZellen). * @param {string} token Bestaetigungstoken aus dem Link * @param {Date} now * @returns {Promise<{ status: string, schon?: true }>} @@ -499,6 +530,7 @@ export async function bestaetige(token, now) { if (!frisch) throw unbekannt() if (frisch.status === 'verfallen' || (frisch.status === 'gehalten' && !(zeitMs(frisch.heldUntil) > nowMs))) throw abgelaufenMit(410, ctx) if (frisch.status !== 'gehalten') return { schon: true, status: frisch.status } + await sichereZellen(frisch, ctx, now) const anfrage = frisch.buchungsart === 'anfrage' const buchung = await aendere(id, { status: anfrage ? 'angefragt' : 'bestaetigt', diff --git a/test/termineAblaufGast.test.js b/test/termineAblaufGast.test.js index 68c96e2..d15a2c0 100644 --- a/test/termineAblaufGast.test.js +++ b/test/termineAblaufGast.test.js @@ -693,6 +693,49 @@ test('bestaetige: nach 31 Minuten ist die Reservierung abgelaufen (410 mit Betri assert.deepEqual(await bestaetige(token, plus(JETZT, 29 * MIN)), { status: 'bestaetigt' }) }) +test('bestaetige: hat ein anderer Gast die Zellen einer abgelaufenen Haltung uebernommen, ist die Bestaetigung ein 410 und die Buchung verfallen (keine zwei Bestaetigungen auf einer Zeit)', async () => { + const ctx = await speichere() + const a = await haltenMitToken(ctx, { ressource: R1, email: 'eins@example.com' }) + const ablauf = plus(JETZT, 30 * MIN) // heldUntil von A + // B haelt dieselbe Zeit genau zum Ablauf von A: A gilt fuer B als abgelaufen, B loescht A's Zellen und belegt sie + const b = await haltenMitToken(ctx, { ressource: R1, email: 'zwei@example.com' }, ablauf) + assert.equal(claims(a.buchung.$id).length, 0, 'B hat die Zellen von A uebernommen') + assert.equal(claims(b.buchung.$id).length, 10) + const mailsVorher = sink.sent.length + + // A's Anfrage lief noch eine Millisekunde vor heldUntil: ohne eigene Zellen darf sie nichts bestaetigen + const err = await fehlerVon(() => bestaetige(a.token, new Date(ablauf.getTime() - 1))) + await warteAufVersand() + assert.equal(err.status, 410) + assert.equal(err.code, 'abgelaufen') + assert.deepEqual(err.betrieb, { name: 'Salon Müller', telefon: '06301 1234', websiteUrl: 'https://salon.de/' }) + assert.equal(aw.docs('termineBookings').get(a.buchung.$id).status, 'verfallen') + assert.equal(aw.docs('termineBookings').get(a.buchung.$id).manageHash, undefined) + assert.equal(aw.docs('termineBookings').get(a.buchung.$id).bestaetigtAt, undefined) + assert.equal(claims(a.buchung.$id).length, 0) + assert.equal(claims(b.buchung.$id).length, 10, 'B behaelt alle Zellen') + assert.equal(sink.sent.length, mailsVorher, 'keine Bestaetigungsmails an A und den Betrieb') + assert.equal((await fehlerVon(() => bestaetige(a.token, new Date(ablauf.getTime() - 1)))).status, 410, 'auch ein weiterer Klick') + + // B kann bestaetigen; es gibt genau eine bestaetigte Buchung auf dieser Zeit + assert.deepEqual(await bestaetige(b.token, plus(ablauf, MIN)), { status: 'bestaetigt' }) + await warteAufVersand() + assert.deepEqual(buchungen().map((x) => x.status).sort(), ['bestaetigt', 'verfallen']) + assert.equal(claims(b.buchung.$id).length, 10) +}) + +test('bestaetige: fehlen nur die Zellen der eigenen Haltung (niemand sonst hat sie), werden sie neu angelegt und die Bestaetigung gilt', async () => { + const ctx = await speichere() + const { buchung, token } = await haltenMitToken(ctx) + for (const zelle of claims(buchung.$id)) await deleteDocument('termineClaims', zelle.$id) + assert.equal(claims(buchung.$id).length, 0) + assert.deepEqual(await bestaetige(token, plus(JETZT, 5 * MIN)), { status: 'bestaetigt' }) + await warteAufVersand() + assert.equal(claims(buchung.$id).length, 10) + assert.ok(claims(buchung.$id).every((z) => z.ressourceId === buchung.ressourceId && iso(z.bis) === iso(buchung.blockEndsAt))) + assert.deepEqual(eintraege().map((d) => d.art).sort(), ['G01', 'G02', 'K20']) +}) + test('bestaetige: verfallene Buchungen, unbekannte und fremde Token ergeben 404/410, ein Verwaltungstoken bestaetigt nichts', async () => { const ctx = await speichere() const { buchung, token } = await haltenMitToken(ctx)