Buchungskalender: Verwaltungs-API für Vermieter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:26:46 +00:00
1 parent a94b50fbc1
commit d0ab92658a
5 files changed
+1486 -8

No files matched your search

+632 -5
View File
@@ -1,13 +1,640 @@
import { Router } from 'express'
import { requireSession } from '../middleware/session.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { withLocks } from '../services/keyedLock.js'
import { isKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js'
import * as ablauf from '../services/unterkunft/ablauf.js'
import { istFrei } from '../services/unterkunft/belegung.js'
import { istTag, naechte } from '../services/unterkunft/datum.js'
import { UkFehler } from '../services/unterkunft/fehler.js'
import { ladeFuerProjekt } from '../services/unterkunft/kontext.js'
import { berechnePreis, preiseVollstaendig, summiere } from '../services/unterkunft/preis.js'
import { unterkunftFuerKunde } from '../services/unterkunft/schalter.js'
import * as sp from '../services/unterkunft/speicher.js'
import * as vw from '../services/unterkunft/verwaltung.js'
/**
* Kunden-API des Buchungskalenders: /api/unterkunft (Session, Projekt per ?projectId=; Vertrag: docs/funktionen/unterkunft-api.md).
* Geruest (Task 5): Task 8 fuellt Status, Einstellungen, Unterkuenfte, Preise, Kalender, Sperren, iCal, Anfragen, Rechnungen
* und Stripe; Task 11 die Anfrage-Funktion (/anfrage-funktion).
* Kunden-API des Buchungskalenders: /api/unterkunft (Session, Projekt per ?projectId= oder Body; Vertrag: docs/funktionen/unterkunft-api.md,
* Antwortformen: ui-erwartungen.md). Jede Route: Sitzung, eigenes Projekt (403), Funktion vorhanden und nicht off (409 nicht_aktiv).
* Schreibende Routen zusaetzlich: nur der Kunde selbst (nicht Kundenansicht/"Ansehen als"), Zustand setup|active, Schreibgrenze
* (300 je Kunde und Stunde). Alle IDs aus dem Pfad werden vor jedem Zugriff geprueft und gehoeren zum Projekt (sonst 404).
* Fehler: { fehler: code, felder? }; Texte von Appwrite oder Stripe verlassen den Server nie. Die Anfrage-Funktion (/anfrage-funktion) folgt in Task 11.
*/
const router = Router()
// Jede Route hier braucht eine Sitzung (Task 8 darf das nicht vergessen); Kunden-Pruefung je Route wie in routes/shop.js
// Jede Route hier braucht eine Sitzung; Kunden-Pruefung je Route (nurKunde)
router.use(requireSession)
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
const MAX_WRITES_PER_HOUR = 300
const MELDUNG = {
kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
stripeFern: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.',
plattform: 'Die Stripe-Anbindung ist bei WEBklar noch nicht fertig eingerichtet. Wir kümmern uns darum – schreib uns gern im Chat.',
erstattung: 'Die Stornierung wurde durchgeführt, aber die Erstattung ist fehlgeschlagen. Bitte erstatte den Betrag im Stripe-Dashboard oder versuch es später noch einmal.',
}
const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetUnterkunftRoutesForTests() {
schreibLimiter.reset()
}
const nurKunde = (req, res, next) => {
if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ fehler: 'kundenansicht', error: MELDUNG.kundenansicht })
return next()
}
const schreib = [nurKunde, limitWrites]
/** Fachfehler und 403 wie sie sind, alles andere 500 ohne Rohtext. */
function fail(res, err) {
if (err instanceof UkFehler) {
const body = { fehler: err.code }
if (err.felder && Object.keys(err.felder).length) body.felder = err.felder
if (err.status === 502 && err.code === 'erstattung_fehlgeschlagen') body.meldung = MELDUNG.erstattung
return res.status(err.status).json(body)
}
if (err?.status === 403 && !err.type) return res.status(403).json({ fehler: 'kein_zugriff', error: err.message })
if (err?.fachlich) return res.status(err.status).json({ fehler: err.fachlich, ...(err.meldung ? { meldung: err.meldung } : {}) })
console.error(`[unterkunft] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim())
return res.status(500).json({ fehler: 'intern', error: 'Interner Serverfehler' })
}
const route = (fn) => async (req, res) => {
try {
await fn(req, res)
} catch (err) {
fail(res, err)
}
}
const stop = (status, fachlich, extra = {}) => Object.assign(new Error(fachlich), { status, fachlich, ...extra })
const ungueltig = (felder) => new UkFehler(400, 'ungueltig', felder)
const nichtGefunden = () => new UkFehler(404, 'nicht_gefunden')
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {})
/** Eigenes Projekt (403) und Funktion des Projekts; schreiben verlangt Zustand setup|active. */
async function mitProjekt(req, { schreiben = false } = {}) {
const projectId = projectIdOf(req)
if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' })
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId)
const ctx = await ladeFuerProjekt(customerId, projectId)
if (!ctx || !unterkunftFuerKunde(ctx.besitzerEmail)) throw new UkFehler(409, 'nicht_aktiv')
if (schreiben && !['setup', 'active'].includes(ctx.state)) throw new UkFehler(409, 'nicht_aktiv')
return ctx
}
/** Unterkunft aus dem Pfad: ID geprueft, gehoert zum Projekt (sonst 404). */
async function eigeneUnterkunft(ctx, id) {
if (typeof id !== 'string' || !sp.ID_RE.test(id)) throw nichtGefunden()
const u = await sp.ladeUnterkunft(id)
if (!u || u.featureId !== ctx.featureId) throw nichtGefunden()
return u
}
async function eigeneAnfrage(ctx, id) {
if (typeof id !== 'string' || !sp.ID_RE.test(id)) throw nichtGefunden()
const a = await sp.ladeAnfrage(id)
if (!a || a.featureId !== ctx.featureId) throw nichtGefunden()
return a
}
const sperreKey = (id) => ['unterkunft:' + id]
// ---- Ansichten (nie tokenHash, ipHash, Konto-ID, Stripe-IDs)
const gastAnsicht = (g) => ({ name: g?.name ?? '', firma: g?.firma ?? '', email: g?.email ?? '', telefon: g?.telefon ?? '', nachricht: g?.nachricht ?? '' })
function unterkunftAnsicht(u) {
return {
id: u.$id,
name: u.name ?? '',
beschreibung: u.beschreibung ?? '',
maxPersonen: u.maxPersonen ?? 1,
mindestNaechte: u.mindestNaechte ?? 1,
checkIn: u.checkIn ?? '',
checkOut: u.checkOut ?? '',
sort: u.sort ?? 0,
aktiv: u.aktiv === true,
icalImporte: (Array.isArray(u.icalImporte) ? u.icalImporte : []).map((i) => ({
id: i.id,
name: i.name ?? '',
url: i.url ?? '',
letzterAbrufAt: i.letzterAbrufAt ?? null,
letzterErfolgAt: i.letzterErfolgAt ?? null,
fehler: i.fehler ?? '',
})),
icalExportUrl: vw.exportUrl(u.icalExportToken),
}
}
function anfrageAnsicht(a, { unterkunftName = '', rechnungNummer = null, stornoRechnungId = null, steuerart = null } = {}) {
return {
id: a.$id,
nummer: a.nummer ?? '',
status: a.status ?? '',
von: a.von,
bis: a.bis,
naechte: istTag(a.von) && istTag(a.bis) ? naechte(a.von, a.bis) : 0,
personen: a.personen ?? 0,
unterkunftId: a.unterkunftId ?? '',
unterkunftName,
gast: gastAnsicht(a.gast),
rechnungsadresse: a.rechnungsadresse ?? null,
posten: a.posten ?? null,
summen: a.summen ?? null,
steuerart: a.summen?.steuerart ?? steuerart,
zahlart: a.zahlart || null,
bezahltVia: a.bezahltVia || null,
bezahltAt: a.bezahltAt || null,
erstattetCent: a.erstattetCent ?? 0,
rechnungId: a.rechnungId || null,
rechnungNummer,
stornoRechnungId,
fristAt: a.fristAt || null,
nachrichtVermieter: a.nachrichtVermieter ?? '',
}
}
/** Anfrage mit Namen der Unterkunft und Rechnungsnummern (nur Rechnungen dieses Projekts). */
async function volleAnfrageAnsicht(ctx, a, preise) {
const [u, re, storno] = await Promise.all([
sp.ladeUnterkunft(a.unterkunftId),
a.rechnungId ? sp.ladeRechnung(a.rechnungId) : null,
a.status === 'storniert' ? sp.findeRechnung(a.$id, 'storno') : null,
])
return anfrageAnsicht(a, {
unterkunftName: u?.name ?? '',
rechnungNummer: re && re.featureId === ctx.featureId ? re.nummer : null,
stornoRechnungId: storno && storno.featureId === ctx.featureId ? storno.$id : null,
steuerart: preise?.steuerart ?? null,
})
}
const preiseAnsicht = (p) =>
p
? {
staffeln: Array.isArray(p.staffeln) ? p.staffeln : [],
reinigungCent: p.reinigungCent ?? 0,
personenInklusive: p.personenInklusive ?? 1,
aufpreisPersonNachtCent: p.aufpreisPersonNachtCent ?? 0,
direktrabattProzent: p.direktrabattProzent ?? 0,
steuerart: p.steuerart ?? 'regel',
ustUebernachtung: p.ustUebernachtung ?? 0,
ustReinigung: p.ustReinigung ?? 0,
fristTage: p.fristTage ?? 3,
stornobedingungen: p.stornobedingungen ?? '',
}
: null
const STRIPE_FELDER = ['charges_enabled', 'payouts_enabled', 'details_submitted', 'currently_due', 'past_due', 'disabled_reason', 'geprueftAt']
const stripeStatusKnapp = (s) => (s && typeof s === 'object' ? Object.fromEntries(STRIPE_FELDER.filter((k) => Object.hasOwn(s, k)).map((k) => [k, s[k]])) : null)
const stripeZustand = (einst) => (!einst?.stripeAccountId ? 'keins' : einst.stripeStatus?.charges_enabled === true ? 'aktiv' : 'onboarding')
/** shopConfig mit Stripe-Konto desselben Projekts (shopConfig hat die ID des Shop-Funktionsdokuments), sonst null. */
async function shopMitKonto(customerId, projectId) {
const doc = await getFeatureDoc(customerId, projectId, 'shop')
if (!doc) return null
try {
const shop = await getDocument(config.collections.shopConfig, doc.$id)
return typeof shop?.stripeAccountId === 'string' && shop.stripeAccountId ? shop : null
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
// ---- Status
router.get('/status', route(async (req, res) => {
const projectId = projectIdOf(req)
if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' })
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId)
const doc = await getFeatureDoc(customerId, projectId, 'unterkunft')
const state = doc?.state ?? 'off'
const antwort = { state, stripe: { status: 'keins', vorhandenesShopKonto: false }, onlineZahlung: false, unterkuenfte: 0, rechnungenVorhanden: false }
if (!doc || state === 'off') return res.json(antwort)
const [einst, unterkuenfte, rechnungen, shop] = await Promise.all([
sp.ladeEinstellungen(doc.$id),
sp.listeUnterkuenfte(doc.$id),
sp.listeRechnungen(doc.$id),
shopMitKonto(customerId, projectId),
])
antwort.stripe = { status: stripeZustand(einst), vorhandenesShopKonto: Boolean(shop) }
antwort.onlineZahlung = einst?.onlineZahlung === true
antwort.unterkuenfte = unterkuenfte.length
antwort.rechnungenVorhanden = rechnungen.length > 0
for (const u of unterkuenfte) {
const p = await sp.ladePreise(u.$id)
if (p?.steuerart) {
antwort.steuerart = p.steuerart
break
}
}
res.json(antwort)
}))
// ---- Einstellungen
function einstellungenAntwort(einst) {
const e = einst ?? {}
const ansicht = Object.fromEntries(vw.EINSTELLUNG_FELDER.map((k) => [k, e[k] ?? '']))
ansicht.onlineZahlung = e.onlineZahlung === true
ansicht.rechnungsPraefixFrei = vw.zaehlerLeer(e)
ansicht.stripe = { status: stripeZustand(e) }
return { einstellungen: ansicht, vollstaendig: vw.pruefeEinstellungen({}, e).werte !== undefined }
}
router.get('/einstellungen', route(async (req, res) => {
const ctx = await mitProjekt(req)
res.json(einstellungenAntwort(await sp.ladeEinstellungen(ctx.featureId)))
}))
router.put('/einstellungen', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const eingabe = body(req).einstellungen && typeof body(req).einstellungen === 'object' ? body(req).einstellungen : body(req)
const neu = await withLocks(['uk-einst:' + ctx.featureId], async () => {
const vorher = (await sp.ladeEinstellungen(ctx.featureId)) ?? {}
const r = vw.pruefeEinstellungen(eingabe, vorher)
if (r.fehler) throw ungueltig(r.fehler)
if (r.werte.rechnungsPraefix !== undefined && r.werte.rechnungsPraefix !== (vorher.rechnungsPraefix ?? '') && !vw.zaehlerLeer(vorher)) {
throw new UkFehler(409, 'praefix_gesperrt', { rechnungsPraefix: 'Das Präfix kann nach der ersten Rechnung nicht mehr geändert werden.' })
}
return sp.speichereEinstellungen(ctx.featureId, r.werte)
})
res.json(einstellungenAntwort(neu))
}))
// ---- Unterkuenfte
router.get('/unterkuenfte', route(async (req, res) => {
const ctx = await mitProjekt(req)
res.json({ unterkuenfte: (await sp.listeUnterkuenfte(ctx.featureId)).map(unterkunftAnsicht) })
}))
router.post('/unterkuenfte', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const r = vw.pruefeUnterkunft(body(req))
if (r.fehler) throw ungueltig(r.fehler)
const u = await withLocks(['uk-neu:' + ctx.featureId], async () => {
const alle = await sp.listeUnterkuenfte(ctx.featureId)
if (alle.length >= vw.MAX_UNTERKUENFTE) throw new UkFehler(409, 'limit_erreicht')
const werte = { ...r.werte }
if (body(req).sort === undefined) werte.sort = alle.reduce((m, x) => Math.max(m, x.sort ?? 0), 0) + 1
return sp.speichereUnterkunft(ctx.featureId, {
...werte,
customerId: ctx.customerId,
projectId: ctx.projectId,
icalImporte: [],
icalExportToken: vw.neuesExportToken(),
})
})
res.status(201).json({ unterkunft: unterkunftAnsicht(u) })
}))
router.put('/unterkuenfte/:id', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const r = vw.pruefeUnterkunft(body(req), { partiell: true })
if (r.fehler) throw ungueltig(r.fehler)
const neu = await sp.speichereUnterkunft(ctx.featureId, r.werte, u.$id)
res.json({ unterkunft: unterkunftAnsicht(neu) })
}))
// Loeschen nur ohne Anfragen (sonst deaktivieren: aktiv:false); die Oberflaeche deaktiviert nur
router.delete('/unterkuenfte/:id', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
await withLocks(sperreKey(u.$id), async () => {
if ((await sp.listeAnfragen(ctx.featureId, { unterkunftId: u.$id })).length > 0) throw new UkFehler(409, 'hat_anfragen')
for (const s of await sp.listeSperren(u.$id)) await sp.loescheSperre(s.$id)
await sp.loeschePreise(u.$id)
await sp.loescheUnterkunft(u.$id)
})
res.json({ ok: true })
}))
// ---- Preise
router.get('/unterkuenfte/:id/preise', route(async (req, res) => {
const ctx = await mitProjekt(req)
const u = await eigeneUnterkunft(ctx, req.params.id)
res.json({ preise: preiseAnsicht(await sp.ladePreise(u.$id)) })
}))
router.put('/unterkuenfte/:id/preise', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const eingabe = body(req).preise && typeof body(req).preise === 'object' ? body(req).preise : body(req)
const r = vw.pruefePreise(eingabe)
if (r.fehler) throw ungueltig(r.fehler)
const neu = await sp.speicherePreise(u.$id, ctx.featureId, r.werte)
res.json({ preise: preiseAnsicht(neu) })
}))
// ---- Kalender, Sperren
const FENSTER_MAX_TAGE = 100
router.get('/unterkuenfte/:id/kalender', route(async (req, res) => {
const ctx = await mitProjekt(req)
const u = await eigeneUnterkunft(ctx, req.params.id)
const { von, bis } = req.query
if (!istTag(von) || !istTag(bis) || naechte(von, bis) < 1 || naechte(von, bis) > FENSTER_MAX_TAGE) {
throw ungueltig({ zeitraum: 'Bitte einen gültigen Zeitraum von höchstens 100 Tagen angeben.' })
}
res.json({ bereiche: await vw.kalenderBereiche(u.$id, von, bis) })
}))
router.post('/unterkuenfte/:id/sperren', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const r = vw.pruefeSperre(body(req))
if (r.fehler) throw ungueltig(r.fehler)
const sperre = await withLocks(sperreKey(u.$id), async () => {
if (!(await istFrei(u.$id, r.werte.von, r.werte.bis))) throw new UkFehler(409, 'zeitraum_belegt')
return sp.legeSperreAn({ unterkunftId: u.$id, featureId: ctx.featureId, von: r.werte.von, bis: r.werte.bis, quelle: 'manuell', notiz: r.werte.notiz })
})
res.status(201).json({ sperre: { id: sperre.$id, von: sperre.von, bis: sperre.bis, notiz: sperre.notiz ?? '' } })
}))
router.delete('/sperren/:id', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
if (typeof req.params.id !== 'string' || !sp.ID_RE.test(req.params.id)) throw nichtGefunden()
const s = await sp.ladeSperre(req.params.id)
if (!s || s.featureId !== ctx.featureId) throw nichtGefunden()
if (String(s.quelle ?? 'manuell').startsWith('ical')) throw new UkFehler(409, 'sperre_aus_ical')
await sp.loescheSperre(s.$id)
res.json({ ok: true })
}))
// ---- iCal
router.post('/unterkuenfte/:id/ical-importe', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u0 = await eigeneUnterkunft(ctx, req.params.id)
const r = vw.pruefeIcalImport(body(req))
if (r.fehler) throw ungueltig(r.fehler)
const eintrag = await withLocks(sperreKey(u0.$id), async () => {
const u = await sp.ladeUnterkunft(u0.$id)
const bisher = Array.isArray(u.icalImporte) ? u.icalImporte : []
if (bisher.length >= vw.MAX_ICAL_IMPORTE) throw new UkFehler(409, 'limit_erreicht')
const neu = { id: vw.neueImportId(), name: r.werte.name, url: r.werte.url, letzterAbrufAt: null, letzterErfolgAt: null, fehler: '' }
await sp.speichereUnterkunft(ctx.featureId, { icalImporte: [...bisher, neu] }, u.$id)
return neu
})
res.status(201).json({ import: eintrag })
}))
router.delete('/unterkuenfte/:id/ical-importe/:importId', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u0 = await eigeneUnterkunft(ctx, req.params.id)
const importId = req.params.importId
if (typeof importId !== 'string' || !sp.ID_RE.test(importId)) throw nichtGefunden()
await withLocks(sperreKey(u0.$id), async () => {
const u = await sp.ladeUnterkunft(u0.$id)
const bisher = Array.isArray(u.icalImporte) ? u.icalImporte : []
if (!bisher.some((i) => i.id === importId)) throw nichtGefunden()
await sp.speichereUnterkunft(ctx.featureId, { icalImporte: bisher.filter((i) => i.id !== importId) }, u.$id)
for (const s of await sp.listeSperren(u.$id)) if (s.quelle === `ical:${importId}`) await sp.loescheSperre(s.$id)
})
res.json({ ok: true })
}))
router.post('/unterkuenfte/:id/ical-export/erneuern', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const neu = await sp.speichereUnterkunft(ctx.featureId, { icalExportToken: vw.neuesExportToken() }, u.$id)
res.json({ icalExportUrl: vw.exportUrl(neu.icalExportToken) })
}))
// ---- Preisvorschau (rechnet nur, schreibt nichts)
router.post('/preisvorschau', route(async (req, res) => {
const ctx = await mitProjekt(req)
const b = body(req)
const u = await eigeneUnterkunft(ctx, b.unterkunftId)
if (!istTag(b.von) || !istTag(b.bis) || naechte(b.von, b.bis) < 1 || naechte(b.von, b.bis) > 365) throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' })
if (!Number.isInteger(b.personen) || b.personen < 1 || b.personen > 50) throw ungueltig({ personen: 'Ungültige Personenzahl.' })
const preise = await sp.ladePreise(u.$id)
if (!preiseVollstaendig(preise)) throw new UkFehler(409, 'preise_unvollstaendig')
res.json(berechnePreis({ preise, von: b.von, bis: b.bis, personen: b.personen }))
}))
// ---- Anfragen
const FILTER = { offen: ['neu', 'freigegeben'], gebucht: ['gebucht', 'bezahlt'] }
router.get('/anfragen', route(async (req, res) => {
const ctx = await mitProjekt(req)
const filter = req.query.filter === undefined ? 'alle' : String(req.query.filter)
if (filter !== 'alle' && !FILTER[filter]) throw ungueltig({ filter: 'Ungültiger Filter.' })
const [liste, unterkuenfte] = await Promise.all([sp.listeAnfragen(ctx.featureId, { status: FILTER[filter] }), sp.listeUnterkuenfte(ctx.featureId)])
const namen = new Map(unterkuenfte.map((u) => [u.$id, u.name ?? '']))
liste.sort((a, b) => String(b.$createdAt ?? '').localeCompare(String(a.$createdAt ?? '')) || String(b.nummer).localeCompare(String(a.nummer)))
res.json({
anfragen: liste.map((a) => ({
id: a.$id,
nummer: a.nummer ?? '',
von: a.von,
bis: a.bis,
naechte: istTag(a.von) && istTag(a.bis) ? naechte(a.von, a.bis) : 0,
personen: a.personen ?? 0,
gast: { name: a.gast?.name ?? '' },
status: a.status ?? '',
summen: a.summen && typeof a.summen.bruttoCent === 'number' ? { bruttoCent: a.summen.bruttoCent } : null,
unterkunftName: namen.get(a.unterkunftId) ?? '',
})),
})
}))
function vorschlagFuer(preise, a) {
const steuerart = preise?.steuerart ?? 'regel'
if (preiseVollstaendig(preise) && istTag(a.von) && istTag(a.bis) && naechte(a.von, a.bis) >= 1) {
return { ...berechnePreis({ preise, von: a.von, bis: a.bis, personen: a.personen }), steuerart }
}
return { posten: [], summen: summiere([], steuerart), steuerart }
}
router.get('/anfragen/:id', route(async (req, res) => {
const ctx = await mitProjekt(req)
const a = await eigeneAnfrage(ctx, req.params.id)
const preise = await sp.ladePreise(a.unterkunftId)
res.json({ anfrage: await volleAnfrageAnsicht(ctx, a, preise), vorschlag: vorschlagFuer(preise, a) })
}))
router.post('/anfragen/:id/freigeben', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const b = body(req)
if (b.fristTage !== undefined && b.fristTage !== null && (!Number.isInteger(b.fristTage) || b.fristTage < 1 || b.fristTage > 14)) {
throw ungueltig({ fristTage: 'Die Frist muss 1 bis 14 Tage betragen.' })
}
if (b.nachricht !== undefined && b.nachricht !== null && (typeof b.nachricht !== 'string' || b.nachricht.length > 2000)) {
throw ungueltig({ nachricht: 'Die Nachricht darf höchstens 2000 Zeichen lang sein.' })
}
const { anfrage, token } = await ablauf.gibFrei(ctx, a.$id, { posten: b.posten, fristTage: b.fristTage ?? undefined, nachricht: b.nachricht ?? '' })
const preise = await sp.ladePreise(anfrage.unterkunftId)
res.json({
anfrage: await volleAnfrageAnsicht(ctx, anfrage, preise),
buchungsLink: `${config.publicBaseUrl}/unterkunft/${ctx.siteKey}/buchung/${anfrage.$id}?t=${token}`,
})
}))
router.post('/anfragen/:id/ablehnen', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const t = body(req).text
if (t !== undefined && t !== null && (typeof t !== 'string' || t.length > 2000)) throw ungueltig({ text: 'Der Text darf höchstens 2000 Zeichen lang sein.' })
const neu = await ablauf.lehneAb(ctx, a.$id, { text: t ?? '' })
res.json({ anfrage: await volleAnfrageAnsicht(ctx, neu, await sp.ladePreise(neu.unterkunftId)) })
}))
router.post('/anfragen/:id/bezahlt', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const neu = await ablauf.markiereBezahlt(ctx, a.$id, { via: 'vorort' })
res.json({ anfrage: await volleAnfrageAnsicht(ctx, neu, await sp.ladePreise(neu.unterkunftId)) })
}))
router.post('/anfragen/:id/stornieren', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const b = body(req)
const grund = b.grund === undefined || b.grund === null ? '' : b.grund
if (typeof grund !== 'string' || grund.length > 500) throw ungueltig({ grund: 'Der Grund darf höchstens 500 Zeichen lang sein.' })
const erstattungCent = b.erstattungCent === undefined || b.erstattungCent === null ? 0 : b.erstattungCent
if (!Number.isInteger(erstattungCent) || erstattungCent < 0) throw ungueltig({ erstattungCent: 'Ungültiger Betrag.' })
const { anfrage, stornoRechnung } = await ablauf.storniere(ctx, a.$id, { grund, erstattungCent })
res.json({ anfrage: await volleAnfrageAnsicht(ctx, anfrage, await sp.ladePreise(anfrage.unterkunftId)), stornoRechnungId: stornoRechnung?.$id ?? null })
}))
// ---- Rechnungen
router.get('/rechnungen', route(async (req, res) => {
const ctx = await mitProjekt(req)
const liste = await sp.listeRechnungen(ctx.featureId)
res.json({
rechnungen: liste.reverse().map((r) => ({
id: r.$id,
nummer: r.nummer ?? '',
art: r.art ?? 'rechnung',
datum: r.datum ?? '',
bruttoCent: r.bruttoCent ?? 0,
anfrageId: r.anfrageId ?? '',
anfrageNummer: r.inhalt?.anfrageNummer ?? '',
empfaenger: r.inhalt?.empfaenger?.firma || r.inhalt?.empfaenger?.name || '',
})),
})
}))
const PDF_RE = /^([A-Za-z0-9_-]{1,36})\.pdf$/
router.get('/rechnungen/:datei', route(async (req, res) => {
const ctx = await mitProjekt(req)
const m = PDF_RE.exec(String(req.params.datei))
if (!m) throw nichtGefunden()
const dok = await sp.ladeRechnung(m[1])
if (!dok || dok.featureId !== ctx.featureId) throw nichtGefunden()
let modul
try {
modul = await import('../services/unterkunft/rechnungPdf.js')
} catch {
return res.status(501).json({ fehler: 'nicht_verfuegbar' })
}
const art = dok.art === 'storno' ? 'storno' : 'rechnung'
const pdf = modul.rechnungPdf(dok.inhalt, { art, nummer: dok.nummer, datum: dok.datum })
const dateiname = `${art === 'storno' ? 'Stornorechnung' : 'Rechnung'}-${String(dok.nummer).replace(/[^A-Za-z0-9_-]/g, '')}.pdf`
res.set('Content-Type', 'application/pdf')
res.set('Content-Disposition', `inline; filename="${dateiname}"`)
res.send(pdf)
}))
// ---- Stripe
const PLATTFORM_FEHLT = /platform profile|signed up for Connect/i
/** Stripe-Aufruf: jeder Fehler von Stripe wird zu 502 mit festem Text (geloggt wird nur Typ und Code). */
async function ueberStripe(fn) {
try {
return await fn()
} catch (err) {
if (err instanceof UkFehler) throw err
if (err?.status >= 400 && err.status < 500 && !err.type) throw err
if (err?.type === 'StripeInvalidRequestError' && PLATTFORM_FEHLT.test(String(err.message || ''))) {
console.error('[unterkunft] Stripe Connect: Plattformprofil fehlt')
throw stop(503, 'plattform_nicht_bereit', { meldung: MELDUNG.plattform })
}
console.error(`[unterkunft] Stripe ${String(err?.type ?? err?.name ?? 'Fehler').slice(0, 60)} ${String(err?.code ?? '').slice(0, 60)}`.trim())
throw stop(502, 'stripe_nicht_erreichbar', { meldung: MELDUNG.stripeFern })
}
}
const stripeRueckUrl = (projectId, was) => `${config.publicBaseUrl}/dashboard.html#buchungen?stripe=${was}&projekt=${encodeURIComponent(projectId)}`
router.post('/stripe/verbinden', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const accountId = await withLocks(['uk-stripe:' + ctx.featureId], async () => {
const einst = await sp.ladeEinstellungen(ctx.featureId)
if (einst?.stripeAccountId) return einst.stripeAccountId
const customer = await getDocument(config.collections.customers, ctx.customerId).catch(() => null)
const neu = await ueberStripe(() =>
kontoAnlegen({
customer: customer || { $id: ctx.customerId, email: req.session?.email },
project: ctx.project,
webklar: 'unterkunft',
idempotencyKey: 'uk-konto-' + ctx.projectId,
})
)
let status
try {
status = await ueberStripe(() => kontoStatus(neu))
} catch {
status = statusAusKonto({})
}
await sp.speichereEinstellungen(ctx.featureId, { stripeAccountId: neu, stripeStatus: JSON.stringify(status) })
return neu
})
const url = await ueberStripe(() => onboardingLink(accountId, { returnUrl: stripeRueckUrl(ctx.projectId, 'zurueck'), refreshUrl: stripeRueckUrl(ctx.projectId, 'neu') }))
res.json({ url })
}))
router.post('/stripe/vorhandenes-konto', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const shop = await shopMitKonto(ctx.customerId, ctx.projectId)
if (!shop) throw new UkFehler(409, 'kein_konto')
const einst = await withLocks(['uk-stripe:' + ctx.featureId], async () => {
const vorher = await sp.ladeEinstellungen(ctx.featureId)
if (vorher?.stripeAccountId && vorher.stripeAccountId !== shop.stripeAccountId) throw new UkFehler(409, 'konto_vorhanden')
return sp.speichereEinstellungen(ctx.featureId, {
stripeAccountId: shop.stripeAccountId,
stripeStatus: typeof shop.stripeStatus === 'string' && shop.stripeStatus ? shop.stripeStatus : JSON.stringify(statusAusKonto({})),
})
})
res.json({ stripe: { status: stripeZustand(einst), details: stripeStatusKnapp(einst.stripeStatus) } })
}))
router.post('/stripe/aktualisieren', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const einst = await sp.ladeEinstellungen(ctx.featureId)
if (!einst?.stripeAccountId) throw new UkFehler(409, 'kein_konto')
const status = await ueberStripe(() => kontoStatus(einst.stripeAccountId))
const neu = await sp.speichereEinstellungen(ctx.featureId, { stripeStatus: JSON.stringify(status) })
res.json({ stripe: { status: stripeZustand(neu), details: stripeStatusKnapp(neu.stripeStatus) } })
}))
export default router
+3 -3
View File
@@ -41,7 +41,7 @@ function liveUrl(project) {
}
/** Legt das Haendlerkonto an und liefert die Konto-ID. Idempotent je Projekt (Stripe liefert bei gleichem Schluessel dasselbe Konto). */
export async function kontoAnlegen({ customer, project }) {
export async function kontoAnlegen({ customer, project, webklar = 'shop', idempotencyKey } = {}) {
const customerId = String(customer?.$id || customer?.id || '')
const projectId = String(project?.$id || project?.id || '')
if (!projectId) throw fehler(400, 'Projekt fehlt.')
@@ -60,9 +60,9 @@ export async function kontoAnlegen({ customer, project }) {
requirement_collection: 'stripe',
},
capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
metadata: { webklar: 'shop', customerId, projectId },
metadata: { webklar, customerId, projectId },
},
{ idempotencyKey: `shop-konto-${projectId}` }
{ idempotencyKey: idempotencyKey || `shop-konto-${projectId}` }
)
return account.id
}
+8
View File
@@ -83,6 +83,8 @@ export async function speichereUnterkunft(featureId, felder, id) {
return lesen('ukUnterkuenfte', await createDocument(config.collections.ukUnterkuenfte, { ...daten, featureId }))
}
export const loescheUnterkunft = (id) => deleteDocument(config.collections.ukUnterkuenfte, id)
// ---- ukPreise (1:1 je Unterkunft)
async function preiseDok(unterkunftId) {
@@ -99,6 +101,11 @@ export async function speicherePreise(unterkunftId, featureId, felder) {
return lesen('ukPreise', await createDocument(config.collections.ukPreise, { ...daten, unterkunftId, featureId }))
}
export async function loeschePreise(unterkunftId) {
const vorhanden = await preiseDok(unterkunftId)
if (vorhanden) await deleteDocument(config.collections.ukPreise, vorhanden.$id)
}
// ---- ukSperren
export const listeSperren = (unterkunftId) => liste('ukSperren', [Query.equal('unterkunftId', unterkunftId)])
@@ -108,6 +115,7 @@ export async function legeSperreAn(felder) {
}
export const loescheSperre = (id) => deleteDocument(config.collections.ukSperren, id)
export const ladeSperre = (id) => ladeDok('ukSperren', id)
// ---- ukAnfragen
+289
View File
@@ -0,0 +1,289 @@
import { randomBytes } from 'node:crypto'
import { config } from '../../config.js'
import { istTag, naechte } from './datum.js'
import { AKTIVE_STATUS, ueberschneidet } from './belegung.js'
import { listeAnfragenNachStatus, listeSperren } from './speicher.js'
/**
* Buchungskalender, Verwaltung durch den Vermieter: Validierung der Eingaben (reine Funktionen, Rueckgabe { werte } oder
* { fehler: { feld: Text } }) und Aufbereitung von Kalender und Ansichten. Speichern und Rechte liegen in routes/unterkunft.js.
*/
const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max + 1) : '')
const istText = (v) => typeof v === 'string'
const ganz = (v) => Number.isSafeInteger(v)
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
const UHRZEIT_RE = /^([01]\d|2[0-3]):[0-5]\d$/
const FARBE_RE = /^#[0-9a-fA-F]{6}$/
const PRAEFIX_RE = /^[A-Za-z0-9]{1,8}$/
const IBAN_RE = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/
const BIC_RE = /^[A-Z0-9]{8}([A-Z0-9]{3})?$/
const UST_ID_RE = /^[A-Z]{2}[A-Z0-9]{2,18}$/
const SAETZE = [0, 7, 19]
export const MAX_UNTERKUENFTE = 20
export const MAX_ICAL_IMPORTE = 5
export const MAX_STAFFELN = 6
// ---- Einstellungen
const EINSTELLUNG_TEXTE = [
['firma', 150, 'Firma'],
['inhaber', 150, 'Inhaber'],
['strasse', 150, 'Straße'],
['plz', 10, 'PLZ'],
['ort', 100, 'Ort'],
['email', 200, 'E-Mail'],
['telefon', 50, 'Telefon'],
['steuernummer', 30, 'Steuernummer'],
['ustId', 20, 'USt-IdNr.'],
['iban', 34, 'IBAN'],
['bic', 11, 'BIC'],
['bank', 100, 'Bank'],
['rechnungsPraefix', 8, 'Präfix'],
['akzentfarbe', 7, 'Farbe'],
]
/** Felder der Einstellungen, die der Vermieter sieht und aendert (nie Stripe-Konto-ID). */
export const EINSTELLUNG_FELDER = EINSTELLUNG_TEXTE.map(([k]) => k)
const PFLICHT = ['firma', 'strasse', 'plz', 'ort', 'email']
/**
* Pruefung der Einstellungen. Nur mitgeschickte Felder werden geaendert; die Vollstaendigkeit (Pflichtangaben, Steuernummer
* oder USt-IdNr.) gilt fuer das Ergebnis aus vorher + eingabe. onlineZahlung nur mit verbundenem Stripe-Konto (vorher.stripeAccountId).
* @returns {{ werte: object } | { fehler: Record<string,string> }}
*/
export function pruefeEinstellungen(eingabe, vorher = {}) {
const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
const fehler = {}
const werte = {}
for (const [key, max, label] of EINSTELLUNG_TEXTE) {
if (e[key] === undefined) continue
if (!istText(e[key])) {
fehler[key] = `${label}: ungültige Eingabe.`
continue
}
let v = text(e[key], key === 'iban' ? 60 : max)
if (key === 'iban' || key === 'bic' || key === 'ustId') v = v.replace(/\s+/g, '').toUpperCase()
if (v.length > max) {
fehler[key] = `${label} darf höchstens ${max} Zeichen lang sein.`
continue
}
if (v === '') {
werte[key] = ''
continue
}
if (key === 'email' && !EMAIL_RE.test(v)) fehler[key] = 'Bitte eine gültige E-Mail-Adresse angeben.'
else if (key === 'akzentfarbe' && !FARBE_RE.test(v)) fehler[key] = 'Bitte eine Farbe wie #8a6a3f angeben.'
else if (key === 'rechnungsPraefix' && !PRAEFIX_RE.test(v)) fehler[key] = 'Das Präfix darf nur aus Buchstaben und Ziffern bestehen (höchstens 8).'
else if (key === 'iban' && !IBAN_RE.test(v)) fehler[key] = 'Bitte eine gültige IBAN angeben.'
else if (key === 'bic' && !BIC_RE.test(v)) fehler[key] = 'Bitte eine gültige BIC angeben.'
else if (key === 'ustId' && !UST_ID_RE.test(v)) fehler[key] = 'Bitte eine gültige USt-IdNr. angeben.'
else werte[key] = v
}
if (e.onlineZahlung !== undefined) {
if (typeof e.onlineZahlung !== 'boolean') fehler.onlineZahlung = 'Ungültige Eingabe.'
else if (e.onlineZahlung && !vorher.stripeAccountId) fehler.onlineZahlung = 'Dafür muss zuerst Stripe verbunden werden.'
else werte.onlineZahlung = e.onlineZahlung
}
const gesamt = { ...vorher, ...werte }
for (const k of PFLICHT) {
if (fehler[k] === undefined && !String(gesamt[k] ?? '').trim()) fehler[k] = 'Pflichtfeld.'
}
if (fehler.steuernummer === undefined && fehler.ustId === undefined && !String(gesamt.steuernummer ?? '').trim() && !String(gesamt.ustId ?? '').trim()) {
fehler.steuernummer = 'Bitte Steuernummer oder USt-IdNr. angeben (Pflicht auf Rechnungen).'
}
return Object.keys(fehler).length ? { fehler } : { werte }
}
/** Zaehler leer = noch keine Rechnung ausgestellt; erst dann ist das Praefix frei aenderbar. */
export const zaehlerLeer = (einst) => !einst?.rechnungZaehler || !(Number(einst.rechnungZaehler.n) > 0)
// ---- Unterkuenfte
/**
* Pruefung einer Unterkunft. partiell (PUT): nur mitgeschickte Felder pruefen; sonst name und maxPersonen Pflicht und
* Standardwerte fuer den Rest (mindestNaechte 1, Check-in 15:00, Check-out 10:00, sort 0, aktiv).
*/
export function pruefeUnterkunft(eingabe, { partiell = false } = {}) {
const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
const fehler = {}
const werte = {}
const da = (k) => e[k] !== undefined
if (da('name') || !partiell) {
const v = text(e.name, 120)
if (!v) fehler.name = 'Bitte einen Namen angeben.'
else if (v.length > 120) fehler.name = 'Der Name darf höchstens 120 Zeichen lang sein.'
else werte.name = v
}
if (da('beschreibung')) {
const v = istText(e.beschreibung) ? e.beschreibung.trim() : null
if (v === null || v.length > 500) fehler.beschreibung = 'Die Beschreibung darf höchstens 500 Zeichen lang sein.'
else werte.beschreibung = v
} else if (!partiell) werte.beschreibung = ''
if (da('maxPersonen') || !partiell) {
if (!ganz(e.maxPersonen) || e.maxPersonen < 1 || e.maxPersonen > 50) fehler.maxPersonen = 'Bitte 1 bis 50 Personen angeben.'
else werte.maxPersonen = e.maxPersonen
}
if (da('mindestNaechte')) {
if (!ganz(e.mindestNaechte) || e.mindestNaechte < 1 || e.mindestNaechte > 365) fehler.mindestNaechte = 'Bitte 1 bis 365 Nächte angeben.'
else werte.mindestNaechte = e.mindestNaechte
} else if (!partiell) werte.mindestNaechte = 1
for (const [k, std] of [['checkIn', '15:00'], ['checkOut', '10:00']]) {
if (da(k)) {
if (!istText(e[k]) || !UHRZEIT_RE.test(e[k].trim())) fehler[k] = 'Bitte eine Uhrzeit wie 15:00 angeben.'
else werte[k] = e[k].trim()
} else if (!partiell) werte[k] = std
}
if (da('sort')) {
if (!ganz(e.sort) || e.sort < 0 || e.sort > 9999) fehler.sort = 'Ungültige Reihenfolge.'
else werte.sort = e.sort
}
if (da('aktiv')) {
if (typeof e.aktiv !== 'boolean') fehler.aktiv = 'Ungültige Eingabe.'
else werte.aktiv = e.aktiv
} else if (!partiell) werte.aktiv = true
return Object.keys(fehler).length ? { fehler } : { werte }
}
// ---- Preise
/** Pruefung der Preise einer Unterkunft (Staffeln aufsteigend, erste ab 1 Nacht, hoechstens 6). */
export function pruefePreise(eingabe) {
const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
const fehler = {}
const werte = {}
const st = e.staffeln
if (!Array.isArray(st) || st.length === 0) fehler.staffeln = 'Mindestens eine Preisstufe ist nötig.'
else if (st.length > MAX_STAFFELN) fehler.staffeln = `Höchstens ${MAX_STAFFELN} Preisstufen sind erlaubt.`
else {
const sauber = []
let letzte = 0
for (const s of st) {
if (!s || typeof s !== 'object' || !ganz(s.abNaechten) || !ganz(s.preisProNachtCent)) {
fehler.staffeln = 'Ungültige Preisstufe.'
break
}
if (s.preisProNachtCent < 100 || s.preisProNachtCent > 100000) {
fehler.staffeln = 'Der Preis pro Nacht muss zwischen 1 € und 1.000 € liegen.'
break
}
if (sauber.length === 0 && s.abNaechten !== 1) {
fehler.staffeln = 'Die erste Preisstufe muss ab 1 Nacht gelten.'
break
}
if (s.abNaechten <= letzte || s.abNaechten > 365) {
fehler.staffeln = 'Die Preisstufen müssen aufsteigend nach Nächten geordnet sein.'
break
}
letzte = s.abNaechten
sauber.push({ abNaechten: s.abNaechten, preisProNachtCent: s.preisProNachtCent })
}
if (!fehler.staffeln) werte.staffeln = sauber
}
const zahl = (k, min, max, label, std) => {
const v = e[k] === undefined && std !== undefined ? std : e[k]
if (!ganz(v) || v < min || v > max) fehler[k] = `${label}: bitte einen Wert von ${min} bis ${max} angeben.`
else werte[k] = v
}
zahl('reinigungCent', 0, 100000, 'Endreinigung', 0)
zahl('personenInklusive', 1, 50, 'Inklusive Personen')
zahl('aufpreisPersonNachtCent', 0, 100000, 'Aufpreis je Person', 0)
zahl('direktrabattProzent', 0, 50, 'Direktrabatt', 0)
zahl('fristTage', 1, 14, 'Frist')
if (e.steuerart !== 'regel' && e.steuerart !== 'kleinunternehmer') fehler.steuerart = 'Bitte die Steuerart wählen.'
else werte.steuerart = e.steuerart
if (werte.steuerart === 'kleinunternehmer') {
werte.ustUebernachtung = 0
werte.ustReinigung = 0
} else {
for (const [k, std] of [['ustUebernachtung', 7], ['ustReinigung', 19]]) {
const v = e[k] === undefined ? std : e[k]
if (!SAETZE.includes(v)) fehler[k] = 'Der Steuersatz muss 0, 7 oder 19 sein.'
else werte[k] = v
}
}
const sb = text(e.stornobedingungen, 5000)
if (sb.length < 20 || sb.length > 5000) fehler.stornobedingungen = 'Die Stornobedingungen müssen 20 bis 5000 Zeichen lang sein.'
else werte.stornobedingungen = sb
return Object.keys(fehler).length ? { fehler } : { werte }
}
// ---- Sperren, iCal
/** Sperre [von, bis) mit Notiz; bis ist der Abreisetag (exklusiv). */
export function pruefeSperre(eingabe) {
const e = eingabe && typeof eingabe === 'object' ? eingabe : {}
const fehler = {}
if (!istTag(e.von)) fehler.von = 'Ungültiges Datum.'
if (!istTag(e.bis)) fehler.bis = 'Ungültiges Datum.'
if (!fehler.von && !fehler.bis) {
const n = naechte(e.von, e.bis)
if (n < 1) fehler.bis = 'Das Ende muss nach dem Anfang liegen.'
else if (n > 731) fehler.bis = 'Der Zeitraum ist zu lang.'
}
if (e.notiz !== undefined && !istText(e.notiz)) fehler.notiz = 'Ungültige Eingabe.'
const notiz = istText(e.notiz) ? e.notiz.trim() : ''
if (notiz.length > 200) fehler.notiz = 'Die Notiz darf höchstens 200 Zeichen lang sein.'
return Object.keys(fehler).length ? { fehler } : { werte: { von: e.von, bis: e.bis, notiz } }
}
/** Nur https, hoechstens 500 Zeichen, ohne Zugangsdaten in der Adresse. DNS-/SSRF-Pruefung folgt beim Abruf (Task 9). */
export function pruefeIcalUrl(url) {
if (!istText(url) || !url.trim()) return { fehler: { url: 'Bitte eine Adresse angeben.' } }
const u = url.trim()
if (u.length > 500) return { fehler: { url: 'Die Adresse darf höchstens 500 Zeichen lang sein.' } }
let p
try {
p = new URL(u)
} catch {
return { fehler: { url: 'Bitte eine gültige Adresse angeben.' } }
}
if (p.protocol !== 'https:' || !p.hostname) return { fehler: { url: 'Die Adresse muss mit https:// beginnen.' } }
if (p.username || p.password) return { fehler: { url: 'Die Adresse darf keine Zugangsdaten enthalten.' } }
return { werte: { url: u } }
}
export function pruefeIcalImport(eingabe) {
const e = eingabe && typeof eingabe === 'object' ? eingabe : {}
const u = pruefeIcalUrl(e.url)
const fehler = { ...(u.fehler ?? {}) }
const name = text(e.name, 60)
if (!name) fehler.name = 'Bitte einen Namen angeben (z. B. Booking.com).'
else if (name.length > 60) fehler.name = 'Der Name darf höchstens 60 Zeichen lang sein.'
return Object.keys(fehler).length ? { fehler } : { werte: { name, url: u.werte.url } }
}
export const neuesExportToken = () => randomBytes(32).toString('base64url')
export const neueImportId = () => randomBytes(6).toString('hex')
export const exportUrl = (token) => (token ? `${config.publicBaseUrl}/unterkunft/ical/${token}.ics` : '')
// ---- Kalender
/**
* Belegte Bereiche einer Unterkunft im Fenster [von, bis) mit Quelle, Anfragenummer, Sperr-ID und Notiz. Wie belegung.js
* (gebucht/bezahlt = gebucht, freigegeben mit Frist in der Zukunft = reserviert), aber mit den Angaben fuer die Verwaltung.
*/
export async function kalenderBereiche(unterkunftId, von, bis, { now = Date.now() } = {}) {
const jetzt = now instanceof Date ? now.getTime() : now
const fenster = { von, bis }
const roh = []
for (const s of await listeSperren(unterkunftId)) {
const quelle = String(s.quelle ?? '')
roh.push({
von: s.von,
bis: s.bis,
quelle: quelle.startsWith('ical') ? quelle : 'manuell',
sperreId: s.$id,
notiz: s.notiz ?? '',
})
}
for (const a of await listeAnfragenNachStatus(unterkunftId, [...AKTIVE_STATUS, 'freigegeben'])) {
if (a.status === 'freigegeben' && !(Date.parse(a.fristAt) > jetzt)) continue
roh.push({ von: a.von, bis: a.bis, quelle: a.status === 'freigegeben' ? 'reserviert' : 'gebucht', anfrageNummer: a.nummer })
}
return roh
.filter((r) => r.von && r.bis && ueberschneidet(r, fenster))
.map((r) => ({ ...r, von: r.von > von ? r.von : von, bis: r.bis < bis ? r.bis : bis }))
.sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : 0))
}