Terminplaner: Absagen, Verschieben, Widerruf durch Gaeste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1289b5697b
commit
2d9b75b3bf
5 files changed
+1682
-24
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { config } from '../../config.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { aendere, hole, imZeitraum, legeAn, offeneFuerEmail, perHash } from './buchungen.js'
|
||||
import { aendere, hole, imZeitraum, legeAn, offeneFuerEmail, perEmailUndNummer, perHash } from './buchungen.js'
|
||||
import {
|
||||
brauchtZustimmungVorzeitig,
|
||||
emailAdresse,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
zahlungText,
|
||||
} from './einstellungen.js'
|
||||
import { kontextPerFeature, websiteUrl } from './kontext.js'
|
||||
import { darfG08, darfK21NichtZugeordnet } from './limits.js'
|
||||
import { anBetrieb, anGast, dauerText } from './mails.js'
|
||||
import {
|
||||
RECHTSTEXTE_VERSION,
|
||||
@@ -22,11 +23,11 @@ import {
|
||||
formularHinweis,
|
||||
widerrufsbelehrung,
|
||||
} from './rechtstexte.js'
|
||||
import { belegungAus, blockEnde, freieZeiten, waehleRessource } from './slots.js'
|
||||
import { belegungAus, blockEnde, freieTage, freieZeiten, waehleRessource } from './slots.js'
|
||||
import { buchungsNummer, hashToken, neuesToken, verwaltungsToken } from './token.js'
|
||||
import { pruefeNow, zeitMs } from './util.js'
|
||||
import { ausUtc, datumLang, uhrzeit, zuUtc } from './zeit.js'
|
||||
import { belegeZellen } from './zellen.js'
|
||||
import { ausUtc, datumKurz, datumLang, monatInfo, uhrzeit, zuUtc } from './zeit.js'
|
||||
import { belegeZellen, gibZellenFrei, zellenFuer } from './zellen.js'
|
||||
|
||||
/**
|
||||
* Gast-Ablauf des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 4): Termin halten,
|
||||
@@ -45,6 +46,14 @@ import { belegeZellen } from './zellen.js'
|
||||
* Zustandsaenderungen laufen unter `termine-buchung:<id>` mit frischem Lesen. Mails gehen erst NACH der Aenderung und
|
||||
* ausserhalb der Sperre in die Warteschlange; scheitert das, bleibt die Aenderung bestehen (Log ohne personenbezogene
|
||||
* Daten, die Warteschlange vermerkt Fehler beim Senden selbst).
|
||||
*
|
||||
* Verwaltung durch den Gast (Verwaltungstoken, gueltig bis Terminende + 1 Tag): sageAbGast, verschiebeGast (mit
|
||||
* zeitenFuerToken fuer die freien Zeiten), widerrufeGast; ohne Link widerrufeOhneLink. Absagen und Verschieben gehen bis
|
||||
* absageBisStd vor Beginn (danach 409 "Absage nur telefonisch"), der Widerruf haengt nur an Vertragsschluss + 14 Tagen
|
||||
* und am Beginn des Termins. Beim Verschieben werden die neuen Zellen VOR der Aenderung der Buchung belegt (scheitert
|
||||
* das, bleibt alles wie es war), die alten erst danach freigegeben; die Zellen einer abgesagten Buchung gelten ohnehin
|
||||
* als frei, ein Fehler beim Freigeben macht eine Absage also nicht rueckgaengig (nur ein Log). Fuer die Zellen gilt:
|
||||
* nie selbst eine termine-res-Sperre halten (belegeZellen/gibZellenFrei sind nicht wiedereintrittsfaehig).
|
||||
*/
|
||||
|
||||
const MIN_MS = 60 * 1000
|
||||
@@ -56,6 +65,7 @@ const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
|
||||
const ZEIT_RE = /^([01]\d|2[0-3]):[0-5]\d$/
|
||||
const TELEFON_RE = /^[0-9 +()-]+$/
|
||||
const NAME_MAX = 100
|
||||
const NUMMER_MAX = 40
|
||||
const NACHRICHT_MAX = 500
|
||||
const TELEFON_MAX = 40
|
||||
const ZUSATZ_WERT_MAX = 200
|
||||
@@ -66,7 +76,9 @@ const MELDUNG = {
|
||||
leistung: 'Bitte eine Leistung auswählen.',
|
||||
ressource: 'Bitte aus den angebotenen Möglichkeiten auswählen.',
|
||||
tag: 'Bitte ein Datum auswählen.',
|
||||
monat: 'Bitte einen Monat auswählen.',
|
||||
zeit: 'Bitte eine Uhrzeit auswählen.',
|
||||
gleicheZeit: 'Das ist bereits der gebuchte Termin. Bitte eine andere Zeit wählen.',
|
||||
zeitVergeben: 'Diese Zeit ist leider nicht mehr frei. Bitte eine andere Zeit wählen.',
|
||||
nameLeer: 'Bitte einen Namen angeben.',
|
||||
nameLang: `Der Name darf höchstens ${NAME_MAX} Zeichen lang sein.`,
|
||||
@@ -83,6 +95,8 @@ const MELDUNG = {
|
||||
vorzeitig: 'Bitte der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zustimmen, sonst ist die Buchung nicht möglich.',
|
||||
unbekannt: 'Dieser Link ist ungültig oder abgelaufen.',
|
||||
abgelaufen: 'Diese Reservierung ist abgelaufen.',
|
||||
nichtMoeglich: 'Dieser Termin kann nicht mehr abgesagt werden.',
|
||||
keinWiderruf: 'Für diesen Termin ist kein Widerruf möglich.',
|
||||
}
|
||||
|
||||
const iso = (wert) => new Date(wert).toISOString()
|
||||
@@ -99,6 +113,23 @@ const unbekannt = () => fehlerMit(404, { _: MELDUNG.unbekannt }, { code: 'unbeka
|
||||
const betriebKurz = (ctx) => ({ name: ctx.einstellung.betrieb.name, telefon: ctx.einstellung.betrieb.telefon, websiteUrl: websiteUrl(ctx) })
|
||||
const abgelaufenMit = (status, ctx) => fehlerMit(status, { _: MELDUNG.abgelaufen }, { code: 'abgelaufen', betrieb: betriebKurz(ctx) })
|
||||
|
||||
/** Sammelt Feldfehler (der erste je Feld gilt): { fehler, fehl(feld, meldung) }. */
|
||||
function fehlerSammler() {
|
||||
const fehler = {}
|
||||
const fehl = (feld, meldung) => {
|
||||
if (!Object.hasOwn(fehler, feld)) fehler[feld] = meldung
|
||||
}
|
||||
return { fehler, fehl }
|
||||
}
|
||||
|
||||
/** Name: nicht leer, hoechstens 100 Zeichen (bereinigt); Fehler gehen an fehl. */
|
||||
function pruefeNameFeld(wert, fehl) {
|
||||
const name = bereinigterText(wert, NAME_MAX + 1)
|
||||
if (name === '') fehl('name', MELDUNG.nameLeer)
|
||||
else if ([...name].length > NAME_MAX) fehl('name', MELDUNG.nameLang)
|
||||
return name
|
||||
}
|
||||
|
||||
/** Log ohne personenbezogene Daten: Vorgang, Buchungsnummer, Art und Text des Fehlers (kommt aus unserem Code oder Appwrite). */
|
||||
function protokolliere(was, bookingId, err) {
|
||||
const nummer = bookingId ? buchungsNummer(bookingId) : '-'
|
||||
@@ -122,10 +153,7 @@ async function mailSicher(was, bookingId, fn) {
|
||||
*/
|
||||
function pruefeEingabe(e, roh, now) {
|
||||
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
|
||||
const fehler = {}
|
||||
const fehl = (feld, meldung) => {
|
||||
if (!Object.hasOwn(fehler, feld)) fehler[feld] = meldung
|
||||
}
|
||||
const { fehler, fehl } = fehlerSammler()
|
||||
|
||||
// Leistung, Ressource
|
||||
const leistung = typeof roh.leistung === 'string' ? (e.leistungen || []).find((l) => l.id === roh.leistung && l.aktiv) : undefined
|
||||
@@ -150,9 +178,7 @@ function pruefeEingabe(e, roh, now) {
|
||||
if (zeit === null || (tag !== null && start === null)) fehl('zeit', MELDUNG.zeit)
|
||||
|
||||
// Name, E-Mail
|
||||
const name = bereinigterText(roh.name, NAME_MAX + 1)
|
||||
if (name === '') fehl('name', MELDUNG.nameLeer)
|
||||
else if ([...name].length > NAME_MAX) fehl('name', MELDUNG.nameLang)
|
||||
const name = pruefeNameFeld(roh.name, fehl)
|
||||
const email = emailAdresse(roh.email)
|
||||
if (email === null) fehl('email', MELDUNG.email)
|
||||
|
||||
@@ -250,6 +276,9 @@ function baueNachweis(e, g, verbraucher, now) {
|
||||
|
||||
// ---------------------------------------------------------------- Halten
|
||||
|
||||
/** Buchungen der Liste, die an diesem Berliner Tag beginnen (Mass fuer "die wenigsten Buchungen an dem Tag"). */
|
||||
const beginnenAn = (liste, tag) => liste.filter((b) => Number.isFinite(zeitMs(b.startsAt)) && ausUtc(new Date(zeitMs(b.startsAt))).tag === tag)
|
||||
|
||||
/** Freie Zeit neu berechnen (Belegung des Tages frisch aus der Datenbank) und bei "egal" die Ressource waehlen. */
|
||||
async function pruefeSlot(ctx, g, now) {
|
||||
const e = ctx.einstellung
|
||||
@@ -261,8 +290,7 @@ async function pruefeSlot(ctx, g, now) {
|
||||
if (!treffer) throw zeitVergeben()
|
||||
if (g.ressource !== 'egal') return { start: treffer.start, ressourceId: g.ressource }
|
||||
// "Die wenigsten Buchungen an dem Tag": nur Buchungen, die an diesem Berliner Tag beginnen
|
||||
const beginnenHeute = amTag.filter((b) => Number.isFinite(zeitMs(b.startsAt)) && ausUtc(new Date(zeitMs(b.startsAt))).tag === g.tag)
|
||||
return { start: treffer.start, ressourceId: waehleRessource(treffer.ressourceIds, beginnenHeute, e, now) }
|
||||
return { start: treffer.start, ressourceId: waehleRessource(treffer.ressourceIds, beginnenAn(amTag, g.tag), e, now) }
|
||||
}
|
||||
|
||||
/** Haltung wieder aufgeben (nach gescheitertem Belegen): verfallen, unter der Buchungssperre. Fehler dabei werden nur protokolliert. */
|
||||
@@ -362,12 +390,15 @@ export async function halte(ctx, eingabe, now) {
|
||||
|
||||
// ---------------------------------------------------------------- Token -> Buchung
|
||||
|
||||
/** Buchung zu einem Token (Bestaetigungstoken zuerst, dann Verwaltungstoken) samt Kontext; null, wenn es nichts gibt. */
|
||||
async function findePerToken(token, { nurBestaetigung = false } = {}) {
|
||||
/**
|
||||
* Buchung zu einem Token (Bestaetigungstoken zuerst, dann Verwaltungstoken) samt Kontext; null, wenn es nichts gibt.
|
||||
* nurBestaetigung / nurVerwaltung lassen die jeweils andere Art weg.
|
||||
*/
|
||||
async function findePerToken(token, { nurBestaetigung = false, nurVerwaltung = false } = {}) {
|
||||
if (typeof token !== 'string' || !TOKEN_RE.test(token)) return null
|
||||
const hash = hashToken(token)
|
||||
let art = 'bestaetigen'
|
||||
let buchung = await perHash('verifyHash', hash)
|
||||
let buchung = nurVerwaltung ? null : await perHash('verifyHash', hash)
|
||||
if (!buchung && !nurBestaetigung) {
|
||||
art = 'verwalten'
|
||||
buchung = await perHash('manageHash', hash)
|
||||
@@ -389,21 +420,37 @@ export function kannAbsagen(einstellung, buchung, now) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Besteht noch ein Widerrufsrecht? Verbindlich (sofort), Verbraucher, bestaetigt (kein Handeintrag des Betriebs),
|
||||
* weniger als 14 Tage seit dem Vertragsschluss (bestaetigtAt) und der Termin hat noch nicht begonnen.
|
||||
* Zeitpunkt des Vertragsschlusses in ms: bestaetigtAt bei einer sofort buchbaren Leistung, angenommenAt bei einer vom
|
||||
* Betrieb angenommenen Anfrage; NaN, wenn es keinen gibt (auch eine Anfrage, die nur bestaetigt wurde, ist kein Vertrag).
|
||||
*/
|
||||
function vertragAm(buchung) {
|
||||
if (buchung.buchungsart === 'sofort') return zeitMs(buchung.bestaetigtAt)
|
||||
if (buchung.buchungsart === 'anfrage') return zeitMs(buchung.angenommenAt)
|
||||
return Number.NaN
|
||||
}
|
||||
|
||||
/**
|
||||
* Besteht noch ein Widerrufsrecht? Verbindlich (sofort oder vom Betrieb angenommene Anfrage), Verbraucher, bestaetigt
|
||||
* (kein Handeintrag des Betriebs), weniger als 14 Tage seit dem Vertragsschluss (vertragAm) und der Termin hat noch
|
||||
* nicht begonnen. Unabhaengig von absageBisStd (§ 361 BGB: die Stornofrist schraenkt das Widerrufsrecht nicht ein).
|
||||
*/
|
||||
export function kannWiderrufen(buchung, now) {
|
||||
const nowMs = pruefeNow(now, 'kannWiderrufen')
|
||||
return (
|
||||
buchung.buchungsart === 'sofort' &&
|
||||
buchung.verbraucher === true &&
|
||||
buchung.status === 'bestaetigt' &&
|
||||
buchung.quelle !== 'manuell' &&
|
||||
nowMs < zeitMs(buchung.bestaetigtAt) + WIDERRUF_TAGE * TAG_MS &&
|
||||
nowMs < vertragAm(buchung) + WIDERRUF_TAGE * TAG_MS &&
|
||||
nowMs < zeitMs(buchung.startsAt)
|
||||
)
|
||||
}
|
||||
|
||||
/** Hinweis, wenn ein Gast nicht mehr selbst absagen kann (Frist vorbei, Termin aber noch nicht begonnen). */
|
||||
const absageHinweis = (betrieb) => (betrieb.telefon ? `Absage nur telefonisch: ${betrieb.telefon}` : 'Absage nur direkt beim Betrieb möglich.')
|
||||
|
||||
/** Der Verwaltungslink gilt bis Terminende + 1 Tag. */
|
||||
const linkAbgelaufen = (buchung, nowMs) => nowMs >= zeitMs(buchung.endsAt) + TAG_MS
|
||||
|
||||
// ---------------------------------------------------------------- Vorschau
|
||||
|
||||
/**
|
||||
@@ -425,7 +472,7 @@ export async function vorschau(token, now) {
|
||||
if (art === 'bestaetigen' && (buchung.status === 'verfallen' || (buchung.status === 'gehalten' && !(zeitMs(buchung.heldUntil) > nowMs)))) {
|
||||
throw abgelaufenMit(404, ctx)
|
||||
}
|
||||
if (art === 'verwalten' && nowMs >= zeitMs(buchung.endsAt) + TAG_MS) throw abgelaufenMit(404, ctx) // gueltig bis Terminende + 1 Tag
|
||||
if (art === 'verwalten' && linkAbgelaufen(buchung, nowMs)) throw abgelaufenMit(404, ctx)
|
||||
|
||||
const start = new Date(zeitMs(buchung.startsAt))
|
||||
const preis = preisText({ preisArt: buchung.preisArt, preisCent: buchung.preisCent })
|
||||
@@ -440,7 +487,7 @@ export async function vorschau(token, now) {
|
||||
aktionen.absagen = kannAbsagen(e, buchung, now)
|
||||
aktionen.verschieben = aktionen.absagen
|
||||
aktionen.widerrufen = kannWiderrufen(buchung, now)
|
||||
if (offen && !aktionen.absagen && nowMs < start.getTime()) hinweis = b.telefon ? `Absage nur telefonisch: ${b.telefon}` : 'Absage nur direkt beim Betrieb möglich.'
|
||||
if (offen && !aktionen.absagen && nowMs < start.getTime()) hinweis = absageHinweis(b)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -552,3 +599,367 @@ export async function bestaetige(token, now) {
|
||||
}
|
||||
return { status: ergebnis.status }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Verwaltung durch den Gast
|
||||
|
||||
/** Buchung zum Verwaltungstoken samt Kontext; 404 (code 'unbekannt'), ab Terminende + 1 Tag 404 code 'abgelaufen'. */
|
||||
async function ladeVerwaltung(token, nowMs) {
|
||||
const fund = await findePerToken(token, { nurVerwaltung: true })
|
||||
if (!fund) throw unbekannt()
|
||||
if (linkAbgelaufen(fund.buchung, nowMs)) throw abgelaufenMit(404, fund.ctx)
|
||||
return fund
|
||||
}
|
||||
|
||||
/**
|
||||
* Absagen und Verschieben setzen eine offene Buchung (bestaetigt, angefragt) vor Ablauf der Absagefrist voraus. Sonst 409:
|
||||
* ist der Termin noch nicht begonnen, mit "Absage nur telefonisch: ..." (code 'frist_vorbei'), andernfalls "nicht mehr
|
||||
* moeglich" (code 'nicht_moeglich', z. B. erledigt oder abgesagt).
|
||||
*/
|
||||
function pruefeAbsagbar(einstellung, buchung, now) {
|
||||
if (kannAbsagen(einstellung, buchung, now)) return
|
||||
if (VERWALTBAR.includes(buchung.status) && now.getTime() < zeitMs(buchung.startsAt)) {
|
||||
throw fehlerMit(409, { _: absageHinweis(einstellung.betrieb) }, { code: 'frist_vorbei' })
|
||||
}
|
||||
throw fehlerMit(409, { _: MELDUNG.nichtMoeglich }, { code: 'nicht_moeglich' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Zellen einer Buchung freigeben, nachdem sich ihr Zustand geaendert hat. Ein Fehler wird nur protokolliert: bei einer
|
||||
* Absage gelten die Zellen einer nicht mehr belegenden Buchung ohnehin als frei (zellen.js, der Tagesjob raeumt sie).
|
||||
*/
|
||||
async function gibFreiSicher(bookingId, optionen) {
|
||||
try {
|
||||
await gibZellenFrei(bookingId, optionen)
|
||||
} catch (err) {
|
||||
protokolliere('Zellen nicht freigegeben', bookingId, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gast sagt per Verwaltungslink ab (bis absageBisStd vor Beginn): `abgesagt` von `gast`, Zellen frei, G06 an den Gast
|
||||
* (Kalenderabsage CANCEL; fuer eine unbestaetigte Anfrage ohne Kalenderdatei, sie hat nie eine Einladung bekommen), K21
|
||||
* `abgesagt` an den Betrieb. Ein weiterer Klick aendert nichts und meldet `schon`.
|
||||
* @param {string} token Verwaltungstoken
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ status: 'abgesagt', schon?: true }>}
|
||||
* @throws Error 404 (unbekannt; 'abgelaufen' mit betrieb), 409 code 'frist_vorbei' (Feld _ "Absage nur telefonisch: ...")
|
||||
* oder 'nicht_moeglich'
|
||||
*/
|
||||
export async function sageAbGast(token, now) {
|
||||
const nowMs = pruefeNow(now, 'sageAbGast')
|
||||
const { ctx, buchung: gefunden } = await ladeVerwaltung(token, nowMs)
|
||||
const id = gefunden.$id
|
||||
|
||||
const ergebnis = await withLocks([`termine-buchung:${id}`], async () => {
|
||||
const frisch = await hole(id)
|
||||
if (!frisch) throw unbekannt()
|
||||
if (frisch.status === 'abgesagt') return { schon: true }
|
||||
pruefeAbsagbar(ctx.einstellung, frisch, now)
|
||||
const buchung = await aendere(id, { status: 'abgesagt', abgesagtAt: iso(now), abgesagtVon: 'gast', updatedAt: iso(now) })
|
||||
await gibFreiSicher(id)
|
||||
return { buchung, ohneIcs: frisch.status === 'angefragt' }
|
||||
})
|
||||
if (ergebnis.schon) return { status: 'abgesagt', schon: true }
|
||||
|
||||
const { buchung, ohneIcs } = ergebnis
|
||||
await mailSicher('G06', id, () => anGast('G06', ctx, buchung, { von: 'gast', ohneIcs }, now))
|
||||
await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: 'abgesagt' }, now))
|
||||
return { status: 'abgesagt' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dauer und Pause so, wie der Gast gebucht hat (nicht wie die Leistung heute eingestellt ist): eine Einstellung, in der
|
||||
* die Leistung der Buchung diese Laenge hat. Beim Verschieben aendert der Termin seine Lage, nicht seinen Umfang.
|
||||
*/
|
||||
function mitGebuchterDauer(einstellung, buchung) {
|
||||
const dauer = Math.round((zeitMs(buchung.endsAt) - zeitMs(buchung.startsAt)) / MIN_MS)
|
||||
const pufferNach = Math.round((zeitMs(buchung.blockEndsAt) - zeitMs(buchung.endsAt)) / MIN_MS)
|
||||
return { ...einstellung, leistungen: (einstellung.leistungen || []).map((l) => (l.id === buchung.leistungId ? { ...l, dauer, pufferNach } : l)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Ressourcen kommen beim Verschieben infrage? Ist die Person waehlbar, hat der Gast sie bewusst oder durch "egal"
|
||||
* bekommen und behaelt sie; sonst (Plaetze, keine Wahl) jede freie, wobei die eigene bevorzugt wird.
|
||||
*/
|
||||
const verschiebeRessource = (einstellung, buchung) => (einstellung.regeln?.personWaehlbar === true ? buchung.ressourceId : 'egal')
|
||||
|
||||
/** Freie Startzeiten eines Tages fuer das Verschieben einer Buchung (ohne die Buchung selbst); => { zeiten, amTag }. */
|
||||
async function verschiebeZeiten(ctx, buchung, tag, now) {
|
||||
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
|
||||
const zeiten = freieZeiten({
|
||||
einstellung: mitGebuchterDauer(ctx.einstellung, buchung),
|
||||
leistungId: buchung.leistungId,
|
||||
ressourceId: verschiebeRessource(ctx.einstellung, buchung),
|
||||
tag,
|
||||
belegung: belegungAus(amTag, now, { ohneBuchung: buchung.$id }),
|
||||
now,
|
||||
})
|
||||
return { zeiten, amTag }
|
||||
}
|
||||
|
||||
/** Tag ('YYYY-MM-DD') und Uhrzeit ('HH:MM') des neuen Termins; sonst 400 mit den Feldern tag und zeit. */
|
||||
function pruefeVerschiebung(roh) {
|
||||
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
|
||||
const { fehler, fehl } = fehlerSammler()
|
||||
const tag = typeof roh.tag === 'string' && zuUtc(roh.tag, '12:00') !== null ? roh.tag : null
|
||||
if (tag === null) fehl('tag', MELDUNG.tag)
|
||||
const zeit = typeof roh.zeit === 'string' && ZEIT_RE.test(roh.zeit) ? roh.zeit : null
|
||||
if (zeit === null) fehl('zeit', MELDUNG.zeit)
|
||||
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
|
||||
return { tag, zeit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gast verschiebt per Verwaltungslink (gleiche Frist wie beim Absagen) auf eine freie Zeit. Die eigene Buchung belegt
|
||||
* die neue Zeit nicht selbst (belegungAus ohneBuchung, belegeZellen ausser), 10:00 -> 10:30 ueberlappend ist also
|
||||
* moeglich. Reihenfolge unter `termine-buchung:<id>`: frisch lesen, neue Zeit berechnen, neue Zellen belegen (SLOT_VERGEBEN
|
||||
* => 409, alles unveraendert), Buchung aendern (Beginn, Ende, Blockende, Ressource, icsSequenz + 1; scheitert das, werden
|
||||
* die neuen Zellen wieder entfernt), erst dann die alten Zellen freigeben. Status und Verwaltungslink bleiben. Danach G07
|
||||
* (neuer Termin, Kalender-REQUEST mit hoeherer Sequenz; bei einer Anfrage ohne Kalenderdatei) und K21 `verschoben`.
|
||||
* @param {string} token Verwaltungstoken
|
||||
* @param {{ tag: string, zeit: string }} roh Berliner Tag 'YYYY-MM-DD' und Uhrzeit 'HH:MM'
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ status: string, termin: { datum: string, zeit: string } }>}
|
||||
* @throws Error 400 (Felder tag, zeit; dieselbe Zeit), 404, 409 ('frist_vorbei', 'nicht_moeglich', 'SLOT_VERGEBEN' Feld zeit)
|
||||
*/
|
||||
export async function verschiebeGast(token, roh, now) {
|
||||
const nowMs = pruefeNow(now, 'verschiebeGast')
|
||||
const { tag, zeit } = pruefeVerschiebung(roh)
|
||||
const { ctx, buchung: gefunden } = await ladeVerwaltung(token, nowMs)
|
||||
const id = gefunden.$id
|
||||
const featureId = ctx.feature.$id
|
||||
|
||||
const ergebnis = await withLocks([`termine-buchung:${id}`], async () => {
|
||||
const alt = await hole(id)
|
||||
if (!alt) throw unbekannt()
|
||||
pruefeAbsagbar(ctx.einstellung, alt, now)
|
||||
|
||||
const { zeiten, amTag } = await verschiebeZeiten(ctx, alt, tag, now)
|
||||
const treffer = zeiten.find((z) => z.uhrzeit === zeit)
|
||||
if (!treffer) throw zeitVergeben()
|
||||
const ressourceId = treffer.ressourceIds.includes(alt.ressourceId)
|
||||
? alt.ressourceId
|
||||
: waehleRessource(treffer.ressourceIds, beginnenAn(amTag, tag), ctx.einstellung, now)
|
||||
if (treffer.start.getTime() === zeitMs(alt.startsAt) && ressourceId === alt.ressourceId) throw fehlerMit(400, { zeit: MELDUNG.gleicheZeit })
|
||||
|
||||
const start = treffer.start
|
||||
const ende = new Date(start.getTime() + (zeitMs(alt.endsAt) - zeitMs(alt.startsAt)))
|
||||
const block = new Date(start.getTime() + (zeitMs(alt.blockEndsAt) - zeitMs(alt.startsAt)))
|
||||
try {
|
||||
await belegeZellen({ featureId, ressourceId, bookingId: id, start, blockEnde: block, now, ausser: id })
|
||||
} catch (err) {
|
||||
throw err?.code === 'SLOT_VERGEBEN' ? zeitVergeben() : err
|
||||
}
|
||||
|
||||
let buchung
|
||||
try {
|
||||
buchung = await aendere(id, {
|
||||
startsAt: iso(start),
|
||||
endsAt: iso(ende),
|
||||
blockEndsAt: iso(block),
|
||||
ressourceId,
|
||||
ressourceName: ctx.einstellung.ressourcen.find((r) => r.id === ressourceId)?.name ?? alt.ressourceName,
|
||||
icsSequenz: (Number.isInteger(alt.icsSequenz) ? alt.icsSequenz : 0) + 1,
|
||||
// eine fuer die alte Zeit schon gesendete Erinnerung gilt nicht fuer die neue
|
||||
...(alt.erinnerungGesendetAt ? { erinnerungGesendetAt: null } : {}),
|
||||
updatedAt: iso(now),
|
||||
})
|
||||
} catch (err) {
|
||||
// die neuen Zellen wieder abgeben: ausser den alten bleibt nichts stehen
|
||||
await gibFreiSicher(id, { behalte: zellenFuer(alt.startsAt, alt.blockEndsAt), ressourceId: alt.ressourceId })
|
||||
throw err
|
||||
}
|
||||
// Mit der Ressource des neuen Termins bleiben nur dessen Zellen; die der alten Zeit (und einer anderen Ressource) gehen.
|
||||
await gibFreiSicher(id, { behalte: zellenFuer(start, block), ressourceId })
|
||||
return { alt, buchung, start }
|
||||
})
|
||||
|
||||
const { alt, buchung, start } = ergebnis
|
||||
const ohneIcs = alt.status === 'angefragt'
|
||||
await mailSicher('G07', id, () => anGast('G07', ctx, buchung, { altStart: alt.startsAt, ohneIcs }, now))
|
||||
await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: 'verschoben', extra: { altStart: alt.startsAt } }, now))
|
||||
return { status: buchung.status, termin: { datum: datumLang(start), zeit: uhrzeit(start) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Freie Zeiten fuer die Gaesteseite beim Verschieben (Verwaltungstoken), mit der Belegung ohne die eigene Buchung und in
|
||||
* der gebuchten Dauer. Dieselben Formen wie die oeffentliche Schnittstelle: `{ tag }` => { zeiten: ['HH:MM', ...] },
|
||||
* `{ monat: 'YYYY-MM' }` => { tage: ['YYYY-MM-DD', ...], monat: monatInfo }. Nur solange Verschieben moeglich ist.
|
||||
* @param {string} token Verwaltungstoken
|
||||
* @param {{ tag?: string, monat?: string }} roh
|
||||
* @param {Date} now
|
||||
* @throws Error 400 (Feld tag bzw. monat), 404, 409 ('frist_vorbei', 'nicht_moeglich')
|
||||
*/
|
||||
export async function zeitenFuerToken(token, roh, now) {
|
||||
const nowMs = pruefeNow(now, 'zeitenFuerToken')
|
||||
const abfrage = istObjekt(roh) ? roh : {}
|
||||
const nachMonat = abfrage.tag === undefined && abfrage.monat !== undefined
|
||||
const tag = typeof abfrage.tag === 'string' && zuUtc(abfrage.tag, '12:00') !== null ? abfrage.tag : null
|
||||
const info = nachMonat ? monatInfo(abfrage.monat) : null
|
||||
if (nachMonat ? info === null : tag === null) throw fehlerMit(400, nachMonat ? { monat: MELDUNG.monat } : { tag: MELDUNG.tag })
|
||||
|
||||
const { ctx, buchung } = await ladeVerwaltung(token, nowMs)
|
||||
pruefeAbsagbar(ctx.einstellung, buchung, now)
|
||||
if (!nachMonat) return { zeiten: (await verschiebeZeiten(ctx, buchung, tag, now)).zeiten.map((z) => z.uhrzeit) }
|
||||
|
||||
const imMonat = await imZeitraum(ctx.feature.$id, zuUtc(`${abfrage.monat}-01`, '00:00'), zuUtc(`${info.nachher}-01`, '00:00'))
|
||||
const tage = freieTage({
|
||||
einstellung: mitGebuchterDauer(ctx.einstellung, buchung),
|
||||
leistungId: buchung.leistungId,
|
||||
ressourceId: verschiebeRessource(ctx.einstellung, buchung),
|
||||
monat: abfrage.monat,
|
||||
belegung: belegungAus(imMonat, now, { ohneBuchung: buchung.$id }),
|
||||
now,
|
||||
})
|
||||
return { tage, monat: info }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Widerruf
|
||||
|
||||
const VERTRAG_STATUS = ['bestaetigt', 'erledigt', 'nichterschienen']
|
||||
|
||||
/**
|
||||
* Wie steht die Buchung zu einer Widerrufserklaerung? 'wirksam' (kannWiderrufen), 'nach_frist' (ein Verbrauchervertrag
|
||||
* besteht oder bestand, aber die 14 Tage sind um oder der Termin hat begonnen: der Betrieb prueft) oder 'kein_recht'
|
||||
* (Unternehmer, Freizeit, Handeintrag, noch nicht angenommene Anfrage, abgesagt, ...).
|
||||
*/
|
||||
function widerrufLage(buchung, now) {
|
||||
if (kannWiderrufen(buchung, now)) return 'wirksam'
|
||||
const vertrag = buchung.verbraucher === true && buchung.quelle !== 'manuell' && Number.isFinite(vertragAm(buchung)) && VERTRAG_STATUS.includes(buchung.status)
|
||||
return vertrag ? 'nach_frist' : 'kein_recht'
|
||||
}
|
||||
|
||||
/** Eingang einer Erklaerung als Berliner Strings ('TT.MM.JJJJ', 'HH:MM'). */
|
||||
const eingangVon = (wann) => ({ datum: datumKurz(wann), uhrzeit: uhrzeit(wann) })
|
||||
|
||||
/**
|
||||
* Eingabe der Widerrufserklaerung: Name (Pflicht) und, ohne Link, die E-Mail-Adresse (Pflicht, ein Postfach, klein und
|
||||
* getrimmt). Die Buchungsnummer ist freiwillig: fehlt sie oder passt sie nicht, geht die Erklaerung als "nicht
|
||||
* zugeordnet" an den Betrieb, statt dass das Formular sie ablehnt.
|
||||
*/
|
||||
function pruefeWiderrufEingabe(roh, { mitAdresse }) {
|
||||
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
|
||||
const { fehler, fehl } = fehlerSammler()
|
||||
const name = pruefeNameFeld(roh.name, fehl)
|
||||
let email = null
|
||||
if (mitAdresse) {
|
||||
email = emailAdresse(roh.email)
|
||||
if (email === null) fehl('email', MELDUNG.email)
|
||||
}
|
||||
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
|
||||
return { name, email, nummer: bereinigterText(roh.nummer, NUMMER_MAX) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Erklaerung zu einer gefundenen Buchung festhalten, unter `termine-buchung:<id>` nach frischem Lesen.
|
||||
* wirksam: `abgesagt` von `gast`, widerrufenAt, Zellen frei. nach_frist: nur ein Vermerk in absageGrund, sonst bleibt
|
||||
* alles. kein_recht: nichts. schon: die Buchung war schon widerrufen (nichts). weg: gibt es nicht mehr.
|
||||
* => { lage, buchung }
|
||||
*/
|
||||
async function haltWiderrufFest(id, name, now) {
|
||||
return withLocks([`termine-buchung:${id}`], async () => {
|
||||
const frisch = await hole(id)
|
||||
if (!frisch) return { lage: 'weg' }
|
||||
if (frisch.widerrufenAt) return { lage: 'schon', buchung: frisch }
|
||||
const lage = widerrufLage(frisch, now)
|
||||
if (lage === 'kein_recht') return { lage, buchung: frisch }
|
||||
const eingang = eingangVon(now)
|
||||
const wann = `${eingang.datum}, ${eingang.uhrzeit} Uhr`
|
||||
if (lage === 'nach_frist') {
|
||||
const vermerk = `Widerruf nach Ablauf der Frist erklärt von: ${name} (eingegangen ${wann})`
|
||||
return { lage, buchung: await aendere(id, { absageGrund: vermerk, updatedAt: iso(now) }) }
|
||||
}
|
||||
const buchung = await aendere(id, {
|
||||
status: 'abgesagt',
|
||||
abgesagtAt: iso(now),
|
||||
abgesagtVon: 'gast',
|
||||
widerrufenAt: iso(now),
|
||||
absageGrund: `Widerruf erklärt von: ${name} (eingegangen ${wann})`,
|
||||
updatedAt: iso(now),
|
||||
})
|
||||
await gibFreiSicher(id)
|
||||
return { lage, buchung }
|
||||
})
|
||||
}
|
||||
|
||||
/** Nicht gesendet, weil eine Grenze erreicht ist: Log ohne personenbezogene Daten, die Antwort an den Gast aendert das nicht. */
|
||||
function ueberspringe(was, bookingId) {
|
||||
console.warn(`[termine-ablauf] ${was} nicht gesendet (${bookingId ? buchungsNummer(bookingId) : '-'}): Grenze erreicht`)
|
||||
}
|
||||
|
||||
/** Mails zu einer festgehaltenen Erklaerung: G08 an die Adresse des Gastes (mit Grenzen) und K21 an den Betrieb. */
|
||||
async function widerrufsMails(ctx, lage, buchung, adresse, now) {
|
||||
const id = buchung.$id
|
||||
if (darfG08(ctx.feature.$id, adresse, now)) {
|
||||
await mailSicher('G08', id, () => anGast('G08', ctx, buchung, { fall: lage === 'wirksam' ? 'zugeordnet' : 'nach_frist', eingang: now, an: adresse }, now))
|
||||
} else {
|
||||
ueberspringe('G08', id)
|
||||
}
|
||||
await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: lage === 'wirksam' ? 'widerrufen' : 'nach_frist', extra: { eingang: now } }, now))
|
||||
}
|
||||
|
||||
/** Erklaerung ohne passende Buchung: G08 (ohne Bezug) an die angegebene Adresse, K21 `nicht_zugeordnet` an den Betrieb, beide mit Grenzen. */
|
||||
async function nichtZugeordnetMails(ctx, { name, email, nummer }, now) {
|
||||
const featureId = ctx.feature.$id
|
||||
if (darfG08(featureId, email, now)) {
|
||||
await mailSicher('G08', null, () => anGast('G08', ctx, null, { fall: 'nicht_zugeordnet', eingang: now, an: email }, now))
|
||||
} else {
|
||||
ueberspringe('G08', null)
|
||||
}
|
||||
if (!darfK21NichtZugeordnet(featureId, now)) return ueberspringe('K21', null)
|
||||
const erklaerung = { gastName: name, gastEmail: email, gastTelefon: '', gastNachricht: `Angegebene Buchungsnummer: ${nummer || 'keine Angabe'}` }
|
||||
await mailSicher('K21', null, () => anBetrieb('K21', ctx, erklaerung, { block: 'nicht_zugeordnet', extra: { eingang: now } }, now))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gast widerruft per Verwaltungslink (Formular nach § 356a BGB, Knopf "Widerruf bestätigen"). Wirksam, solange ein
|
||||
* Widerrufsrecht besteht (kannWiderrufen), unabhaengig von der Stornofrist: `abgesagt` von `gast`, widerrufenAt, Zellen
|
||||
* frei, G08 `zugeordnet` an die Adresse der Buchung, K21 `widerrufen`. Nach Ablauf der Frist (oder bei begonnenem Termin)
|
||||
* wird die Erklaerung nur vermerkt und mit G08/K21 `nach_frist` gemeldet. Hat die Buchung kein Widerrufsrecht (nicht
|
||||
* angenommene Anfrage, Unternehmer, ...), 409 code 'kein_widerruf'. Ein weiterer Klick meldet den Eingang der ersten Erklaerung.
|
||||
* @param {string} token Verwaltungstoken
|
||||
* @param {{ name: string }} roh
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ eingang: { datum: string, uhrzeit: string } }>} Berliner Zeit ('TT.MM.JJJJ', 'HH:MM')
|
||||
*/
|
||||
export async function widerrufeGast(token, roh, now) {
|
||||
const nowMs = pruefeNow(now, 'widerrufeGast')
|
||||
const { name } = pruefeWiderrufEingabe(roh, { mitAdresse: false })
|
||||
const { ctx, buchung: gefunden } = await ladeVerwaltung(token, nowMs)
|
||||
|
||||
const erg = await haltWiderrufFest(gefunden.$id, name, now)
|
||||
if (erg.lage === 'weg') throw unbekannt()
|
||||
if (erg.lage === 'kein_recht') throw fehlerMit(409, { _: MELDUNG.keinWiderruf }, { code: 'kein_widerruf' })
|
||||
if (erg.lage === 'schon') return { eingang: eingangVon(new Date(zeitMs(erg.buchung.widerrufenAt))) }
|
||||
await widerrufsMails(ctx, erg.lage, erg.buchung, erg.buchung.gastEmail, now)
|
||||
return { eingang: eingangVon(now) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Widerruf ohne Link (Widerrufsfunktion im Widget, § 356a BGB). Die Antwort ist in jedem Fall dieselbe
|
||||
* (`{ eingang }` mit dem Eingang jetzt): sie verraet nicht, ob eine Buchung zu E-Mail-Adresse und Nummer passt.
|
||||
* - Treffer mit Widerrufsrecht: wie widerrufeGast (abgesagt, widerrufenAt, Zellen frei, G08 `zugeordnet`, K21 `widerrufen`).
|
||||
* - Treffer nach Ablauf der Frist: nur vermerkt, G08 `nach_frist` und K21 `nach_frist`; der Termin bleibt.
|
||||
* - sonst (kein Treffer oder Buchung ohne Widerrufsrecht): G08 ohne Bezug an die angegebene Adresse und K21
|
||||
* `nicht_zugeordnet` an den Betrieb, damit der Betrieb die Erklaerung pruefen kann.
|
||||
* G08 geht nie mit Text des Gastes raus. Grenzen (limits.js): 3 G08 je Adresse und Tag, 20 je Funktion und Stunde,
|
||||
* 10 K21 `nicht_zugeordnet` je Funktion und Stunde; eine erreichte Grenze ueberspringt nur die Mail.
|
||||
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
|
||||
* @param {{ name: string, email: string, nummer?: string }} roh
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ eingang: { datum: string, uhrzeit: string } }>}
|
||||
* @throws Error 400 (Felder name, email)
|
||||
*/
|
||||
export async function widerrufeOhneLink(ctx, roh, now) {
|
||||
pruefeNow(now, 'widerrufeOhneLink')
|
||||
if (!ctx?.einstellung?.betrieb || typeof ctx.feature?.$id !== 'string') throw new TypeError('widerrufeOhneLink: ctx fehlt')
|
||||
const erklaerung = pruefeWiderrufEingabe(roh, { mitAdresse: true })
|
||||
const antwort = { eingang: eingangVon(now) }
|
||||
|
||||
const treffer = await perEmailUndNummer(ctx.feature.$id, erklaerung.email, erklaerung.nummer)
|
||||
const erg = treffer ? await haltWiderrufFest(treffer.$id, erklaerung.name, now) : { lage: 'weg' }
|
||||
if (erg.lage === 'schon') return antwort // schon bestaetigt und gemeldet
|
||||
if (erg.lage === 'wirksam' || erg.lage === 'nach_frist') await widerrufsMails(ctx, erg.lage, erg.buchung, erklaerung.email, now)
|
||||
else await nichtZugeordnetMails(ctx, erklaerung, now)
|
||||
return antwort
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { pruefeNow, pruefeText } from './util.js'
|
||||
|
||||
/**
|
||||
* Grenzen des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 8): Zaehler im Speicher dieses
|
||||
* Prozesses, je Schluessel ein festes Fenster ab dem ersten gezaehlten Ereignis. Die Schluessel enthalten Adressen oder
|
||||
* IPs und verlassen den Speicher nie (kein Log, keine Datenbank). Hoechstens 10 000 Schluessel: ist die Map voll,
|
||||
* faellt der aelteste (zuerst begonnene) Zaehler weg; ein abgelaufenes Fenster wird beim naechsten Zaehlen neu begonnen.
|
||||
* Ein Neustart des Prozesses leert alles (die Grenzen sind Missbrauchsschutz, kein Buchungsrecht).
|
||||
*
|
||||
* Allgemein: zaehle / zaehleAlle (true = Ereignis erlaubt und gezaehlt, false = Grenze erreicht, nichts gezaehlt).
|
||||
* Mails des Widerrufs (Aufgabe 13): darfG08 und darfK21NichtZugeordnet. Die Grenzen je IP, siteKey und G01 ergaenzt
|
||||
* Aufgabe 16 in diesem Modul.
|
||||
*/
|
||||
|
||||
const MAX_SCHLUESSEL = 10000
|
||||
const STUNDE_MS = 60 * 60 * 1000
|
||||
const TAG_MS = 24 * STUNDE_MS
|
||||
|
||||
/** G08 (Eingangsbestaetigung des Widerrufs): je Empfaengeradresse und Tag, je Funktion und Stunde. */
|
||||
const G08_JE_ADRESSE = 3
|
||||
const G08_JE_FUNKTION = 20
|
||||
/** K21 "nicht zugeordnet": je Funktion und Stunde. */
|
||||
const K21_NICHT_ZUGEORDNET_JE_FUNKTION = 10
|
||||
|
||||
/** schluessel -> { start (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne. */
|
||||
const zaehler = new Map()
|
||||
|
||||
/** Der Zaehler des Schluessels, wenn sein Fenster zum Zeitpunkt nowMs noch laeuft; sonst undefined. */
|
||||
function laufend(schluessel, fensterMs, nowMs) {
|
||||
const eintrag = zaehler.get(schluessel)
|
||||
return eintrag && nowMs < eintrag.start + fensterMs ? eintrag : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaehlt ein Ereignis je Regel, aber nur, wenn keine Regel ihre Grenze erreicht hat (alle oder keine).
|
||||
* @param {{ schluessel: string, max: number, fensterMs: number }[]} regeln
|
||||
* @param {Date} now
|
||||
* @returns {boolean} true = erlaubt (gezaehlt), false = mindestens eine Grenze erreicht
|
||||
*/
|
||||
export function zaehleAlle(regeln, now) {
|
||||
const nowMs = pruefeNow(now, 'zaehleAlle')
|
||||
if (!Array.isArray(regeln) || regeln.length === 0) throw new TypeError('zaehleAlle: regeln fehlen')
|
||||
for (const { schluessel, max, fensterMs } of regeln) {
|
||||
pruefeText(schluessel, 'schluessel')
|
||||
if (!Number.isInteger(max) || max < 0 || !(fensterMs > 0)) throw new TypeError('zaehleAlle: max oder fensterMs ungueltig')
|
||||
if ((laufend(schluessel, fensterMs, nowMs)?.n ?? 0) >= max) return false
|
||||
}
|
||||
for (const { schluessel, fensterMs } of regeln) {
|
||||
const eintrag = laufend(schluessel, fensterMs, nowMs)
|
||||
if (eintrag) {
|
||||
eintrag.n += 1
|
||||
continue
|
||||
}
|
||||
zaehler.delete(schluessel) // neu einfuegen = ans Ende der Reihenfolge
|
||||
if (zaehler.size >= MAX_SCHLUESSEL) zaehler.delete(zaehler.keys().next().value)
|
||||
zaehler.set(schluessel, { start: nowMs, n: 1 })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Ein Ereignis zaehlen: hoechstens max je Fenster. => true = erlaubt (gezaehlt), false = Grenze erreicht. */
|
||||
export function zaehle(schluessel, max, fensterMs, now) {
|
||||
return zaehleAlle([{ schluessel, max, fensterMs }], now)
|
||||
}
|
||||
|
||||
/** Darf eine G08 an diese (normalisierte) Adresse gehen? Hoechstens 3 je Adresse und Tag, 20 je Funktion und Stunde. */
|
||||
export function darfG08(featureId, adresse, now) {
|
||||
return zaehleAlle(
|
||||
[
|
||||
{ schluessel: `g08-adresse:${adresse}`, max: G08_JE_ADRESSE, fensterMs: TAG_MS },
|
||||
{ schluessel: `g08-funktion:${featureId}`, max: G08_JE_FUNKTION, fensterMs: STUNDE_MS },
|
||||
],
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
/** Darf eine K21 "nicht zugeordnet" an den Betrieb gehen? Hoechstens 10 je Funktion und Stunde. */
|
||||
export function darfK21NichtZugeordnet(featureId, now) {
|
||||
return zaehle(`k21-nicht-zugeordnet:${featureId}`, K21_NICHT_ZUGEORDNET_JE_FUNKTION, STUNDE_MS, now)
|
||||
}
|
||||
|
||||
/** Leert alle Zaehler (Tests). */
|
||||
export function resetTermineLimitsForTests() {
|
||||
zaehler.clear()
|
||||
}
|
||||
@@ -33,6 +33,8 @@ import { datumKurz, datumLang, uhrzeit } from './zeit.js'
|
||||
* G04 nachricht (optionale Nachricht des Betriebs)
|
||||
* G06 von 'gast' | 'betrieb' | 'system' (Pflicht); grund, nachricht (nur bei Absage durch den Betrieb); sequenz (ICS, Vorgabe: icsSequenz + 1)
|
||||
* G07 altStart (bisheriger Beginn, Pflicht)
|
||||
* G06/G07 ohneIcs: true laesst die Kalenderdatei weg (eine unbestaetigte Anfrage hat nie eine Einladung bekommen,
|
||||
* es gibt im Kalender des Gastes nichts abzusagen oder zu verschieben)
|
||||
* G08 fall 'zugeordnet' | 'nach_frist' | 'nicht_zugeordnet' (Pflicht); eingang (Date, Pflicht); an (bei 'nicht_zugeordnet')
|
||||
* K20 fall (= block) 'anfrage' oder nichts
|
||||
* K21 fall (= block) 'abgesagt' | 'verschoben' | 'widerrufen' | 'nicht_zugeordnet' | 'nach_frist' (Pflicht); altStart (verschoben);
|
||||
@@ -455,7 +457,8 @@ function kalenderDatei(art, ctx, buchung, extra, jetzt) {
|
||||
|
||||
/**
|
||||
* Reiht eine Mail an den Gast ein (Absender: Betriebsname, Antworten an die Betriebs-E-Mail, nie BCC). Empfaenger ist
|
||||
* extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL).
|
||||
* extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL), jeweils
|
||||
* ausser mit extra.ohneIcs.
|
||||
* @param {string} art 'G01' ... 'G08'
|
||||
* @param {object} ctx
|
||||
* @param {object|null} buchung
|
||||
@@ -469,7 +472,7 @@ export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) {
|
||||
extra = extra ?? {}
|
||||
const { values, blocks } = werteFuer(art, ctx, buchung, extra)
|
||||
const b = ctx.einstellung.betrieb
|
||||
const ics = kalenderDatei(art, ctx, buchung, extra, now)
|
||||
const ics = extra.ohneIcs === true ? null : kalenderDatei(art, ctx, buchung, extra, now)
|
||||
return einreihen(
|
||||
{
|
||||
featureId: ctx.feature?.$id,
|
||||
|
||||
Reference in new issue
Block a user