Buchungskalender: iCal-Import und -Export

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:32:34 +00:00
1 parent e3b399e003
commit f57a3d477e
13 files changed
+993 -7

No files matched your search

+43
View File
@@ -1,11 +1,54 @@
import { Router } from 'express'
import { config } from '../config.js'
import { listDocuments, Query } from '../services/appwriteAdmin.js'
import { baueIcs } from '../services/unterkunft/ical.js'
import { ladeFuerProjekt } from '../services/unterkunft/kontext.js'
import { unterkunftFuerKunde, unterkunftModus } from '../services/unterkunft/schalter.js'
import { listeAnfragenNachStatus, listeSperren } from '../services/unterkunft/speicher.js'
/**
* Oeffentliche Standalone-Seiten des Buchungskalenders: /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id
* (Vorbild: shopSeite.js). Geruest (Task 5): Task 7 und Strang 3 fuellen die Seiten.
* Davor steht der iCal-Export /unterkunft/ical/<token>.ics (Task 9): die Adresse ist das Geheimnis (Token, 43 Zeichen).
*/
const DATEI_RE = /^[A-Za-z0-9_-]{20,64}\.ics$/
const nicht = (res) => res.status(404).type('text/plain').set('Cache-Control', 'no-store').send('Not found')
async function exportEreignisse(unterkunft, jetzt) {
const out = []
for (const s of await listeSperren(unterkunft.$id)) {
const quelle = String(s.quelle ?? 'manuell')
if (quelle.startsWith('ical') || !s.von || !s.bis) continue // nie zurueckspielen, was importiert wurde
out.push({ uid: `${s.$id}@project.webklar.com`, von: s.von, bis: s.bis })
}
for (const a of await listeAnfragenNachStatus(unterkunft.$id, ['gebucht', 'bezahlt', 'freigegeben'])) {
if (a.status === 'freigegeben' && !(Date.parse(a.fristAt) > jetzt)) continue
if (!a.von || !a.bis) continue
out.push({ uid: `${a.$id}@project.webklar.com`, von: a.von, bis: a.bis })
}
return out.sort((x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.uid < y.uid ? -1 : 1))
}
export function unterkunftSeiteRouter() {
const router = Router()
router.get('/unterkunft/ical/:datei', async (req, res, next) => {
try {
if (unterkunftModus() === 'off' || !DATEI_RE.test(req.params.datei)) return nicht(res)
const token = req.params.datei.slice(0, -4)
const [u] = await listDocuments(config.collections.ukUnterkuenfte, [Query.equal('icalExportToken', token), Query.limit(1)])
if (!u || u.icalExportToken !== token) return nicht(res)
const ctx = await ladeFuerProjekt(u.customerId, u.projectId)
if (!ctx || ctx.featureId !== u.featureId || ctx.state !== 'active' || !unterkunftFuerKunde(ctx.besitzerEmail)) return nicht(res)
const jetzt = new Date()
const text = baueIcs({ name: u.name, ereignisse: await exportEreignisse(u, jetzt.getTime()), jetzt })
res.set({ 'Content-Type': 'text/calendar; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }).send(text)
} catch (err) {
next(err)
}
})
return router
}
+161
View File
@@ -0,0 +1,161 @@
import { heute as berlinHeute } from '../termine/zeit.js'
import { istTag, naechte, plusTage } from './datum.js'
import { config } from '../../config.js'
/**
* Buchungskalender: iCalendar lesen und schreiben (reine Funktionen, keine I/O). Gelesen werden nur VEVENT mit Anfangs- und
* Enddatum (DATE, lokale Zeit, TZID oder UTC); mehr braucht die Belegung nicht. Ergebnis sind Berliner Kalendertage [von, bis)
* wie bei allen Sperren. Wiederholungsregeln (RRULE) werden nicht aufgeloest: Booking.com und Airbnb liefern Einzeltermine.
*/
const MAX_EREIGNISSE = 2000
const MAX_NAECHTE = 731
/** Zeilenumbrueche entfalten: eine Zeile, die mit Leerzeichen oder Tab beginnt, setzt die vorige fort. */
function entfalte(text) {
const out = []
for (const z of text.split(/\r\n|\n|\r/)) {
if ((z.startsWith(' ') || z.startsWith('\t')) && out.length) out[out.length - 1] += z.slice(1)
else out.push(z)
}
return out
}
/** 'NAME;PARAM=x:Wert' => { name (gross), params, wert }. Doppelpunkte in Anfuehrungszeichen der Parameter zaehlen nicht. */
function zeile(z) {
let inAnf = false
let doppelpunkt = -1
for (let i = 0; i < z.length; i++) {
const c = z[i]
if (c === '"') inAnf = !inAnf
else if (c === ':' && !inAnf) {
doppelpunkt = i
break
}
}
if (doppelpunkt < 1) return null
const kopf = z.slice(0, doppelpunkt).split(';')
const params = {}
for (const p of kopf.slice(1)) {
const i = p.indexOf('=')
if (i > 0) params[p.slice(0, i).toUpperCase()] = p.slice(i + 1).replace(/^"|"$/g, '')
}
return { name: kopf[0].trim().toUpperCase(), params, wert: z.slice(doppelpunkt + 1).trim() }
}
const DATUM_RE = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/
/** iCal-Datum oder -Zeitpunkt => Berliner Kalendertag oder null. UTC (Z) wird nach Berlin umgerechnet, alles andere gilt als lokal. */
function tagVon(wert) {
const m = DATUM_RE.exec(wert)
if (!m) return null
const [, j, mo, t, h, mi, s, z] = m
if (z && h !== undefined) {
const d = new Date(Date.UTC(+j, +mo - 1, +t, +h, +mi, +(s ?? 0)))
return Number.isNaN(d.getTime()) ? null : berlinHeute(d)
}
const tag = `${j}-${mo}-${t}`
return istTag(tag) ? tag : null
}
function dauerTage(wert) {
const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T.*)?$/.exec(wert)
if (!m) return null
const tage = (m[1] ? +m[1] * 7 : 0) + (m[2] ? +m[2] : 0)
return tage > 0 ? tage : null
}
/**
* ICS-Text lesen. Ereignisse mit STATUS:CANCELLED, ohne lesbaren Anfang, die vor gestern enden oder nach dem Horizont beginnen
* fallen weg. Fehlt das Ende, ist es eine Nacht. Kein VCALENDAR (z. B. HTML-Fehlerseite) => Error('kein_ics').
* @param {string} text
* @param {{ heute?: string, horizontTage?: number }} [opt] heute: Berliner Tag (Vorgabe: jetzt); horizontTage: Vorgabe 31 x horizontMonate
*/
export function parseIcs(text, { heute = berlinHeute(), horizontTage = config.unterkunft.horizontMonate * 31 } = {}) {
if (typeof text !== 'string' || !/^\s*BEGIN:VCALENDAR/i.test(text.replace(/^\uFEFF/, ''))) throw new Error('kein_ics')
const gestern = plusTage(heute, -1)
const horizont = plusTage(heute, horizontTage)
const ereignisse = []
const gesehen = new Set()
let ev = null
let tiefe = 0
let nr = 0
for (const roh of entfalte(text.replace(/^\uFEFF/, ''))) {
const p = zeile(roh)
if (!p) continue
if (p.name === 'BEGIN') {
if (p.wert.toUpperCase() === 'VEVENT') {
ev = { uid: '', start: null, ende: null, dauer: null, status: '' }
tiefe = 0
} else if (ev) tiefe++
continue
}
if (p.name === 'END') {
if (p.wert.toUpperCase() === 'VEVENT' && ev) {
const e = ev
ev = null
nr++
if (e.status === 'CANCELLED' || !e.start) continue
let bis = e.ende
if (!bis && e.dauer) bis = plusTage(e.start, e.dauer)
if (!bis || bis <= e.start) bis = plusTage(e.start, 1)
if (naechte(e.start, bis) > MAX_NAECHTE) bis = plusTage(e.start, MAX_NAECHTE)
if (bis < gestern || e.start > horizont) continue
let uid = e.uid || `ohne-uid-${nr}`
if (gesehen.has(uid)) uid = `${uid}#${nr}`
gesehen.add(uid)
if (ereignisse.length < MAX_EREIGNISSE) ereignisse.push({ von: e.start, bis, uid })
} else if (ev && tiefe > 0) tiefe--
continue
}
if (!ev || tiefe > 0) continue
if (p.name === 'UID') ev.uid = p.wert.slice(0, 200)
else if (p.name === 'STATUS') ev.status = p.wert.toUpperCase()
else if (p.name === 'DTSTART') ev.start = tagVon(p.wert)
else if (p.name === 'DTEND') ev.ende = tagVon(p.wert)
else if (p.name === 'DURATION') ev.dauer = dauerTage(p.wert)
}
return { ereignisse }
}
// ---- Schreiben
/** Zeile auf hoechstens 75 Oktette falten (Fortsetzungszeilen beginnen mit einem Leerzeichen); schneidet nie mitten in ein Zeichen. */
function falte(z) {
const teile = []
let aktuell = ''
let bytes = 0
let limit = 75
for (const zeichen of z) {
const b = Buffer.byteLength(zeichen)
if (bytes + b > limit) {
teile.push(aktuell)
aktuell = ' '
bytes = 1
limit = 75
}
aktuell += zeichen
bytes += b
}
teile.push(aktuell)
return teile
}
const maskiere = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n')
const ohneTrenner = (tag) => tag.replace(/-/g, '')
const zeitstempel = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
/**
* Kalender mit ganztaegigen Ereignissen "Belegt" bauen (CRLF, gefaltet). Absichtlich keine Gastdaten.
* @param {{ name: string, ereignisse: {uid: string, von: string, bis: string}[], jetzt?: Date }} eingabe
*/
export function baueIcs({ name, ereignisse, jetzt = new Date() }) {
const stempel = zeitstempel(jetzt)
const z = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//WEBklar//Buchungskalender//DE', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH']
if (name) z.push(`X-WR-CALNAME:${maskiere(name)}`)
for (const e of ereignisse) {
z.push('BEGIN:VEVENT', `UID:${e.uid}`, `DTSTAMP:${stempel}`, `DTSTART;VALUE=DATE:${ohneTrenner(e.von)}`, `DTEND;VALUE=DATE:${ohneTrenner(e.bis)}`, 'SUMMARY:Belegt', 'TRANSP:OPAQUE', 'END:VEVENT')
}
z.push('END:VCALENDAR')
return z.flatMap(falte).join('\r\n') + '\r\n'
}
+367
View File
@@ -0,0 +1,367 @@
import dns from 'node:dns/promises'
import https from 'node:https'
import net from 'node:net'
import { Readable } from 'node:stream'
import { config } from '../../config.js'
import { listAllDocuments, Query } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { heute as berlinHeute } from '../termine/zeit.js'
import { parseIcs } from './ical.js'
import { ladeFuerProjekt } from './kontext.js'
import * as mails from './mails.js'
import { unterkunftFuerKunde, unterkunftModus } from './schalter.js'
import { ladeEinstellungen, ladeUnterkunft, legeSperreAn, listeSperren, listeUnterkuenfte, loescheSperre, speichereEinstellungen, speichereUnterkunft } from './speicher.js'
/**
* Buchungskalender: iCal-Import. holeIcs ruft eine fremde Adresse ab (Booking.com, Airbnb, ...) und ist deshalb gegen SSRF
* gehaertet: nur https auf Port 443, keine Zugangsdaten, der Host wird selbst aufgeloest, jede Adresse muss oeffentlich sein,
* und der Abruf verbindet sich genau mit der geprueften Adresse (kein zweites DNS, also kein Rebinding). Weiterleitungen
* (hoechstens 3) werden jeweils neu geprueft. Fehler tragen einen Code (err.code) und nie die Adresse: Booking- und
* Airbnb-Adressen enthalten ein Geheimnis, deshalb wird nirgends eine volle Adresse protokolliert (nur der Host).
*/
const MAX_WEITERLEITUNGEN = 3
const TAG_MS = 24 * 3600 * 1000
const MAX_IMPORTE_JE_LAUF = 200
class IcalFehler extends Error {
constructor(code) {
super(code)
this.code = code
}
}
// ---- Adresspruefung
function v4Privat(a) {
const [o1, o2, o3] = a.split('.').map(Number)
return (
o1 === 0 || // "dieses Netz" inkl. 0.0.0.0
o1 === 10 ||
(o1 === 100 && o2 >= 64 && o2 <= 127) || // CGNAT
o1 === 127 ||
(o1 === 169 && o2 === 254) || // Link-Local, Cloud-Metadaten
(o1 === 172 && o2 >= 16 && o2 <= 31) ||
(o1 === 192 && o2 === 0 && o3 === 0) ||
(o1 === 192 && o2 === 0 && o3 === 2) ||
(o1 === 192 && o2 === 168) ||
(o1 === 198 && (o2 === 18 || o2 === 19)) ||
(o1 === 198 && o2 === 51 && o3 === 100) ||
(o1 === 203 && o2 === 0 && o3 === 113) ||
o1 >= 224 // Multicast, reserviert, Broadcast
)
}
function v6Gruppen(adresse) {
let a = adresse.toLowerCase().split('%')[0]
const m = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(a)
if (m) {
const b = m[2].split('.').map(Number)
a = `${m[1]}${((b[0] << 8) | b[1]).toString(16)}:${((b[2] << 8) | b[3]).toString(16)}`
}
const teile = a.split('::')
if (teile.length > 2) return null
const kopf = teile[0] ? teile[0].split(':') : []
const rest = teile.length === 2 && teile[1] ? teile[1].split(':') : []
let g
if (teile.length === 2) {
const luecke = 8 - kopf.length - rest.length
if (luecke < 1) return null
g = [...kopf, ...Array(luecke).fill('0'), ...rest]
} else g = kopf
if (g.length !== 8 || g.some((x) => !/^[0-9a-f]{1,4}$/.test(x))) return null
return g.map((x) => parseInt(x, 16))
}
const v4Aus = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`
function v6Privat(adresse) {
const g = v6Gruppen(adresse)
if (!g) return true
const nullBis = (n) => g.slice(0, n).every((x) => x === 0)
if (nullBis(5) && g[5] === 0xffff) return v4Privat(v4Aus(g[6], g[7])) // IPv4-mapped
if (nullBis(6)) return true // ::, ::1, IPv4-kompatibel
if (g[0] === 0x64 && g[1] === 0xff9b) return true // NAT64
if (g[0] === 0x2002) return v4Privat(v4Aus(g[1], g[2])) // 6to4
if (g[0] === 0x2001 && g[1] === 0) return true // Teredo
if (g[0] === 0x2001 && g[1] === 0xdb8) return true // Dokumentation
if (g[0] === 0x100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return true // Discard
return (g[0] & 0xfe00) === 0xfc00 || (g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0 || (g[0] & 0xff00) === 0xff00
}
/** true fuer alles, was nicht eindeutig eine oeffentliche IPv4-/IPv6-Adresse ist. */
export function istPrivateAdresse(adresse) {
if (net.isIPv4(adresse)) return v4Privat(adresse)
if (net.isIPv6(adresse)) return v6Privat(adresse)
return true
}
// ---- Abruf
/** Standard-Abruf: https.request mit fester Adresse (lookup-Override), SNI und Zertifikatspruefung gegen den Hostnamen. */
function festerAbruf(url, { adresse, signal }) {
return new Promise((resolve, reject) => {
const u = new URL(url)
const host = u.hostname.replace(/^\[|\]$/g, '')
const req = https.request(
{
hostname: host,
port: 443,
path: `${u.pathname}${u.search}`,
method: 'GET',
headers: { 'user-agent': 'WEBklar-Buchungskalender/1.0', accept: 'text/calendar, text/plain;q=0.9, */*;q=0.1', 'accept-encoding': 'identity' },
lookup: (_h, opts, cb) => (opts?.all ? cb(null, [{ address: adresse.address, family: adresse.family }]) : cb(null, adresse.address, adresse.family)),
signal,
},
(res) => {
const headers = new Headers()
for (const [k, v] of Object.entries(res.headers)) if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(', ') : v)
const ohneKoerper = [204, 205, 304].includes(res.statusCode)
if (ohneKoerper) res.resume()
resolve(new Response(ohneKoerper ? null : Readable.toWeb(res), { status: res.statusCode, headers }))
},
)
req.on('error', reject)
req.end()
})
}
async function pruefeZiel(url, lookup) {
let u
try {
u = new URL(url)
} catch {
throw new IcalFehler('ungueltige_adresse')
}
if (u.protocol !== 'https:' || !u.hostname || u.username || u.password || (u.port && u.port !== '443')) throw new IcalFehler('ungueltige_adresse')
const host = u.hostname.replace(/^\[|\]$/g, '')
let adressen
if (net.isIP(host)) adressen = [{ address: host, family: net.isIPv6(host) ? 6 : 4 }]
else {
try {
adressen = await lookup(host, { all: true })
} catch {
throw new IcalFehler('netzwerk')
}
}
if (!adressen?.length) throw new IcalFehler('netzwerk')
if (adressen.some((a) => istPrivateAdresse(a.address))) throw new IcalFehler('private_adresse')
return { adresse: adressen[0] }
}
async function liesBegrenzt(res, max) {
const laenge = Number(res.headers.get('content-length'))
if (Number.isFinite(laenge) && laenge > max) {
await res.body?.cancel().catch(() => {})
throw new IcalFehler('zu_gross')
}
if (!res.body) return ''
const reader = res.body.getReader()
const teile = []
let summe = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
summe += value.byteLength
if (summe > max) {
await reader.cancel().catch(() => {})
throw new IcalFehler('zu_gross')
}
teile.push(value)
}
return Buffer.concat(teile).toString('utf8')
}
/**
* ICS-Text von einer Adresse holen. Fehler: Error mit code ungueltige_adresse | private_adresse | netzwerk | timeout | zu_gross |
* zu_viele_weiterleitungen | http_<status>.
* @param {string} url
* @param {{ fetch?: Function, lookup?: Function }} [opt] fetch(url, { adresse, signal, redirect: 'manual' }) => Response; lookup(host, { all: true }) (Tests)
*/
export async function holeIcs(url, { fetch: abruf = festerAbruf, lookup = dns.lookup } = {}) {
const ac = new AbortController()
const timer = setTimeout(() => ac.abort(), config.unterkunft.icalTimeoutMs)
try {
let aktuell = url
for (let sprung = 0; ; sprung++) {
const { adresse } = await pruefeZiel(aktuell, lookup)
const res = await abruf(aktuell, { adresse, signal: ac.signal, redirect: 'manual' })
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
await res.body?.cancel().catch(() => {})
if (sprung >= MAX_WEITERLEITUNGEN) throw new IcalFehler('zu_viele_weiterleitungen')
try {
aktuell = new URL(res.headers.get('location'), aktuell).href
} catch {
throw new IcalFehler('ungueltige_adresse')
}
continue
}
if (res.status !== 200) {
await res.body?.cancel().catch(() => {})
throw new IcalFehler(`http_${res.status}`)
}
return await liesBegrenzt(res, config.unterkunft.icalMaxBytes)
}
} catch (err) {
if (err instanceof IcalFehler) throw err
throw new IcalFehler(ac.signal.aborted ? 'timeout' : 'netzwerk')
} finally {
clearTimeout(timer)
}
}
// ---- Abgleich
const ms = (now) => (now instanceof Date ? now.getTime() : Number(now))
const iso = (t) => new Date(t).toISOString()
const lockKey = (id) => ['unterkunft:' + id]
const hostVon = (url) => {
try {
return new URL(url).hostname
} catch {
return '?'
}
}
/** '07.11.2026, 06:00' in Berliner Zeit. */
function berlinZeit(t) {
const p = Object.fromEntries(
new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
.formatToParts(new Date(t))
.map((x) => [x.type, x.value]),
)
return `${p.day}.${p.month}.${p.year}, ${p.hour}:${p.minute}`
}
async function ersetzeEintrag(unterkunft, importId, aenderung) {
const u = await ladeUnterkunft(unterkunft.$id)
const liste = Array.isArray(u?.icalImporte) ? u.icalImporte : []
const i = liste.findIndex((x) => x.id === importId)
if (i < 0) return null
const neu = aenderung(liste[i])
const kopie = [...liste]
kopie[i] = neu
await speichereUnterkunft(u.featureId, { icalImporte: kopie }, u.$id)
return neu
}
/** Fehlermail K33, wenn der Import seit icalFehlerStunden nicht mehr gelang, hoechstens einmal je 24 h und Funktion. Wirft nie. */
async function pruefeFehlerMail(unterkunft, eintrag, jetzt, ctxVorgabe) {
try {
const seit = Date.parse(eintrag.letzterErfolgAt) || Date.parse(eintrag.fehlerSeit)
if (!Number.isFinite(seit) || jetzt - seit < config.unterkunft.icalFehlerStunden * 3600e3) return
const einst = await ladeEinstellungen(unterkunft.featureId)
const letzte = Date.parse(einst?.icalFehlerMailAt)
if (Number.isFinite(letzte) && jetzt - letzte < TAG_MS) return
const ctx = ctxVorgabe ?? (await ladeFuerProjekt(unterkunft.customerId, unterkunft.projectId)) ?? { featureId: unterkunft.featureId, customerId: unterkunft.customerId, projectId: unterkunft.projectId, einstellungen: einst ?? {} }
await mails.anVermieter('K33', ctx, { unterkunft: unterkunft.name, quelle: eintrag.name, seit: berlinZeit(seit) })
await speichereEinstellungen(unterkunft.featureId, { icalFehlerMailAt: iso(jetzt) })
} catch (err) {
console.warn(`[unterkunft-ical] Fehlermail fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
}
}
/**
* Einen Import abgleichen: Feed holen, lesen, die Sperren 'ical:<importId>' angleichen (fehlende zuerst anlegen, dann
* ueberzaehlige loeschen) und den Eintrag in icalImporte nachfuehren. Bei einem Fehler bleiben die alten Sperren stehen.
* @returns {Promise<{ok: true, anzahl: number} | {ok: false, fehler: string}>}
*/
export async function gleicheAb(unterkunft, importEintrag, { now = new Date(), fetch, lookup, ctx } = {}) {
const jetzt = ms(now)
const importId = importEintrag.id
const quelle = `ical:${importId}`
let ereignisse = null
let code = null
try {
const text = await holeIcs(importEintrag.url, { fetch, lookup })
ereignisse = parseIcs(text, { heute: berlinHeute(new Date(jetzt)) }).ereignisse
} catch (err) {
code = err?.code || (err?.message === 'kein_ics' ? 'kein_ics' : 'netzwerk')
}
const ergebnis = await withLocks(lockKey(unterkunft.$id), async () => {
const frisch = await ladeUnterkunft(unterkunft.$id)
const aktuellerEintrag = (Array.isArray(frisch?.icalImporte) ? frisch.icalImporte : []).find((x) => x.id === importId)
if (!aktuellerEintrag) return { ok: false, fehler: 'import_entfernt' }
if (code) {
const neu = await ersetzeEintrag(frisch, importId, (e) => ({ ...e, letzterAbrufAt: iso(jetzt), fehler: code, fehlerSeit: e.fehlerSeit || iso(jetzt) }))
return { ok: false, fehler: code, eintrag: neu }
}
const alt = (await listeSperren(frisch.$id)).filter((s) => s.quelle === quelle)
const behalten = new Set()
const vorhanden = new Map()
const ueberzaehlig = []
for (const s of alt) {
const k = `${s.von}|${s.bis}`
if (vorhanden.has(k)) ueberzaehlig.push(s)
else vorhanden.set(k, s)
}
const wuensche = new Map()
for (const e of ereignisse) wuensche.set(`${e.von}|${e.bis}`, e)
for (const [k, e] of wuensche) {
if (vorhanden.has(k)) behalten.add(k)
else await legeSperreAn({ unterkunftId: frisch.$id, featureId: frisch.featureId, von: e.von, bis: e.bis, quelle, notiz: String(importEintrag.name ?? '').slice(0, 200) })
}
for (const [k, s] of vorhanden) if (!behalten.has(k)) ueberzaehlig.push(s)
for (const s of ueberzaehlig) await loescheSperre(s.$id)
await ersetzeEintrag(frisch, importId, (e) => {
const { fehlerSeit, ...rest } = e
void fehlerSeit
return { ...rest, letzterAbrufAt: iso(jetzt), letzterErfolgAt: iso(jetzt), fehler: '' }
})
return { ok: true, anzahl: wuensche.size }
})
if (!ergebnis.ok && ergebnis.eintrag) {
await pruefeFehlerMail(unterkunft, ergebnis.eintrag, jetzt, ctx)
return { ok: false, fehler: ergebnis.fehler }
}
return ergebnis.ok ? { ok: true, anzahl: ergebnis.anzahl } : { ok: false, fehler: ergebnis.fehler }
}
/**
* Alle faelligen Importe aller aktiven Buchungskalender nacheinander abgleichen (Viertelstundenjob). Hoechstens maxImporte je Lauf;
* ein Fehler stoppt die anderen nicht. Protokoll nur mit Host, nie mit der vollen Adresse.
* @returns {Promise<{importe: number, erfolg: number, fehler: number}>}
*/
export async function gleicheAlleAb(now = new Date(), { fetch, lookup, maxImporte = MAX_IMPORTE_JE_LAUF } = {}) {
const jetzt = ms(now)
const zaehler = { importe: 0, erfolg: 0, fehler: 0 }
const faellig = (e) => {
const letzter = Date.parse(e.letzterAbrufAt)
return !Number.isFinite(letzter) || jetzt - letzter >= (config.unterkunft.icalIntervallMin - 1) * 60e3
}
const features = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'unterkunft'), Query.equal('state', 'active')])
for (const feature of features) {
if (zaehler.importe >= maxImporte) break
try {
if (unterkunftModus() === 'off') break
let ctx
if (unterkunftModus() === 'pilot') {
ctx = await ladeFuerProjekt(feature.customerId, feature.projectId)
if (!ctx || !unterkunftFuerKunde(ctx.besitzerEmail)) continue
}
for (const u of await listeUnterkuenfte(feature.$id, { nurAktive: true })) {
for (const eintrag of Array.isArray(u.icalImporte) ? u.icalImporte : []) {
if (zaehler.importe >= maxImporte) break
if (!eintrag?.url || !faellig(eintrag)) continue
zaehler.importe++
try {
const r = await gleicheAb(u, eintrag, { now, fetch, lookup, ctx })
if (r.ok) zaehler.erfolg++
else {
zaehler.fehler++
console.warn(`[unterkunft-ical] Import von ${hostVon(eintrag.url)} fehlgeschlagen: ${r.fehler}`)
}
} catch (err) {
zaehler.fehler++
console.warn(`[unterkunft-ical] Import von ${hostVon(eintrag.url)} fehlgeschlagen: ${err?.name}`)
}
}
}
} catch (err) {
console.warn(`[unterkunft-ical] Funktion ${feature.$id} uebersprungen: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
}
}
return zaehler
}
+4 -4
View File
@@ -1,10 +1,11 @@
import { laufeFristenAb } from './ablauf.js'
import { gleicheAlleAb } from './icalAbgleich.js'
import { unterkunftModus } from './schalter.js'
/**
* Jobs des Buchungskalenders (Registrierung in server/index.js: Minuten 80 s Start / 60 s, Viertelstunden 140 s / 15 min,
* taeglich 210 s / 24 h). Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten
* Takt), wirft nie und kehrt bei UNTERKUNFT_ENABLED=off sofort zurueck. Geruest: Task 9/10 fuellen Viertelstunden- und Tagesjob.
* Takt), wirft nie und kehrt bei UNTERKUNFT_ENABLED=off sofort zurueck. Geruest: Task 10 fuellt den Tagesjob.
*/
const laufend = new Set()
@@ -31,11 +32,10 @@ export async function minutenJob(now = new Date()) {
return einzeln('minutenJob', () => laufeFristenAb(now))
}
/** Viertelstundenjob (Task 9: iCal-Import). Bei UNTERKUNFT_ENABLED=off aus. */
/** Viertelstundenjob: iCal-Importe abgleichen. Bei UNTERKUNFT_ENABLED=off aus. */
export async function viertelstundenJob(now = new Date()) {
void now
if (unterkunftModus() === 'off') return { aus: true }
return einzeln('viertelstundenJob', async () => ({}))
return einzeln('viertelstundenJob', () => gleicheAlleAb(now))
}
/** Tagesjob (Task 10: Aufbewahrung, Loeschen). Bei UNTERKUNFT_ENABLED=off aus. */
+1 -1
View File
@@ -32,7 +32,7 @@ export const UNTERKUNFT_COLLECTIONS = {
str('icalImporte', 4000),
str('icalExportToken', 64),
],
indexes: [index('idx_feature', 'featureId')],
indexes: [index('idx_feature', 'featureId'), index('idx_export_token', 'icalExportToken')],
},
ukPreise: {
attributes: [