diff --git a/server/routes/publicUnterkunft.js b/server/routes/publicUnterkunft.js index 203b357..868c84f 100644 --- a/server/routes/publicUnterkunft.js +++ b/server/routes/publicUnterkunft.js @@ -7,7 +7,7 @@ import { belegteBereiche } from '../services/unterkunft/belegung.js' import { istTag, naechte } from '../services/unterkunft/datum.js' import { UkFehler } from '../services/unterkunft/fehler.js' import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js' -import { ID_RE, findeRechnung, ladeAnfrage, ladeRechnung, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js' +import { ID_RE, belegungsStand, findeRechnung, ladeAnfrage, ladeRechnung, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js' import { checkoutFuer, schliesseAbWennBezahlt } from '../services/unterkunft/zahlung.js' import { formToken, ipHashVon, pruefeFormToken, tokenPasst } from '../services/unterkunft/token.js' import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js' @@ -264,6 +264,12 @@ function verschmelze(bereiche) { return aus } +// Antwort von /belegung 10 s im Speicher je siteKey+unterkunft+von+bis; jedes Schreiben an Sperren/Anfragen (speicher.belegungsStand) macht sie ungueltig +const BELEGUNG_TTL_MS = 10 * 1000 +const BELEGUNG_MAX = 2000 +const belegungSpeicher = new Map() +export const leereBelegungSpeicherFuerTests = () => belegungSpeicher.clear() + router.get( '/:siteKey/belegung', route(async (req, now) => { @@ -277,13 +283,24 @@ router.get( throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' }) } const tag = heute(now) + const nowMs = now instanceof Date ? now.getTime() : Number(now) + const speicherKey = `${req.params.siteKey}|${unterkunft}|${von}|${bis}` + const gemerkt = belegungSpeicher.get(speicherKey) + if (gemerkt && gemerkt.bis > nowMs && gemerkt.stand === belegungsStand() && gemerkt.tag === tag) return { body: gemerkt.body } + const stand = belegungsStand() const bisMax = monateDazu(tag, config.unterkunft.horizontMonate) const bisBis = bis > bisMax ? bisMax : bis // Fenster des Widgets (UTC-Datum des Browsers) darf nie an einem Tag Abweichung scheitern const u = await ladeUnterkunft(unterkunft) if (!u || u.featureId !== ctx.featureId || u.aktiv !== true) throw nichtGefunden() const ab = von < tag ? tag : von const belegt = ab < bisBis ? verschmelze(await belegteBereiche(u.$id, ab, bisBis, { now })) : [] - return { body: { belegt, heute: tag, bisMax } } + const body = { belegt, heute: tag, bisMax } + if (belegungSpeicher.size >= BELEGUNG_MAX) { + for (const [k, e] of belegungSpeicher) if (e.bis <= nowMs) belegungSpeicher.delete(k) + if (belegungSpeicher.size >= BELEGUNG_MAX) belegungSpeicher.delete(belegungSpeicher.keys().next().value) + } + belegungSpeicher.set(speicherKey, { bis: nowMs + BELEGUNG_TTL_MS, stand, tag, body }) + return { body } }), ) diff --git a/server/services/unterkunft/belegung.js b/server/services/unterkunft/belegung.js index 3075431..0041dc7 100644 --- a/server/services/unterkunft/belegung.js +++ b/server/services/unterkunft/belegung.js @@ -15,10 +15,11 @@ export async function belegteBereiche(unterkunftId, von, bis, { ohneAnfrage, now const jetzt = now instanceof Date ? now.getTime() : now const fenster = { von, bis } const roh = [] - for (const s of await listeSperren(unterkunftId)) { + // nur was nach dem Fensteranfang endet (Index auf bis): alles andere kann das Fenster nicht beruehren + for (const s of await listeSperren(unterkunftId, { bisNach: von })) { roh.push({ von: s.von, bis: s.bis, quelle: String(s.quelle ?? '').startsWith('ical') ? 'ical' : 'manuell' }) } - for (const a of await listeAnfragenNachStatus(unterkunftId, [...AKTIVE_STATUS, 'freigegeben'])) { + for (const a of await listeAnfragenNachStatus(unterkunftId, [...AKTIVE_STATUS, 'freigegeben'], { bisNach: von })) { if (ohneAnfrage && a.$id === ohneAnfrage) continue if (a.status === 'freigegeben') { const frist = Date.parse(a.fristAt) diff --git a/server/services/unterkunft/schema.js b/server/services/unterkunft/schema.js index c1f2f23..f3b7085 100644 --- a/server/services/unterkunft/schema.js +++ b/server/services/unterkunft/schema.js @@ -29,7 +29,7 @@ export const UNTERKUNFT_COLLECTIONS = { str('checkOut', 5), int('sort'), bool('aktiv'), - str('icalImporte', 4000), + str('icalImporte', 8000), str('icalExportToken', 64), ], indexes: [index('idx_feature', 'featureId'), index('idx_export_token', 'icalExportToken')], @@ -60,7 +60,7 @@ export const UNTERKUNFT_COLLECTIONS = { str('quelle', 60), str('notiz', 200), ], - indexes: [index('idx_unterkunft', 'unterkunftId'), index('idx_unterkunft_quelle', 'unterkunftId', 'quelle')], + indexes: [index('idx_unterkunft', 'unterkunftId'), index('idx_unterkunft_quelle', 'unterkunftId', 'quelle'), index('idx_bis', 'bis')], }, ukAnfragen: { attributes: [ @@ -103,6 +103,7 @@ export const UNTERKUNFT_COLLECTIONS = { index('idx_unterkunft', 'unterkunftId'), index('idx_unterkunft_status', 'unterkunftId', 'status'), index('idx_status', 'status'), + index('idx_bis', 'bis'), ], }, ukRechnungen: { diff --git a/server/services/unterkunft/speicher.js b/server/services/unterkunft/speicher.js index 215b33b..8233af0 100644 --- a/server/services/unterkunft/speicher.js +++ b/server/services/unterkunft/speicher.js @@ -106,15 +106,32 @@ export async function loeschePreise(unterkunftId) { if (vorhanden) await deleteDocument(config.collections.ukPreise, vorhanden.$id) } +// ---- Belegungsstand: wird mit jedem Schreiben an Sperren oder Anfragen erhoeht (der Speicher der oeffentlichen /belegung-Antwort gilt nur bis dahin) + +let belegungStand = 0 +export const belegungsStand = () => belegungStand + // ---- ukSperren -export const listeSperren = (unterkunftId) => liste('ukSperren', [Query.equal('unterkunftId', unterkunftId)]) +/** bisNach: nur Sperren, die nach diesem Tag enden (bis > bisNach); die uebrigen koennen kein Fenster ab diesem Tag beruehren. */ +export const listeSperren = (unterkunftId, { bisNach } = {}) => + liste('ukSperren', [Query.equal('unterkunftId', unterkunftId), ...(bisNach ? [Query.greaterThan('bis', bisNach)] : [])]) export async function legeSperreAn(felder) { - return lesen('ukSperren', await createDocument(config.collections.ukSperren, schreiben('ukSperren', felder))) + try { + return lesen('ukSperren', await createDocument(config.collections.ukSperren, schreiben('ukSperren', felder))) + } finally { + belegungStand++ + } } -export const loescheSperre = (id) => deleteDocument(config.collections.ukSperren, id) +export async function loescheSperre(id) { + try { + return await deleteDocument(config.collections.ukSperren, id) + } finally { + belegungStand++ + } +} export const ladeSperre = (id) => ladeDok('ukSperren', id) // ---- ukAnfragen @@ -122,11 +139,19 @@ export const ladeSperre = (id) => ladeDok('ukSperren', id) export const ladeAnfrage = (id) => ladeDok('ukAnfragen', id) export async function legeAnfrageDokAn(felder) { - return lesen('ukAnfragen', await createDocument(config.collections.ukAnfragen, schreiben('ukAnfragen', felder))) + try { + return lesen('ukAnfragen', await createDocument(config.collections.ukAnfragen, schreiben('ukAnfragen', felder))) + } finally { + belegungStand++ + } } export async function aktualisiereAnfrage(id, felder) { - return lesen('ukAnfragen', await updateDocument(config.collections.ukAnfragen, id, schreiben('ukAnfragen', felder))) + try { + return lesen('ukAnfragen', await updateDocument(config.collections.ukAnfragen, id, schreiben('ukAnfragen', felder))) + } finally { + belegungStand++ + } } export function listeAnfragen(featureId, { status, unterkunftId } = {}) { @@ -137,8 +162,8 @@ export function listeAnfragen(featureId, { status, unterkunftId } = {}) { } /** Fuer belegung.js: Anfragen einer Unterkunft mit einem der Status. */ -export const listeAnfragenNachStatus = (unterkunftId, status) => - liste('ukAnfragen', [Query.equal('unterkunftId', unterkunftId), Query.equal('status', status)]) +export const listeAnfragenNachStatus = (unterkunftId, status, { bisNach } = {}) => + liste('ukAnfragen', [Query.equal('unterkunftId', unterkunftId), Query.equal('status', status), ...(bisNach ? [Query.greaterThan('bis', bisNach)] : [])]) // ---- ukRechnungen diff --git a/test/unterkunftBelegung.test.js b/test/unterkunftBelegung.test.js index 5952bb4..27c9aa0 100644 --- a/test/unterkunftBelegung.test.js +++ b/test/unterkunftBelegung.test.js @@ -39,6 +39,22 @@ test('ueberschneidet: Halboffen, Abreise gleich Anreise ist frei', () => { assert.equal(bel.ueberschneidet({ von: '2026-11-01', bis: '2026-11-10' }, { von: '2026-11-03', bis: '2026-11-04' }), true) }) +test('Belegung laedt nur Sperren und Anfragen, die nach dem Fensteranfang enden (bis > von)', async () => { + await sp.legeSperreAn({ unterkunftId: 'u1', featureId: 'f1', von: '2026-09-01', bis: '2026-09-10', quelle: 'manuell' }) + await sp.legeSperreAn({ unterkunftId: 'u1', featureId: 'f1', von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell' }) + anfrage({ status: 'gebucht', von: '2026-09-01', bis: '2026-09-05' }) + anfrage({ status: 'gebucht', von: '2026-11-20', bis: '2026-11-22' }) + assert.deepEqual((await sp.listeSperren('u1', { bisNach: '2026-11-01' })).map((s) => s.bis), ['2026-11-12']) + assert.equal((await sp.listeSperren('u1')).length, 2) + assert.deepEqual((await sp.listeAnfragenNachStatus('u1', ['gebucht'], { bisNach: '2026-11-01' })).map((a) => a.bis), ['2026-11-22']) + // bis == von: beruehrt das Fenster nicht + assert.equal((await sp.listeSperren('u1', { bisNach: '2026-11-12' })).length, 0) + assert.deepEqual(await bel.belegteBereiche('u1', '2026-11-01', '2026-11-30', { now: NOW }), [ + { von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell' }, + { von: '2026-11-20', bis: '2026-11-22', quelle: 'gebucht' }, + ]) +}) + test('Sperre manuell blockiert, Abreisetag nicht', async () => { await sp.legeSperreAn({ unterkunftId: 'u1', featureId: 'f1', von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell' }) assert.equal(await bel.istFrei('u1', '2026-11-11', '2026-11-13', { now: NOW }), false) diff --git a/test/unterkunftPublic.test.js b/test/unterkunftPublic.test.js index ae8b105..da657b3 100644 --- a/test/unterkunftPublic.test.js +++ b/test/unterkunftPublic.test.js @@ -20,7 +20,7 @@ const K = await import('../server/services/unterkunft/kontext.js') const T = await import('../server/services/unterkunft/token.js') const mails = await import('../server/services/unterkunft/mails.js') const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') -const { default: publicRoutes, setzeUhrFuerTests } = await import('../server/routes/publicUnterkunft.js') +const { default: publicRoutes, setzeUhrFuerTests, leereBelegungSpeicherFuerTests } = await import('../server/routes/publicUnterkunft.js') const app = await startTestApp({ '/api/public/unterkunft/v1': publicRoutes }) after(async () => { await app.close() @@ -41,6 +41,7 @@ beforeEach(async () => { resetTermineLimitsForTests() mails._aufrufe.length = 0 setzeUhrFuerTests(() => NOW) + leereBelegungSpeicherFuerTests() aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'active', enabled: true }) aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' }) aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau', subdomain: 'donau', liveDomain: 'donau-home.example' }) @@ -113,6 +114,27 @@ test('Belegung: fasst angrenzende Bereiche zusammen, ohne Quelle, auf das Fenste assert.equal(r.headers.get('access-control-allow-origin'), '*') }) +test('Belegung: Antwort 10 s im Speicher (kein Datenbankzugriff), nach einem Schreiben sofort frisch, danach nach Ablauf frisch', async () => { + aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-05', bis: '2026-11-08', quelle: 'manuell' }) + const url = `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2026-11-22` + assert.equal((await send('GET', url)).status, 200) + aw.failNext({ collection: 'ukSperren', method: 'GET', status: 500 }) + const gemerkt = await send('GET', url) + assert.equal(gemerkt.status, 200, 'aus dem Speicher, kein Zugriff auf ukSperren') + assert.deepEqual(gemerkt.body.belegt, [{ von: '2026-11-05', bis: '2026-11-08' }]) + // anderer Zeitraum = anderer Schluessel: trifft den eingeplanten Fehler + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-02&bis=2026-11-22`)).status, 503) + // Schreiben ueber den Speicher-Pfad macht die Antwort ungueltig + const sp = await import('../server/services/unterkunft/speicher.js') + await sp.legeSperreAn({ unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell' }) + assert.deepEqual((await send('GET', url)).body.belegt, [{ von: '2026-11-05', bis: '2026-11-08' }, { von: '2026-11-10', bis: '2026-11-12' }]) + // Ablauf nach 10 s + aw.seed('ukSperren', { $id: 's9', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-15', bis: '2026-11-16', quelle: 'manuell' }) + assert.equal((await send('GET', url)).body.belegt.length, 2) + setzeUhrFuerTests(() => new Date(NOW.getTime() + 11000)) + assert.equal((await send('GET', url)).body.belegt.length, 3) +}) + test('Belegung: ueber 18 Monate hinaus, kaputte Daten, fremde oder inaktive Unterkunft', async () => { aw.seed('ukSperren', { $id: 'sz', unterkunftId: 'u1', featureId: FEATURE, von: '2028-04-28', bis: '2028-06-10', quelle: 'manuell' }) const geklemmt = await send('GET', `${BASE}/belegung?unterkunft=u1&von=2028-04-01&bis=2030-01-01`) diff --git a/test/unterkunftSetup.test.js b/test/unterkunftSetup.test.js index 18b7620..7730c3a 100644 --- a/test/unterkunftSetup.test.js +++ b/test/unterkunftSetup.test.js @@ -97,7 +97,7 @@ test('Schema: Attribute je Collection wie in der Spezifikation', () => { test('Schema: Typen und Groessen', () => { const sizes = { - ukUnterkuenfte: { featureId: 36, name: 120, beschreibung: 500, checkIn: 5, checkOut: 5, icalImporte: 4000, icalExportToken: 64 }, + ukUnterkuenfte: { featureId: 36, name: 120, beschreibung: 500, checkIn: 5, checkOut: 5, icalImporte: 8000, icalExportToken: 64 }, ukPreise: { unterkunftId: 36, staffeln: 2000, steuerart: 20, stornobedingungen: 5000 }, ukSperren: { von: 10, bis: 10, quelle: 60, notiz: 200 }, ukAnfragen: { @@ -145,8 +145,8 @@ test('Schema: Indizes wie in der Spezifikation und nur auf vorhandenen Attribute const idx = (name) => UNTERKUNFT_COLLECTIONS[name].indexes.map((i) => i.attributes.join('+')) assert.deepEqual(idx('ukUnterkuenfte'), ['featureId', 'icalExportToken']) assert.deepEqual(idx('ukPreise'), ['unterkunftId']) - assert.deepEqual(idx('ukSperren'), ['unterkunftId', 'unterkunftId+quelle']) - assert.deepEqual(idx('ukAnfragen'), ['featureId', 'unterkunftId', 'unterkunftId+status', 'status']) + assert.deepEqual(idx('ukSperren'), ['unterkunftId', 'unterkunftId+quelle', 'bis']) + assert.deepEqual(idx('ukAnfragen'), ['featureId', 'unterkunftId', 'unterkunftId+status', 'status', 'bis']) assert.deepEqual(idx('ukRechnungen'), ['featureId', 'anfrageId', 'featureId+nummer']) for (const name of NAMES) { const keys = new Set()