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)