- R55: Widerrufsbelehrung, Anschrift-Pflicht und Knopf "Vertrag widerrufen" gelten für jede Verbraucher-Buchung mit sichtbarer Leistung, auch Anfragen (zeigtWiderruf); der Nachweis hält die Belehrung auch bei Anfragen fest. Damit bleibt die Annahme einer Anfrage nie mehr still ohne G02. - "Gastdaten löschen" leert auch absageGrund und löscht noch nicht versandte Mails der Buchung (neu: verwerfeWartende in der Warteschlange). - R53: GET /:siteKey/config nennt formMinSekunden. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
452 lines
22 KiB
JavaScript
452 lines
22 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
|
import { withLocks } from '../keyedLock.js'
|
|
import { aendere, hole, legeAn, loesche } from './buchungen.js'
|
|
import { text as bereinigterText } from './einstellungen.js'
|
|
import { anGast } from './mails.js'
|
|
import { verwerfeWartende } from './outbox.js'
|
|
import {
|
|
MELDUNG,
|
|
VERWALTBAR,
|
|
fehlerMit,
|
|
fehlerSammler,
|
|
gibFreiSicher,
|
|
iso,
|
|
istObjekt,
|
|
mailSicher,
|
|
protokolliere,
|
|
pruefeEingabe,
|
|
pruefeSlot,
|
|
pruefeVerschiebung,
|
|
verschiebeKern,
|
|
zeitVergeben,
|
|
} from './ablauf.js'
|
|
import { blockEnde } from './slots.js'
|
|
import { buchungsNummer, hashToken, verwaltungsToken } from './token.js'
|
|
import { pruefeNow, zeitMs } from './util.js'
|
|
import { datumLang, uhrzeit, zuUtc } from './zeit.js'
|
|
import { belegeZellen } from './zellen.js'
|
|
|
|
/**
|
|
* Betrieb im Portal (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 4, "Betrieb im Portal"): Aktionen auf
|
|
* Buchungen, Termine von Hand und Sperren. Gleiche Regeln wie ablauf.js: Zustandsaenderungen unter
|
|
* `termine-buchung:<id>` nach frischem Lesen, `termine-res:` wird hier nie selbst gehalten, Mails gehen erst NACH der
|
|
* Aenderung und ausserhalb der Sperre in die Warteschlange (Fehler nur im Log), `now` ist immer ein Parameter.
|
|
*
|
|
* Der Betrieb handelt auf eigenen Wunsch, darum: keine K21-Mail an den Betrieb, keine Absagefrist, beim Verschieben kein
|
|
* Vorlauf und kein Horizont (ohneRegeln; nie in die Vergangenheit, nie auf belegte Zellen), keine Zustimmung zum
|
|
* vorzeitigen Beginn. Mails an den Gast gehen nur, wenn die Buchung eine Adresse hat (Handeintrag ohne Adresse,
|
|
* anonymisiert). Wer nie eine Einladung bekam (Status `angefragt`), bekommt bei Absage und Verschiebung keine Kalenderdatei (R21).
|
|
*
|
|
* Fehler: Error mit `status` und `fehler` = { <feld|_>: Text }, Texte in Du-Form (Portal). Unbekannte Buchung, Buchung einer
|
|
* anderen Funktion oder eine Sperre bei `aktion` 404 (code 'unbekannt'); nicht (mehr) moegliche Aktion 409 (code
|
|
* 'nicht_moeglich'); eine schon erfolgte Aktion (zweiter Klick) aendert nichts und meldet `schon: true`.
|
|
*/
|
|
|
|
const MIN_MS = 60 * 1000
|
|
const AKTIONEN = ['annehmen', 'ablehnen', 'absagen', 'verschieben', 'nichterschienen', 'anonymisieren']
|
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
|
const BIS_RE = /^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/
|
|
const VON_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/
|
|
const GRUND_MAX = 100
|
|
const NACHRICHT_MAX = 500
|
|
const TITEL_MAX = 120
|
|
/** Laenge einer Belegung: hoechstens 100 Zellen zu 5 Minuten (ein Bulk-Create). */
|
|
const BELEGUNG_MAX_MIN = 500
|
|
const GELOESCHT = 'Gelöscht'
|
|
const SPERRE_TITEL = 'Gesperrt'
|
|
|
|
const MELDUNG_BETRIEB = {
|
|
unbekannt: 'Diesen Termin gibt es nicht.',
|
|
keineSperre: 'Diese Sperre gibt es nicht.',
|
|
aktion: 'Bitte eine Aktion auswählen.',
|
|
grund: 'Bitte einen Grund auswählen.',
|
|
grundLang: `Der Grund darf höchstens ${GRUND_MAX} Zeichen lang sein.`,
|
|
nichtOffen: 'Diese Anfrage ist nicht mehr offen.',
|
|
nichtAbsagbar: 'Dieser Termin ist nicht mehr offen. Er kann nicht abgesagt oder verschoben werden.',
|
|
vergangen: 'Der Termin liegt in der Vergangenheit. Lehne die Anfrage ab.',
|
|
vorBeginn: 'Das geht erst, wenn der Termin begonnen hat.',
|
|
nichtMarkierbar: 'Dieser Termin kann nicht als „nicht erschienen“ markiert werden.',
|
|
nochOffen: 'Dieser Termin ist noch nicht bestätigt.',
|
|
zuLang: `Dieser Termin ist länger als ${BELEGUNG_MAX_MIN} Minuten.`,
|
|
schritt: 'Bitte eine Uhrzeit in 5-Minuten-Schritten angeben (z. B. 09:00).',
|
|
endeVorBeginn: 'Das Ende muss nach dem Beginn liegen.',
|
|
sperreLang: 'Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.',
|
|
sperreVorbei: 'Die Sperre muss in der Zukunft enden.',
|
|
sperreBelegt: 'In dieser Zeit gibt es schon einen Termin oder eine Sperre.',
|
|
titelLang: `Der Titel darf höchstens ${TITEL_MAX} Zeichen lang sein.`,
|
|
}
|
|
|
|
const nichtGefunden = (text = MELDUNG_BETRIEB.unbekannt) => fehlerMit(404, { _: text }, { code: 'unbekannt' })
|
|
const nichtMoeglich = (text) => fehlerMit(409, { _: text }, { code: 'nicht_moeglich' })
|
|
|
|
/** Kontext wie in ablauf.js (halte): Betriebsangaben und Funktion muessen da sein. */
|
|
function pruefeKontext(ctx, name) {
|
|
if (!ctx?.einstellung?.betrieb || typeof ctx.feature?.$id !== 'string') throw new TypeError(`${name}: ctx fehlt`)
|
|
}
|
|
|
|
const hatAdresse = (buchung) => typeof buchung?.gastEmail === 'string' && buchung.gastEmail.trim() !== ''
|
|
|
|
/** Mail an den Gast, falls die Buchung eine Adresse hat; ein Fehler macht die Aenderung nicht rueckgaengig (mailSicher). */
|
|
async function mailAnGast(art, ctx, buchung, extra, now) {
|
|
if (!hatAdresse(buchung)) return
|
|
await mailSicher(art, buchung.$id, () => anGast(art, ctx, buchung, extra, now))
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Aktionen auf Buchungen
|
|
|
|
/** Prueft die Eingabe der Aktion; => { aktion, grund?, nachricht?, tag?, zeit? }, sonst 400 mit Feldfehlern. */
|
|
function pruefeAktion(roh) {
|
|
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
|
|
const art = roh.aktion
|
|
if (typeof art !== 'string' || !AKTIONEN.includes(art)) throw fehlerMit(400, { aktion: MELDUNG_BETRIEB.aktion })
|
|
if (art === 'verschieben') return { aktion: art, ...pruefeVerschiebung(roh) }
|
|
|
|
const { fehler, fehl } = fehlerSammler()
|
|
const eingabe = { aktion: art }
|
|
if (art === 'absagen') {
|
|
eingabe.grund = bereinigterText(roh.grund, GRUND_MAX + 1)
|
|
if (eingabe.grund === '') fehl('grund', MELDUNG_BETRIEB.grund)
|
|
else if ([...eingabe.grund].length > GRUND_MAX) fehl('grund', MELDUNG_BETRIEB.grundLang)
|
|
}
|
|
if (art === 'absagen' || art === 'ablehnen') {
|
|
eingabe.nachricht = bereinigterText(roh.nachricht, NACHRICHT_MAX + 1, { mehrzeilig: true })
|
|
if ([...eingabe.nachricht].length > NACHRICHT_MAX) fehl('nachricht', MELDUNG.nachrichtLang)
|
|
}
|
|
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
|
|
return eingabe
|
|
}
|
|
|
|
/** Anfrage annehmen: `angefragt` => `bestaetigt` mit angenommenAt (Beginn der Widerrufsfrist), G02 mit Einladung. */
|
|
async function annehmen(ctx, frisch, _eingabe, now) {
|
|
if (frisch.status === 'bestaetigt' && frisch.angenommenAt) return { antwort: { status: 'bestaetigt', schon: true } }
|
|
if (frisch.status !== 'angefragt') throw nichtMoeglich(MELDUNG_BETRIEB.nichtOffen)
|
|
if (!(zeitMs(frisch.startsAt) > now.getTime())) throw fehlerMit(409, { _: MELDUNG_BETRIEB.vergangen }, { code: 'vergangen' })
|
|
const buchung = await aendere(frisch.$id, { status: 'bestaetigt', angenommenAt: iso(now), updatedAt: iso(now) })
|
|
return { antwort: { status: 'bestaetigt' }, mail: () => mailAnGast('G02', ctx, buchung, { anfrageAngenommen: true }, now) }
|
|
}
|
|
|
|
/** Anfrage ablehnen: `abgelehnt`, Zellen frei, G04 (mit der Nachricht des Betriebs, falls angegeben). */
|
|
async function ablehnen(ctx, frisch, eingabe, now) {
|
|
if (frisch.status === 'abgelehnt') return { antwort: { status: 'abgelehnt', schon: true } }
|
|
if (frisch.status !== 'angefragt') throw nichtMoeglich(MELDUNG_BETRIEB.nichtOffen)
|
|
const buchung = await aendere(frisch.$id, { status: 'abgelehnt', updatedAt: iso(now) })
|
|
await gibFreiSicher(frisch.$id)
|
|
return { antwort: { status: 'abgelehnt' }, mail: () => mailAnGast('G04', ctx, buchung, { nachricht: eingabe.nachricht }, now) }
|
|
}
|
|
|
|
/** Absage durch den Betrieb: `abgesagt` von `betrieb` mit Grund, Zellen frei, G06 (Kalenderabsage nur nach einer Einladung). */
|
|
async function absagen(ctx, frisch, eingabe, now) {
|
|
if (frisch.status === 'abgesagt') return { antwort: { status: 'abgesagt', schon: true } }
|
|
if (!VERWALTBAR.includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtAbsagbar)
|
|
const buchung = await aendere(frisch.$id, {
|
|
status: 'abgesagt',
|
|
abgesagtAt: iso(now),
|
|
abgesagtVon: 'betrieb',
|
|
absageGrund: eingabe.grund,
|
|
updatedAt: iso(now),
|
|
})
|
|
await gibFreiSicher(frisch.$id)
|
|
const extra = { von: 'betrieb', grund: eingabe.grund, nachricht: eingabe.nachricht, ohneIcs: frisch.status === 'angefragt' }
|
|
return { antwort: { status: 'abgesagt' }, mail: () => mailAnGast('G06', ctx, buchung, extra, now) }
|
|
}
|
|
|
|
/** Verschieben durch den Betrieb: derselbe Kern wie beim Gast (verschiebeKern), aber ohne Frist, Vorlauf und Horizont. G07 an den Gast. */
|
|
async function verschieben(ctx, frisch, eingabe, now) {
|
|
if (!VERWALTBAR.includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtAbsagbar)
|
|
const { alt, buchung, start } = await verschiebeKern(ctx, frisch, eingabe, now, { betrieb: true })
|
|
const extra = { altStart: alt.startsAt, ohneIcs: alt.status === 'angefragt' }
|
|
return {
|
|
antwort: { status: buchung.status, termin: { datum: datumLang(start), zeit: uhrzeit(start) } },
|
|
mail: () => mailAnGast('G07', ctx, buchung, extra, now),
|
|
}
|
|
}
|
|
|
|
/** Gast nicht erschienen: nur nach Beginn des Termins, aus `bestaetigt` (oder schon `erledigt`). */
|
|
async function nichterschienen(_ctx, frisch, _eingabe, now) {
|
|
if (frisch.status === 'nichterschienen') return { antwort: { status: 'nichterschienen', schon: true } }
|
|
if (!['bestaetigt', 'erledigt'].includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtMarkierbar)
|
|
if (now.getTime() < zeitMs(frisch.startsAt)) throw fehlerMit(409, { _: MELDUNG_BETRIEB.vorBeginn }, { code: 'vor_beginn' })
|
|
await aendere(frisch.$id, { status: 'nichterschienen', updatedAt: iso(now) })
|
|
await gibFreiSicher(frisch.$id)
|
|
return { antwort: { status: 'nichterschienen' } }
|
|
}
|
|
|
|
/**
|
|
* Gastdaten auf Wunsch loeschen: Name "Gelöscht", E-Mail, Telefon, Nachricht und Zusatzfelder leer, keine Erinnerung,
|
|
* Links ungueltig (Hashes leer), absageGrund leer (er kann den vom Gast getippten Namen einer Widerrufserklaerung oder
|
|
* einen frei getippten Grund des Betriebs enthalten). Noch nicht versandte Mails der Buchung (Outbox, Status wartet)
|
|
* werden geloescht, sie halten Adresse und Inhalt; zuerst, damit ein Fehler dort noch nichts geaendert hat und ein
|
|
* zweiter Klick alles nachholt. Status und Zeiten bleiben. Bei abgesagt/abgelehnt bleibt updatedAt stehen, weil davon
|
|
* die Loeschfrist (30 Tage) laeuft. Eine noch nicht bestaetigte Haltung bleibt unberuehrt (409).
|
|
*/
|
|
async function anonymisieren(_ctx, frisch, _eingabe, now) {
|
|
if (frisch.status === 'gehalten') throw nichtMoeglich(MELDUNG_BETRIEB.nochOffen)
|
|
const behalteFrist = frisch.status === 'abgesagt' || frisch.status === 'abgelehnt'
|
|
await verwerfeWartende(frisch.$id)
|
|
const buchung = await aendere(frisch.$id, {
|
|
gastName: GELOESCHT,
|
|
gastEmail: '',
|
|
gastTelefon: '',
|
|
gastNachricht: '',
|
|
gastZusatz: '',
|
|
absageGrund: '',
|
|
erinnerung: false,
|
|
verifyHash: '',
|
|
manageHash: '',
|
|
updatedAt: iso(behalteFrist && frisch.updatedAt ? frisch.updatedAt : now),
|
|
})
|
|
return { antwort: { status: buchung.status } }
|
|
}
|
|
|
|
const AKTION = { annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren }
|
|
|
|
/**
|
|
* Aktion des Betriebs auf eine Buchung der Funktion.
|
|
* - annehmen: `angefragt` => `bestaetigt`, angenommenAt; G02 (Block anfrage_angenommen) mit Einladung (REQUEST, Sequenz
|
|
* der Buchung) und Verwaltungslink. Nicht fuer eine Anfrage, deren Beginn vorbei ist (409 'vergangen').
|
|
* - ablehnen: `angefragt` => `abgelehnt`, Zellen frei, G04 (optional `nachricht`).
|
|
* - absagen: `bestaetigt`/`angefragt` => `abgesagt` von `betrieb`, Pflicht `grund`, optional `nachricht`, Zellen frei,
|
|
* G06 (absage_von = Betriebsname; ohne Kalenderabsage fuer eine Anfrage). Keine Absagefrist.
|
|
* - verschieben: `{ tag, zeit }`, wie beim Gast, aber ohne Frist, Vorlauf, Horizont und Zustimmungspruefung; G07.
|
|
* - nichterschienen: erst ab Beginn (409 'vor_beginn').
|
|
* - anonymisieren: Gastfelder leeren, Status und Zeiten bleiben.
|
|
* Kein K21 an den Betrieb: er hat es selbst getan.
|
|
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
|
|
* @param {string} bookingId
|
|
* @param {{ aktion: string, grund?: string, nachricht?: string, tag?: string, zeit?: string }} roh
|
|
* @param {Date} now
|
|
* @returns {Promise<{ status: string, schon?: true, termin?: { datum: string, zeit: string } }>}
|
|
* @throws Error 400 (Eingabe; beim Verschieben auch dieselbe Zeit), 404 (unbekannt), 409 ('nicht_moeglich', 'vergangen',
|
|
* 'vor_beginn'; beim Verschieben auch 'SLOT_VERGEBEN' Feld zeit)
|
|
*/
|
|
export async function aktion(ctx, bookingId, roh, now) {
|
|
pruefeNow(now, 'aktion')
|
|
pruefeKontext(ctx, 'aktion')
|
|
const eingabe = pruefeAktion(roh)
|
|
if (typeof bookingId !== 'string' || !ID_RE.test(bookingId)) throw nichtGefunden()
|
|
|
|
const ergebnis = await withLocks([`termine-buchung:${bookingId}`], async () => {
|
|
const frisch = await hole(bookingId)
|
|
if (!frisch || frisch.featureId !== ctx.feature.$id || frisch.quelle === 'sperre') throw nichtGefunden()
|
|
return AKTION[eingabe.aktion](ctx, frisch, eingabe, now)
|
|
})
|
|
if (ergebnis.mail) await ergebnis.mail()
|
|
return ergebnis.antwort
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Termine von Hand
|
|
|
|
/** Eine gescheiterte Neuanlage (Zellen nicht belegbar) wieder entfernen; scheitert das, wird sie als verfallen vermerkt. */
|
|
async function nimmZurueck(bookingId, now) {
|
|
try {
|
|
await withLocks([`termine-buchung:${bookingId}`], () => loesche(bookingId))
|
|
} catch (err) {
|
|
protokolliere('Buchung nicht entfernt', bookingId, err)
|
|
try {
|
|
await withLocks([`termine-buchung:${bookingId}`], () => aendere(bookingId, { status: 'verfallen', heldUntil: iso(now), updatedAt: iso(now) }))
|
|
} catch (fehler) {
|
|
protokolliere('Buchung nicht als verfallen vermerkt', bookingId, fehler)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Legt eine bestaetigte Buchung (Handeintrag oder Sperre) und danach ihre Zellen an. Reihenfolge wie beim Halten
|
|
* (zellen.js): die Buchung steht VOR den Zellen in der Datenbank; scheitert das Belegen, wird sie wieder entfernt.
|
|
* @param {() => Error} belegt erzeugt den Fehler fuer SLOT_VERGEBEN (409); andere Fehler gehen unveraendert durch
|
|
*/
|
|
async function legeBelegungAn(ctx, id, daten, ressourceId, start, block, now, belegt) {
|
|
const buchung = await legeAn(daten, id)
|
|
try {
|
|
await belegeZellen({ featureId: ctx.feature.$id, ressourceId, bookingId: id, start, blockEnde: block, now })
|
|
} catch (err) {
|
|
await nimmZurueck(id, now)
|
|
throw err?.code === 'SLOT_VERGEBEN' ? belegt() : err
|
|
}
|
|
return buchung
|
|
}
|
|
|
|
/**
|
|
* Termin von Hand eintragen (Telefon, Laufkundschaft): `quelle 'manuell'`, sofort `bestaetigt`, `verbraucher false`
|
|
* (kein Widerruf, keine Belehrung durch das System: bei telefonischen Buchungen Sache des Betriebs). Pflicht ist nur der
|
|
* Name; E-Mail, Telefon, Nachricht und Zusatzfelder sind freiwillig, egal was gastFelder fuer Gaeste vorgibt, werden aber,
|
|
* wenn angegeben, wie beim Gast geprueft. Leistung, Person oder "egal", Tag und Uhrzeit wie beim Gast, ohne Zustimmungen
|
|
* und Erinnerung. Die Zeit muss frei sein (freie Zeiten der Leistung), nur Vorlauf und Horizont gelten nicht (ohneRegeln;
|
|
* nie die Vergangenheit, nie belegte Zellen). G02 (Einladung, Verwaltungslink, ohne Widerrufsbelehrung) geht nur mit
|
|
* `bestaetigungSenden: true` an den Gast; dann ist die E-Mail-Adresse Pflicht (400 Feld email). Kein K20: der Betrieb weiss es.
|
|
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
|
|
* @param {object} roh { leistung, ressource, tag, zeit, name, email?, telefon?, nachricht?, zusatz?, bestaetigungSenden? }
|
|
* @param {Date} now
|
|
* @returns {Promise<{ id: string, nummer: string, status: 'bestaetigt' }>}
|
|
* @throws Error 400 (Feldfehler) oder 409 code SLOT_VERGEBEN (Feld zeit)
|
|
*/
|
|
export async function manuell(ctx, roh, now) {
|
|
pruefeNow(now, 'manuell')
|
|
pruefeKontext(ctx, 'manuell')
|
|
const e = ctx.einstellung
|
|
const senden = roh?.bestaetigungSenden === true
|
|
const g = pruefeEingabe(e, roh, now, { manuell: true, bestaetigung: senden })
|
|
if (roh.bestaetigungSenden !== undefined && roh.bestaetigungSenden !== null && typeof roh.bestaetigungSenden !== 'boolean') {
|
|
throw fehlerMit(400, { bestaetigungSenden: MELDUNG.ungueltig })
|
|
}
|
|
|
|
const { start, ressourceId } = await pruefeSlot(ctx, g, now, { ohneRegeln: true })
|
|
const block = blockEnde(start, g.leistung)
|
|
if ((block.getTime() - start.getTime()) / MIN_MS > BELEGUNG_MAX_MIN) throw fehlerMit(400, { leistung: MELDUNG_BETRIEB.zuLang })
|
|
|
|
const id = randomUUID()
|
|
const buchung = await legeBelegungAn(
|
|
ctx,
|
|
id,
|
|
{
|
|
featureId: ctx.feature.$id,
|
|
customerId: ctx.feature.customerId,
|
|
projectId: ctx.feature.projectId,
|
|
leistungId: g.leistung.id,
|
|
leistungName: g.leistung.name,
|
|
ressourceId,
|
|
ressourceName: e.ressourcen.find((r) => r.id === ressourceId)?.name ?? '',
|
|
startsAt: iso(start),
|
|
endsAt: iso(new Date(start.getTime() + g.leistung.dauer * MIN_MS)),
|
|
blockEndsAt: iso(block),
|
|
status: 'bestaetigt',
|
|
quelle: 'manuell',
|
|
gastName: g.name,
|
|
gastEmail: g.email,
|
|
gastTelefon: g.telefon,
|
|
gastNachricht: g.nachricht,
|
|
gastZusatz: Object.keys(g.zusatz).length > 0 ? JSON.stringify(g.zusatz) : '',
|
|
erinnerung: false,
|
|
// der Verwaltungslink der Bestaetigung (und spaeterer Mails) findet die Buchung nur ueber diesen Hash
|
|
manageHash: hashToken(verwaltungsToken(id)),
|
|
bestaetigtAt: iso(now),
|
|
preisCent: g.leistung.preisCent,
|
|
preisArt: g.leistung.preisArt,
|
|
buchungsart: g.leistung.buchungsart,
|
|
verbraucher: false,
|
|
zahlart: 'vorort',
|
|
zahlStatus: 'keine',
|
|
icsSequenz: 0,
|
|
createdAt: iso(now),
|
|
updatedAt: iso(now),
|
|
},
|
|
ressourceId,
|
|
start,
|
|
block,
|
|
now,
|
|
zeitVergeben,
|
|
)
|
|
if (senden) await mailAnGast('G02', ctx, buchung, {}, now)
|
|
return { id, nummer: buchungsNummer(id), status: 'bestaetigt' }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Sperren
|
|
|
|
/** Prueft die Eingabe einer Sperre; => { ressource, tag, start, ende, titel }, sonst 400 mit Feldfehlern. */
|
|
function pruefeSperre(e, roh, now) {
|
|
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
|
|
const { fehler, fehl } = fehlerSammler()
|
|
|
|
const ressource = typeof roh.ressource === 'string' ? (e.ressourcen || []).find((r) => r.id === roh.ressource) : undefined
|
|
if (!ressource) fehl('ressource', MELDUNG.ressource)
|
|
const tag = typeof roh.tag === 'string' && zuUtc(roh.tag, '12:00') !== null ? roh.tag : null
|
|
if (tag === null) fehl('tag', MELDUNG.tag)
|
|
|
|
const vonOk = typeof roh.von === 'string' && VON_RE.test(roh.von)
|
|
const bisOk = typeof roh.bis === 'string' && BIS_RE.test(roh.bis)
|
|
if (!vonOk) fehl('von', MELDUNG.zeit)
|
|
else if (Number(roh.von.slice(3)) % 5 !== 0) fehl('von', MELDUNG_BETRIEB.schritt)
|
|
if (!bisOk) fehl('bis', MELDUNG.zeit)
|
|
else if (Number(roh.bis.slice(3)) % 5 !== 0) fehl('bis', MELDUNG_BETRIEB.schritt)
|
|
|
|
let start = null
|
|
let ende = null
|
|
if (tag !== null && !Object.hasOwn(fehler, 'von')) {
|
|
start = zuUtc(tag, roh.von)
|
|
if (start === null) fehl('von', MELDUNG.zeit) // Luecke der Maerz-Umstellung
|
|
}
|
|
if (tag !== null && !Object.hasOwn(fehler, 'bis')) {
|
|
ende = zuUtc(tag, roh.bis)
|
|
if (ende === null) fehl('bis', MELDUNG.zeit)
|
|
}
|
|
if (start && ende) {
|
|
if (ende.getTime() <= start.getTime()) fehl('bis', MELDUNG_BETRIEB.endeVorBeginn)
|
|
else if ((ende.getTime() - start.getTime()) / MIN_MS > BELEGUNG_MAX_MIN) fehl('bis', MELDUNG_BETRIEB.sperreLang)
|
|
else if (ende.getTime() <= now.getTime()) fehl('bis', MELDUNG_BETRIEB.sperreVorbei)
|
|
}
|
|
|
|
const titel = bereinigterText(roh.titel, TITEL_MAX + 1)
|
|
if ([...titel].length > TITEL_MAX) fehl('titel', MELDUNG_BETRIEB.titelLang)
|
|
|
|
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
|
|
return { ressource, start, ende, titel: titel === '' ? SPERRE_TITEL : titel }
|
|
}
|
|
|
|
/**
|
|
* Zeit sperren: eine bestaetigte Buchung ohne Gast (`quelle 'sperre'`, `leistungName` = Titel, Vorgabe "Gesperrt") auf einer
|
|
* Ressource an einem Berliner Tag von `von` bis `bis` ('HH:MM', Vielfache von 5 Minuten, `bis` auch '24:00'), mit Zellen
|
|
* wie jede Buchung. Hoechstens 500 Minuten (100 Zellen in einem Bulk-Create), sonst 400 Feld `bis`; laengere Zeiten sind
|
|
* Sache der Abwesenheiten. Das Ende muss in der Zukunft liegen. Liegt schon ein Termin oder eine Sperre in der Zeit,
|
|
* 409 (code SLOT_VERGEBEN, Feld von): erst den Termin absagen oder verschieben.
|
|
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
|
|
* @param {{ ressource: string, tag: string, von: string, bis: string, titel?: string }} roh
|
|
* @param {Date} now
|
|
* @returns {Promise<{ id: string, status: 'bestaetigt' }>}
|
|
*/
|
|
export async function sperre(ctx, roh, now) {
|
|
pruefeNow(now, 'sperre')
|
|
pruefeKontext(ctx, 'sperre')
|
|
const s = pruefeSperre(ctx.einstellung, roh, now)
|
|
const id = randomUUID()
|
|
await legeBelegungAn(
|
|
ctx,
|
|
id,
|
|
{
|
|
featureId: ctx.feature.$id,
|
|
customerId: ctx.feature.customerId,
|
|
projectId: ctx.feature.projectId,
|
|
leistungId: '',
|
|
leistungName: s.titel,
|
|
ressourceId: s.ressource.id,
|
|
ressourceName: s.ressource.name,
|
|
startsAt: iso(s.start),
|
|
endsAt: iso(s.ende),
|
|
blockEndsAt: iso(s.ende),
|
|
status: 'bestaetigt',
|
|
quelle: 'sperre',
|
|
erinnerung: false,
|
|
bestaetigtAt: iso(now),
|
|
preisCent: 0,
|
|
preisArt: 'keiner',
|
|
verbraucher: false,
|
|
icsSequenz: 0,
|
|
createdAt: iso(now),
|
|
updatedAt: iso(now),
|
|
},
|
|
s.ressource.id,
|
|
s.start,
|
|
s.ende,
|
|
now,
|
|
() => fehlerMit(409, { von: MELDUNG_BETRIEB.sperreBelegt }, { code: 'SLOT_VERGEBEN' }),
|
|
)
|
|
return { id, status: 'bestaetigt' }
|
|
}
|
|
|
|
/**
|
|
* Sperre aufheben: loescht die Buchung und danach ihre Zellen (nur `quelle 'sperre'` der eigenen Funktion, sonst 404).
|
|
* Wie bei einer Absage gilt eine Zelle ohne Buchung als frei; scheitert das Freigeben, steht ein Eintrag im Log und der
|
|
* Tagesjob raeumt auf.
|
|
* @returns {Promise<{ status: 'entsperrt' }>}
|
|
*/
|
|
export async function entsperre(ctx, bookingId) {
|
|
pruefeKontext(ctx, 'entsperre')
|
|
if (typeof bookingId !== 'string' || !ID_RE.test(bookingId)) throw nichtGefunden(MELDUNG_BETRIEB.keineSperre)
|
|
return withLocks([`termine-buchung:${bookingId}`], async () => {
|
|
const frisch = await hole(bookingId)
|
|
if (!frisch || frisch.featureId !== ctx.feature.$id || frisch.quelle !== 'sperre') throw nichtGefunden(MELDUNG_BETRIEB.keineSperre)
|
|
await loesche(bookingId)
|
|
await gibFreiSicher(bookingId)
|
|
return { status: 'entsperrt' }
|
|
})
|
|
}
|