From 9d01d38a452c11121381599bef7e2bb73d616ecf Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:44:21 +0000 Subject: [PATCH] Buchungskalender: Bestellung auf Anfrage, Freischaltung und Ticket Co-Authored-By: Claude Opus 5.5 --- server/routes/team/unterkunft.js | 83 ++++- server/routes/unterkunft.js | 45 ++- server/services/unterkunft/anfrageFunktion.js | 296 +++++++++++++++++- server/services/unterkunft/freischaltung.js | 35 ++- server/services/unterkunft/ticketDetails.js | 62 ++++ test/unterkunftAnfrageFunktion.test.js | 291 +++++++++++++++++ test/unterkunftWiring.test.js | 2 +- 7 files changed, 799 insertions(+), 15 deletions(-) create mode 100644 server/services/unterkunft/ticketDetails.js create mode 100644 test/unterkunftAnfrageFunktion.test.js diff --git a/server/routes/team/unterkunft.js b/server/routes/team/unterkunft.js index 37dadfe..372bbff 100644 --- a/server/routes/team/unterkunft.js +++ b/server/routes/team/unterkunft.js @@ -1,10 +1,87 @@ import { Router } from 'express' +import { config } from '../../config.js' +import { Query, countDocuments, listDocuments } from '../../services/appwriteAdmin.js' +import { anfrageAnsichtTeam, entscheideAnfrage } from '../../services/unterkunft/anfrageFunktion.js' +import { ladeEinstellungen, listeUnterkuenfte } from '../../services/unterkunft/speicher.js' +import { listCustomerFeatureDocs } from '../../services/siteFeatures.js' /** - * Buchungskalender im Ticketsystem: GET /api/team/unterkunft/:customerId (Stand je Projekt) und - * POST /api/team/unterkunft/:customerId/anfrage (Entscheidung ueber die Anfrage der Funktion). - * Eingehaengt in routes/team/index.js unter /unterkunft, dort hinter requireStaffJwt. Geruest (Task 5): Task 11 fuellt es. + * Buchungskalender im Ticketsystem (Kunden-Reiter "Funktionen"): GET /api/team/unterkunft/:customerId (Stand je Projekt) und + * POST /api/team/unterkunft/:customerId/anfrage (Entscheidung ueber die Anfrage der Funktion). Huelle wie routes/team/shop.js. + * Eingehaengt in routes/team/index.js unter /unterkunft, dort hinter requireStaffJwt. */ const router = Router() +const sendError = (res, err) => { + const status = err.status >= 400 && err.status < 600 ? err.status : 500 + if (status >= 500) console.error('[team-unterkunft]', err.message) + return res.status(status).json({ error: status >= 500 ? 'Interner Serverfehler' : err.message, ...(err.body?.code ? { code: err.body.code } : {}) }) +} +const route = (fn) => (req, res) => fn(req, res).catch((err) => sendError(res, err)) +const person = (req) => ({ name: req.session.adminName || req.session.name || '', email: req.session.email || '' }) + +/** Zaehler oder null, wenn die Abfrage scheitert (die Uebersicht bleibt lesbar). */ +async function zaehle(collection, queries, was) { + try { + return await countDocuments(config.collections[collection], queries) + } catch (err) { + console.warn(`[team-unterkunft] ${was} nicht lesbar: ${err.message}`) + return null + } +} + +const stripeZustand = (einst) => (!einst?.stripeAccountId ? 'keins' : einst.stripeStatus?.charges_enabled === true ? 'aktiv' : 'onboarding') + +async function projektStand(project, feature) { + let einst = null + let unterkuenfte = 0 + if (feature) { + try { + einst = await ladeEinstellungen(feature.$id) + } catch (err) { + console.warn(`[team-unterkunft] Einstellungen ${feature.$id} nicht lesbar: ${err.message}`) + } + try { + unterkuenfte = (await listeUnterkuenfte(feature.$id)).length + } catch (err) { + console.warn(`[team-unterkunft] Unterkuenfte ${feature.$id} nicht lesbar: ${err.message}`) + unterkuenfte = null + } + } + const offeneAnfragen = feature ? await zaehle('ukAnfragen', [Query.equal('featureId', feature.$id), Query.equal('status', 'neu')], 'Anfragenzahl') : 0 + return { + projectId: project.$id, + name: project.projectName || '', + state: feature?.state || 'off', + anfrage: anfrageAnsichtTeam(einst), + stripe: { status: stripeZustand(einst) }, + unterkuenfte, + offeneAnfragen, + } +} + +// Uebersicht je Projekt des Kunden; archivierte Projekte nur, wenn dort ein Dokument der Funktion existiert +router.get('/:customerId', route(async (req, res) => { + const customerId = req.params.customerId + const [docs, projects] = await Promise.all([ + listCustomerFeatureDocs(customerId), + listDocuments(config.collections.websiteProjects, [Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000)]), + ]) + const ukDocs = new Map(docs.filter((d) => d.featureKey === 'unterkunft').map((d) => [d.projectId, d])) + const sichtbar = projects.filter((p) => !p.archived || ukDocs.has(p.$id)) + res.json({ projects: await Promise.all(sichtbar.map((p) => projektStand(p, ukDocs.get(p.$id)))) }) +})) + +router.post('/:customerId/anfrage', route(async (req, res) => { + const { projectId, entscheidung, begruendung } = req.body || {} + const ergebnis = await entscheideAnfrage({ + customerId: req.params.customerId, + projectId, + entscheidung, + begruendung, + person: person(req), + }) + res.json({ ok: true, anfrage: ergebnis.anfrage, ...(ergebnis.mailFehler ? { mailFehler: true } : {}) }) +})) + export default router diff --git a/server/routes/unterkunft.js b/server/routes/unterkunft.js index e953275..5a49d44 100644 --- a/server/routes/unterkunft.js +++ b/server/routes/unterkunft.js @@ -10,6 +10,7 @@ import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../se import * as ablauf from '../services/unterkunft/ablauf.js' import { istFrei } from '../services/unterkunft/belegung.js' import { istTag, naechte } from '../services/unterkunft/datum.js' +import { anfrageAnsicht as funktionAnfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/unterkunft/anfrageFunktion.js' import { UkFehler } from '../services/unterkunft/fehler.js' import { ladeFuerProjekt } from '../services/unterkunft/kontext.js' import { berechnePreis, preiseVollstaendig, summiere } from '../services/unterkunft/preis.js' @@ -22,7 +23,7 @@ import * as vw from '../services/unterkunft/verwaltung.js' * 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. + * Fehler: { fehler: code, felder? }; Texte von Appwrite oder Stripe verlassen den Server nie. Die Anfrage der Funktion (/anfrage-funktion, nur solange sie off ist) hat eine eigene Grenze (20 je Kunde und Stunde). */ const router = Router() // Jede Route hier braucht eine Sitzung; Kunden-Pruefung je Route (nurKunde) @@ -33,6 +34,7 @@ router.use((_req, res, next) => { }) const MAX_WRITES_PER_HOUR = 300 +const MAX_ANFRAGE_WRITES_PER_HOUR = 20 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.', @@ -42,9 +44,12 @@ const MELDUNG = { } const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR }) +const anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR }) +const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) export function resetUnterkunftRoutesForTests() { schreibLimiter.reset() + anfrageLimiter.reset() } const nurKunde = (req, res, next) => { @@ -227,7 +232,12 @@ router.get('/status', route(async (req, res) => { 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) + if (state === 'off') { + // Anfrage der Funktion: offen | abgelehnt | angenommen (zurueckgezogen und keine zaehlen als keine) + const ansicht = doc ? funktionAnfrageAnsicht(await sp.ladeEinstellungen(doc.$id)) : null + antwort.anfrage = ansicht && ansicht.status !== 'zurueckgezogen' ? ansicht : null + return res.json(antwort) + } const [einst, unterkuenfte, rechnungen, shop] = await Promise.all([ sp.ladeEinstellungen(doc.$id), sp.listeUnterkuenfte(doc.$id), @@ -244,6 +254,37 @@ router.get('/status', route(async (req, res) => { res.json(antwort) })) +// ---- Anfrage der Funktion (nur solange sie off ist; kein mitProjekt) + +/** Fehler des Anfrage-Dienstes (status, Text, body.code, fehler je Feld) in { fehler: code, felder? }. */ +function anfrageFehler(err) { + if (err instanceof UkFehler || err?.status === 403 || err?.fachlich || !(err?.status >= 400 && err.status < 500)) return err + if (err.fehler) return new UkFehler(400, 'ungueltig', err.fehler) + return new UkFehler(err.status, err.body?.code || (err.status === 400 ? 'ungueltig' : 'anfrage'), err.status === 400 ? { projectId: err.message } : {}) +} +const anfrageKunde = (req) => ({ name: req.session.name || '', email: req.session.email || '' }) + +router.post('/anfrage-funktion', nurKunde, limitAnfrage, route(async (req, res) => { + const projectId = projectIdOf(req) + if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' }) + try { + const anfrage = await stelleAnfrage({ customerId: getSessionCustomerId(req), projectId, eingabe: body(req), person: anfrageKunde(req) }) + res.json({ anfrage }) + } catch (err) { + throw anfrageFehler(err) + } +})) + +router.post('/anfrage-funktion/zurueckziehen', nurKunde, limitAnfrage, route(async (req, res) => { + const projectId = projectIdOf(req) + if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' }) + try { + res.json({ anfrage: await zieheZurueck({ customerId: getSessionCustomerId(req), projectId, person: anfrageKunde(req) }) }) + } catch (err) { + throw anfrageFehler(err) + } +})) + // ---- Einstellungen async function einstellungenAntwort(ctx, einst) { diff --git a/server/services/unterkunft/anfrageFunktion.js b/server/services/unterkunft/anfrageFunktion.js index 93c4931..901c839 100644 --- a/server/services/unterkunft/anfrageFunktion.js +++ b/server/services/unterkunft/anfrageFunktion.js @@ -1,7 +1,293 @@ -// Stub (Task 5): Anfrage-Funktion des Buchungskalenders. Task 11 ersetzt dieses Modul. -export const ANFRAGE_OFFEN_TEXT = 'Anfrage noch nicht freigegeben' +import { config } from '../../config.js' +import { withLocks } from '../keyedLock.js' +import { sendAdminNotice, sendCustomerMail } from '../mailer.js' +import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js' +import { unterkunftFuerKunde } from './schalter.js' +import { ladeEinstellungen, speichereEinstellungen } from './speicher.js' -/** Bestellpruefung-Haken: offene Punkte der Bestellung (Stub: immer offen). */ -export async function anfrageOffenePunkte() { - return [ANFRAGE_OFFEN_TEXT] +/** + * Anfrage der Funktion "Buchungskalender" (Muster services/shop/anfrage.js, Spezifikation 2026-10-07-buchungskalender-design.md, + * Abschnitt 9). Der Kunde stellt eine Anfrage, das Team entscheidet (annehmen oder ablehnen); erst eine angenommene Anfrage laesst + * die Bestellung zu (anfrageOffenePunkte, Haken bestellpruefung). Die Anfrage steht in ukEinstellungen (Dokument-ID = ID des + * portalFeatures-Dokuments): anfrage (JSON), anfrageStatus ('' | offen | angenommen | abgelehnt | zurueckgezogen), anfrageAt, + * anfrageEntschiedenAt, anfrageEntschiedenVon, anfrageBegruendung. Sperre je Projekt: uk-anfrage:. + * Fehler an den Kunden tragen status (4xx) und einen deutschen Text (Du-Form), optional err.body.code und err.fehler (je Feld). + * Mails: T22 ans Team (Anfrage eingegangen), K34 an den Kunden (Entscheidung); die Vorlagen kommen aus Task 20. + */ + +export const ANFRAGE_OFFEN_TEXT = 'Deine Anfrage für den Buchungskalender ist noch nicht freigegeben.' + +export const ARTEN = ['ferienwohnung', 'monteurzimmer', 'pension', 'sonstiges'] +export const ART_NAMEN = { ferienwohnung: 'Ferienwohnung oder Ferienhaus', monteurzimmer: 'Monteurwohnung', pension: 'Pension oder Gästezimmer', sonstiges: 'Sonstiges' } +/** Das Portal sendet und liest 'monteur' (ui-erwartungen.md); gespeichert wird 'monteurzimmer'. */ +const ART_ALIAS = { monteur: 'monteurzimmer' } +export const PORTALE = ['booking', 'airbnb'] +export const PORTAL_NAMEN = { booking: 'Booking.com', airbnb: 'Airbnb', andere: 'Portale (ohne Angabe)' } + +const EINHEITEN_MAX = 999 +const NACHRICHT_MAX = 1000 +const BEGRUENDUNG_MAX = 1000 + +const MELDUNG = { + projekt: 'Bitte wähle ein Projekt.', + nichtVerfuegbar: 'Der Buchungskalender ist noch nicht verfügbar.', + vorhanden: 'Für dieses Projekt gibt es schon einen Buchungskalender.', + laeuft: 'Für dieses Projekt gibt es schon eine Anfrage für den Buchungskalender.', + nichtOffen: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.', + nichtEntscheidbar: 'Diese Anfrage ist nicht (mehr) offen und kann nicht entschieden werden.', + entscheidung: 'Bitte wähle annehmen oder ablehnen.', + begruendung: 'Bitte nenne eine Begründung für die Ablehnung (höchstens 1000 Zeichen).', + ungueltig: 'Ungültige Eingabe.', +} + +const stop = (status, text, code) => Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}) }) +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/** Versand (nur fuer Tests austauschbar: die Vorlagen K34 und T22 liegen auf einem anderen Strang). */ +export const _versand = { + admin: (nachricht) => sendAdminNotice(nachricht), + kunde: (an, nachricht) => sendCustomerMail(an, nachricht), +} + +/** [] wenn die Anfrage des Projekts vom Team angenommen ist, sonst ein Punkt. Lesefehler werden weitergereicht. */ +export async function anfrageOffenePunkte({ customerId, projectId }) { + const doc = await getFeatureDoc(customerId, projectId, 'unterkunft') + if (!doc) return [ANFRAGE_OFFEN_TEXT] + const einst = await ladeEinstellungen(doc.$id) + return einst?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT] +} + +/** + * Eingabe pruefen => { anfrage } (nur bekannte Felder, bereinigt) oder { fehler: { feld: text } }. + * art (ARTEN, auch 'monteur'), einheiten (ganze Zahl 1-999), portale (Liste aus booking|airbnb, leer erlaubt; ein boolean wird + * angenommen: true = 'andere'), nachricht (optional, bis 1000 Zeichen). + */ +export function normalisiereAnfrage(eingabe) { + const fehler = {} + const e = istObjekt(eingabe) ? eingabe : {} + + const artRoh = typeof e.art === 'string' ? (Object.hasOwn(ART_ALIAS, e.art) ? ART_ALIAS[e.art] : e.art) : '' + const art = ARTEN.includes(artRoh) ? artRoh : '' + if (!art) fehler.art = 'Bitte wähle die Art deiner Unterkunft.' + + const einheiten = e.einheiten + if (!Number.isInteger(einheiten) || einheiten < 1 || einheiten > EINHEITEN_MAX) fehler.einheiten = `Bitte gib eine ganze Zahl von 1 bis ${EINHEITEN_MAX} an.` + + let portale = [] + if (e.portale === undefined || e.portale === null) portale = [] + else if (typeof e.portale === 'boolean') portale = e.portale ? ['andere'] : [] + else if (Array.isArray(e.portale) && e.portale.every((p) => PORTALE.includes(p))) portale = PORTALE.filter((p) => e.portale.includes(p)) + else fehler.portale = MELDUNG.ungueltig + + let nachricht = '' + if (e.nachricht !== undefined && e.nachricht !== null && e.nachricht !== '') { + if (typeof e.nachricht !== 'string') fehler.nachricht = MELDUNG.ungueltig + else { + nachricht = e.nachricht.toWellFormed().trim() + if (nachricht.length > NACHRICHT_MAX) fehler.nachricht = `Die Nachricht darf höchstens ${NACHRICHT_MAX} Zeichen lang sein.` + } + } + + if (Object.keys(fehler).length) return { fehler } + return { anfrage: { art, einheiten, portale, nachricht } } +} + +/** Gespeicherte Anfrage (aus ukEinstellungen.anfrage, schon geparst) bereinigt oder null. */ +function leseAnfrage(einst) { + const roh = einst?.anfrage + if (!istObjekt(roh)) return null + return { + art: ARTEN.includes(roh.art) ? roh.art : '', + einheiten: Number.isInteger(roh.einheiten) ? roh.einheiten : null, + portale: Array.isArray(roh.portale) ? roh.portale.filter((p) => typeof p === 'string') : [], + nachricht: typeof roh.nachricht === 'string' ? roh.nachricht : '', + } +} + +/** + * Anfrage fuer das Portal des Kunden: { status, anfrage: { art ('monteur' statt 'monteurzimmer'), einheiten, portale (Liste), + * nachricht }, at, entschiedenAt, begruendung } oder null, solange es keine gab. + */ +export function anfrageAnsicht(einst) { + const status = einst?.anfrageStatus || '' + if (!status) return null + const a = leseAnfrage(einst) + return { + status, + anfrage: a ? { ...a, art: a.art === 'monteurzimmer' ? 'monteur' : a.art } : null, + at: einst.anfrageAt || null, + entschiedenAt: einst.anfrageEntschiedenAt || null, + begruendung: einst.anfrageBegruendung || '', + } +} + +/** Anfrage fuer das Team (WOMS): flach, art wie gespeichert, portale als boolean; ohne Anfrage status ''. */ +export function anfrageAnsichtTeam(einst) { + const a = leseAnfrage(einst) + return { + status: einst?.anfrageStatus || '', + at: einst?.anfrageAt || null, + art: a?.art || '', + einheiten: a?.einheiten ?? null, + portale: (a?.portale.length ?? 0) > 0, + nachricht: a?.nachricht || '', + entschiedenAt: einst?.anfrageEntschiedenAt || null, + entschiedenVon: einst?.anfrageEntschiedenVon || '', + begruendung: einst?.anfrageBegruendung || '', + } +} + +/** Projekt des Kunden (Appwrite-Fehler 400 bei unbrauchbarer ID zaehlt als fremdes Projekt) und Schalter des Kunden. */ +async function pruefeZugang({ customerId, projectId, person, neu }) { + if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt) + let project + try { + project = await assertOwnProject(customerId, projectId, { forNewOrder: neu }) + } catch (err) { + throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err + } + if (!unterkunftFuerKunde(person?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') + return project +} + +/** + * Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Eingabe (400 mit `fehler`), dann unter Sperre je + * Projekt: Funktion muss `off` sein (409), Status '' | abgelehnt | zurueckgezogen (409 sonst). Speichert, loescht eine fruehere + * Entscheidung und schickt T22 ans Team (ein Fehler dabei wird nur geloggt). + * @returns {Promise} anfrageAnsicht des neuen Standes + */ +export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date() }) { + const project = await pruefeZugang({ customerId, projectId, person, neu: true }) + const { anfrage, fehler } = normalisiereAnfrage(eingabe) + if (fehler) throw Object.assign(stop(400, Object.values(fehler)[0]), { fehler }) + + const gespeichert = await withLocks([`uk-anfrage:${projectId}`], async () => { + const feature = await ensureFeatureDoc(customerId, projectId, 'unterkunft') + if (feature.state !== 'off') throw stop(409, MELDUNG.vorhanden, 'vorhanden') + return withLocks([`uk-einst:${feature.$id}`], async () => { + const einst = await ladeEinstellungen(feature.$id) + if (!['', 'abgelehnt', 'zurueckgezogen'].includes(einst?.anfrageStatus || '')) throw stop(409, MELDUNG.laeuft, 'anfrage_laeuft') + return speichereEinstellungen(feature.$id, { + anfrage, + anfrageStatus: 'offen', + anfrageAt: now.toISOString(), + anfrageEntschiedenAt: null, + anfrageEntschiedenVon: '', + anfrageBegruendung: '', + }) + }) + }) + + await meldeTeam({ customerId, projectId, project, anfrage, person }) + return anfrageAnsicht(gespeichert) +} + +/** T22 "Neue Anfrage Buchungskalender" ans Team; sendAdminNotice wirft nie, ein Fehlschlag steht nur im Log. */ +async function meldeTeam({ customerId, projectId, project, anfrage, person }) { + try { + let kundeName = String(person?.name || '').trim() + if (!kundeName) kundeName = String((await customerOf(customerId))?.name || '').trim() + const result = await _versand.admin({ + template: 'T22', + values: { + kunde_name: kundeName || 'Kunde', + email: person?.email || '', + projekt: project?.projectName || projectId, + art: ART_NAMEN[anfrage.art], + einheiten: String(anfrage.einheiten), + portale: anfrage.portale.length ? anfrage.portale.map((p) => PORTAL_NAMEN[p]).join(', ') : 'keine', + nachricht: anfrage.nachricht || '–', + woms_url: `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId)}?tab=funktionen`, + }, + }) + if (!result?.sent) console.warn(`[unterkunft] Team-Mail T22 nicht gesendet: ${result?.reason || 'unbekannt'}`) + } catch (err) { + console.warn(`[unterkunft] Team-Mail T22 nicht gesendet: ${err?.message}`) + } +} + +/** Offene Anfrage zurueckziehen (Status `zurueckgezogen`); aus jedem anderen Zustand 409. @returns {Promise} anfrageAnsicht */ +export async function zieheZurueck({ customerId, projectId, person = {} }) { + await pruefeZugang({ customerId, projectId, person, neu: false }) + const gespeichert = await withLocks([`uk-anfrage:${projectId}`], async () => { + const feature = await getFeatureDoc(customerId, projectId, 'unterkunft') + if (!feature) throw stop(409, MELDUNG.nichtOffen, 'nicht_offen') + return withLocks([`uk-einst:${feature.$id}`], async () => { + const einst = await ladeEinstellungen(feature.$id) + if (!einst || einst.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtOffen, 'nicht_offen') + return speichereEinstellungen(feature.$id, { anfrageStatus: 'zurueckgezogen' }) + }) + }) + return anfrageAnsicht(gespeichert) +} + +/** 'Name ' (wie createdBy der Angebote); ohne Name die Adresse, ohne beides 'Admin'. */ +const personLabel = (person) => (person?.name ? `${person.name}${person.email ? ` <${person.email}>` : ''}` : person?.email || 'Admin') + +/** + * Das Team entscheidet eine offene Anfrage: annehmen oder ablehnen (Begruendung Pflicht, 1-1000 Zeichen). Unter der Sperre je + * Projekt; das Projekt muss dem Kunden gehoeren (403), der Status muss `offen` sein (409). Danach geht K34 an den Kunden; scheitert + * die Mail, bleibt die Entscheidung bestehen, wird geloggt und die Antwort traegt `mailFehler: true`. + * @returns {Promise<{ anfrage: object, mailFehler: boolean }>} anfrageAnsicht des neuen Standes + */ +export async function entscheideAnfrage({ customerId, projectId, entscheidung, begruendung, person = {}, now = new Date() }) { + if (entscheidung !== 'annehmen' && entscheidung !== 'ablehnen') throw stop(400, MELDUNG.entscheidung) + let grund = '' + if (entscheidung === 'ablehnen') { + grund = typeof begruendung === 'string' ? begruendung.toWellFormed().trim() : '' + if (!grund || grund.length > BEGRUENDUNG_MAX) throw stop(400, MELDUNG.begruendung) + } + if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt) + + const { gespeichert, project } = await withLocks([`uk-anfrage:${projectId}`], async () => { + let besitz + try { + besitz = await assertOwnProject(customerId, projectId) + } catch (err) { + throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err + } + const feature = await getFeatureDoc(customerId, projectId, 'unterkunft') + if (!feature) throw stop(409, MELDUNG.nichtEntscheidbar, 'nicht_offen') + const neu = await withLocks([`uk-einst:${feature.$id}`], async () => { + const einst = await ladeEinstellungen(feature.$id) + if (!einst || einst.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtEntscheidbar, 'nicht_offen') + return speichereEinstellungen(feature.$id, { + anfrageStatus: entscheidung === 'annehmen' ? 'angenommen' : 'abgelehnt', + anfrageEntschiedenAt: now.toISOString(), + anfrageEntschiedenVon: personLabel(person).slice(0, 120), + anfrageBegruendung: grund, + }) + }) + return { gespeichert: neu, project: besitz } + }) + + const mailFehler = !(await sendeEntscheidung({ customerId, projectId, project, angenommen: entscheidung === 'annehmen', begruendung: grund })) + return { anfrage: anfrageAnsicht(gespeichert), mailFehler } +} + +/** K34 an den Kunden; true = gesendet. Jeder Fehler wird nur geloggt (die Entscheidung ist gespeichert). */ +async function sendeEntscheidung({ customerId, projectId, project, angenommen, begruendung }) { + try { + const kunde = await customerOf(customerId) + if (!kunde?.email) { + console.warn(`[unterkunft] K34 nicht gesendet: Kunde ${customerId} ohne E-Mail-Adresse`) + return false + } + await _versand.kunde(kunde.email, { + template: 'K34', + values: { + kunde_name: String(kunde.name || '').trim() || 'Kunde', + projekt: project?.projectName || projectId, + preis: config.features.unterkunft.priceLabel, + ust_hinweis: config.ustHinweis, + begruendung, + portal_url: `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${encodeURIComponent(projectId)}`, + }, + blocks: { angenommen, abgelehnt: !angenommen }, + }) + return true + } catch (err) { + console.warn(`[unterkunft] K34 nicht gesendet: ${err?.message}`) + return false + } } diff --git a/server/services/unterkunft/freischaltung.js b/server/services/unterkunft/freischaltung.js index cf66a75..47a21f3 100644 --- a/server/services/unterkunft/freischaltung.js +++ b/server/services/unterkunft/freischaltung.js @@ -1,6 +1,33 @@ -// Stub (Task 5): Freischaltpruefung des Buchungskalenders. Task 11 ersetzt dieses Modul. +import { preiseVollstaendig } from './preis.js' +import { ladeEinstellungen, ladePreise, listeUnterkuenfte } from './speicher.js' -/** freischaltbar-Haken (Stub, fail-closed: nie freigeben). */ -export async function freischaltbar() { - return false +/** + * freischaltbar-Haken des Buchungskalenders (featureRegistry, Zustand 'setup', Ticket geschlossen): darf die Funktion jetzt aktiv + * werden? Ja, wenn der Kunde alles hinterlegt hat, was Rechnungen und Buchungen brauchen: + * - Einstellungen: firma, strasse, plz, ort, email und (steuernummer oder ustId), dazu rechnungAuftragAt (Zustimmung zur + * Rechnungsstellung im Namen des Vermieters); + * - mindestens eine aktive Unterkunft mit vollstaendigen Preisen (preiseVollstaendig) und Stornobedingungen. + * Fail-closed: bei jedem Lesefehler false. + * @param {{ $id: string }} doc portalFeatures-Dokument + * @returns {Promise} + */ +export async function freischaltbar(doc) { + try { + const featureId = doc?.$id + if (typeof featureId !== 'string' || !featureId) return false + const einst = await ladeEinstellungen(featureId) + if (!einst) return false + const text = (wert) => typeof wert === 'string' && wert.trim() !== '' + if (!['firma', 'strasse', 'plz', 'ort', 'email'].every((k) => text(einst[k]))) return false + if (!text(einst.steuernummer) && !text(einst.ustId)) return false + if (!einst.rechnungAuftragAt) return false + const unterkuenfte = await listeUnterkuenfte(featureId, { nurAktive: true }) + for (const u of unterkuenfte) { + const preise = await ladePreise(u.$id) + if (preiseVollstaendig(preise) && text(preise.stornobedingungen)) return true + } + return false + } catch { + return false + } } diff --git a/server/services/unterkunft/ticketDetails.js b/server/services/unterkunft/ticketDetails.js new file mode 100644 index 0000000..8352c9f --- /dev/null +++ b/server/services/unterkunft/ticketDetails.js @@ -0,0 +1,62 @@ +import { TICKET_DETAILS_MAX, anleitungsLink, kuerze, minimaleTicketDetails } from '../featureRegistry.js' +import { ART_NAMEN, PORTAL_NAMEN } from './anfrageFunktion.js' +import { ladeEinstellungen } from './speicher.js' + +/** + * Details des Einrichtungs-Tickets fuer den Buchungskalender (config.features.unterkunft.ticket.details, Spezifikation Abschnitt 9). + * ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt (doc = portalFeatures-Dokument). Deterministischer Text: + * Kopfzeilen, Kurzfassung der Anfrage (best effort aus ukEinstellungen; Lesefehler => ohne), Snippet und Schritte. Wirft nie. + */ + +/** Kurzfassung der Anfrage als Zeile, oder ''. */ +async function anfrageZeile(doc) { + try { + const a = (await ladeEinstellungen(doc.$id))?.anfrage + if (a === null || typeof a !== 'object' || Array.isArray(a)) return '' + const teile = [] + if (Object.hasOwn(ART_NAMEN, a.art)) teile.push(`Art: ${ART_NAMEN[a.art]}`) + if (Number.isInteger(a.einheiten)) teile.push(`Einheiten: ${a.einheiten}`) + const portale = (Array.isArray(a.portale) ? a.portale : []).filter((p) => Object.hasOwn(PORTAL_NAMEN, p)).map((p) => PORTAL_NAMEN[p]) + teile.push(`Portale: ${portale.length ? portale.join(', ') : 'keine'}`) + return `Anfrage: ${teile.join(', ')}` + } catch { + return '' + } +} + +export async function unterkunftTicketDetails(ctx) { + try { + const { doc } = ctx + const siteKey = doc?.siteKey || '' + const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Buchungskalender' } }).split('\n').filter((zeile) => !zeile.startsWith('Verweis: ')) + const anfrage = doc?.$id ? await anfrageZeile(doc) : '' + const zeilen = [ + ...kopf, + ...(anfrage ? [anfrage] : []), + '', + 'Snippet (an der Stelle des Kalender-Bereichs einsetzen):', + `
`, + '', + '', + 'Schritte:', + '1. Seite oder Bereich für den Kalender anlegen und Menüpunkt setzen, Snippet einsetzen.', + '2. CSS-Variablen (--wk-uk-akzent, --wk-uk-text, --wk-uk-flaeche, --wk-uk-linie, --wk-uk-radius, --wk-uk-schrift) an die Seite anpassen.', + '3. Datenschutz-Absatz Buchungskalender in die Datenschutzerklärung einfügen.', + '4. Impressum prüfen.', + '5. Testanfrage über den Kalender stellen und im Portal (Buchungen) prüfen.', + '6. Ticket schließen: das schaltet die Funktion frei (sobald der Kunde Unterkunft, Preise, Stornobedingungen und Firmendaten hinterlegt hat).', + '', + 'Zeit im Arbeitsblatt erfassen, nicht auf Abo-Stunden anrechnen.', + 'Einbauanleitung: docs/funktionen/unterkunft-einbau.md', + `Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`, + ] + return kuerze(zeilen.join('\n').toWellFormed(), TICKET_DETAILS_MAX, { anZeile: true }) + } catch (err) { + console.error(`[unterkunft-ticket] Details nicht erzeugbar, Notfall-Details: ${err?.message}`) + try { + return kuerze(minimaleTicketDetails({ ...ctx, feature: { key: 'Buchungskalender' } }).toWellFormed(), TICKET_DETAILS_MAX) + } catch { + return 'Funktion: Buchungskalender\nDetails konnten nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).' + } + } +} diff --git a/test/unterkunftAnfrageFunktion.test.js b/test/unterkunftAnfrageFunktion.test.js new file mode 100644 index 0000000..e1bcf5d --- /dev/null +++ b/test/unterkunftAnfrageFunktion.test.js @@ -0,0 +1,291 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Buchungskalender Task 11: Funktion bestellen "auf Anfrage", Freischaltung, Ticket-Details, Team-API +const NAMEN = ['portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukAnfragen', 'ukSperren', 'ukRechnungen', 'featureConsents'] +const aw = await startAppwriteMock({ + schema: Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])), + datetimes: { ukEinstellungen: ['rechnungAuftragAt', 'anfrageAt', 'anfrageEntschiedenAt', 'icalFehlerMailAt'] }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, send, rawGet, post: rawPost } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, seedFixtures, customers } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const af = await import('../server/services/unterkunft/anfrageFunktion.js') +const { freischaltbar } = await import('../server/services/unterkunft/freischaltung.js') +const { unterkunftTicketDetails } = await import('../server/services/unterkunft/ticketDetails.js') +const { featureHaken } = await import('../server/services/featureRegistry.js') +const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/unterkunft': ukRoutes, '/api/team': teamRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +seedFixtures(aw) +aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) +const P = 'pu1' +const FEATURE = `f_${P}_unterkunft` +const cookie = customerCookie('c1') +const staff = { headers: { authorization: 'Bearer jwt-staff' } } +const person = { name: 'Kunde Eins', email: customers.c1.email } +const vorher = { ...config.unterkunft } +const GUELTIG = { art: 'ferienwohnung', einheiten: 3, portale: ['booking', 'airbnb'], nachricht: 'Hallo' } +const stelle = (b = {}, opts = { cookie }) => send('POST', `/api/unterkunft/anfrage-funktion?projectId=${P}`, { ...opts, body: { ...GUELTIG, ...b } }) +const zurueck = (opts = { cookie }) => send('POST', `/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=${P}`, { ...opts, body: {} }) +const status = (opts = { cookie }) => send('GET', `/api/unterkunft/status?projectId=${P}`, opts) +const entscheide = (b, opts = staff) => rawPost(`/api/team/unterkunft/c1/anfrage`, { projectId: P, ...b }, opts) +const einst = () => aw.docs('ukEinstellungen').get(FEATURE) ?? null + +// Mails: Vorlagen K34/T22 liegen auf einem anderen Strang, der Test prueft nur den Aufruf +const aufrufe = { admin: [], kunde: [], adminFehler: false, kundeFehler: false } +af._versand.admin = async (m) => { + aufrufe.admin.push(m) + if (aufrufe.adminFehler) throw new Error('SMTP') + return { sent: true } +} +af._versand.kunde = async (an, m) => { + aufrufe.kunde.push({ an, ...m }) + if (aufrufe.kundeFehler) throw new Error('SMTP') +} + +beforeEach(async () => { + for (const c of [...NAMEN, 'websiteProjects']) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + Object.assign(config.unterkunft, vorher, { mode: 'pilot', pilotEmails: [customers.c1.email] }) + resetUnterkunftRoutesForTests() + aufrufe.admin.length = 0 + aufrufe.kunde.length = 0 + aufrufe.adminFehler = aufrufe.kundeFehler = false + aw.seed('websiteProjects', { $id: P, customerId: 'c1', projectName: 'Donau', archived: false }) + aw.seed('websiteProjects', { $id: 'pfremd', customerId: 'c2', projectName: 'Fremd' }) +}) + +test('normalisiereAnfrage: gueltig, Alias monteur, Fehler je Feld', () => { + const ok = af.normalisiereAnfrage({ ...GUELTIG, art: 'monteur', x: 1 }) + assert.deepEqual(ok.anfrage, { art: 'monteurzimmer', einheiten: 3, portale: ['booking', 'airbnb'], nachricht: 'Hallo' }) + const f = (a) => af.normalisiereAnfrage({ ...GUELTIG, ...a }).fehler + assert.ok(f({ art: 'villa' }).art) + assert.ok(f({ art: 'constructor' }).art) + assert.ok(f({ einheiten: 0 }).einheiten) + assert.ok(f({ einheiten: '3' }).einheiten) + assert.ok(f({ einheiten: 1000 }).einheiten) + assert.equal(f({ einheiten: 999 }), undefined) + assert.ok(f({ portale: ['ebay'] }).portale) + assert.ok(f({ portale: 'booking' }).portale) + assert.deepEqual(af.normalisiereAnfrage({ ...GUELTIG, portale: [] }).anfrage.portale, []) + assert.deepEqual(af.normalisiereAnfrage({ ...GUELTIG, portale: true }).anfrage.portale, ['andere']) + assert.ok(f({ nachricht: 'n'.repeat(1001) }).nachricht) + assert.equal(af.normalisiereAnfrage({ ...GUELTIG, nachricht: undefined }).anfrage.nachricht, '') + assert.ok(af.normalisiereAnfrage(null).fehler.art) +}) + +test('Anfrage stellen: gespeichert, T22 ans Team, Status-Block fuer state off', async () => { + const leer = await status() + assert.equal(leer.status, 200) + assert.equal(leer.body.state, 'off') + assert.equal(leer.body.anfrage, null) + const res = await stelle({ art: 'monteur' }) + assert.equal(res.status, 200) + assert.equal(res.body.anfrage.status, 'offen') + assert.equal(res.body.anfrage.anfrage.art, 'monteur', 'das Portal liest monteur') + assert.deepEqual(res.body.anfrage.anfrage.portale, ['booking', 'airbnb']) + assert.equal(einst().anfrageStatus, 'offen') + assert.equal(JSON.parse(einst().anfrage).art, 'monteurzimmer') + assert.equal(aw.docs('portalFeatures').get(FEATURE).state, 'off') + assert.equal(aufrufe.admin.length, 1) + assert.equal(aufrufe.admin[0].template, 'T22') + assert.equal(aufrufe.admin[0].values.projekt, 'Donau') + assert.equal(aufrufe.admin[0].values.einheiten, '3') + assert.equal(aufrufe.admin[0].values.art, 'Monteurwohnung') + assert.equal(aufrufe.admin[0].values.portale, 'Booking.com, Airbnb') + assert.equal(aufrufe.kunde.length, 0) + const s = (await status()).body.anfrage + assert.equal(s.status, 'offen') + assert.ok(s.at) + assert.deepEqual(Object.keys(s.anfrage).sort(), ['art', 'einheiten', 'nachricht', 'portale']) +}) + +test('Anfrage: doppelt 409, nur bei state off, Eingabefehler, Schalter, fremdes Projekt, Kundenansicht', async () => { + assert.equal((await stelle()).status, 200) + const doppelt = await stelle() + assert.equal(doppelt.status, 409) + assert.equal(doppelt.body.fehler, 'anfrage_laeuft') + assert.equal(aufrufe.admin.length, 1) + + await deleteDocument(config.collections.ukEinstellungen, FEATURE) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true }) + const aktiv = await stelle() + assert.equal(aktiv.status, 409) + assert.equal(aktiv.body.fehler, 'vorhanden') + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false }) + + const schlecht = await stelle({ einheiten: 0, art: 'x' }) + assert.equal(schlecht.status, 400) + assert.equal(schlecht.body.fehler, 'ungueltig') + assert.ok(schlecht.body.felder.einheiten && schlecht.body.felder.art) + assert.equal(einst(), null) + + assert.equal((await send('POST', '/api/unterkunft/anfrage-funktion?projectId=pfremd', { cookie, body: GUELTIG })).status, 403) + assert.equal((await send('POST', '/api/unterkunft/anfrage-funktion', { cookie, body: GUELTIG })).status, 400) + assert.equal((await stelle({}, {})).status, 401) + assert.equal((await stelle({}, { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'a'.repeat(32) } })).status, 403) + config.unterkunft.mode = 'off' + const aus = await stelle() + assert.equal(aus.status, 409) + assert.equal(aus.body.fehler, 'nicht_verfuegbar') + assert.equal(einst(), null) +}) + +test('Anfrage: Mail-Fehler bei T22 verhindert nichts; Grenze 20 je Stunde', async () => { + aufrufe.adminFehler = true + assert.equal((await stelle()).status, 200) + assert.equal(einst().anfrageStatus, 'offen') + let letzter + for (let i = 0; i < 20; i++) letzter = await zurueck() + assert.equal(letzter.status, 429) +}) + +test('zurueckziehen: nur offen; danach neue Anfrage moeglich; Status-Block zeigt keine zurueckgezogene', async () => { + assert.equal((await zurueck()).status, 409) + await stelle() + const z = await zurueck() + assert.equal(z.status, 200) + assert.equal(z.body.anfrage.status, 'zurueckgezogen') + assert.equal((await status()).body.anfrage, null) + assert.equal((await zurueck()).status, 409) + assert.equal((await stelle({ einheiten: 5 })).status, 200) + assert.equal(JSON.parse(einst().anfrage).einheiten, 5) +}) + +test('Team: Uebersicht je Projekt und Entscheidung annehmen/ablehnen mit K34', async () => { + assert.equal((await rawGet('/api/team/unterkunft/c1', {})).status, 401) + const leer = (await rawGet('/api/team/unterkunft/c1', staff)).body.projects + assert.equal(leer.length, 1) + assert.deepEqual(leer[0], { + projectId: P, name: 'Donau', state: 'off', unterkuenfte: 0, offeneAnfragen: 0, stripe: { status: 'keins' }, + anfrage: { status: '', at: null, art: '', einheiten: null, portale: false, nachricht: '', entschiedenAt: null, entschiedenVon: '', begruendung: '' }, + }) + await stelle({ art: 'monteur', portale: [] }) + const p = (await rawGet('/api/team/unterkunft/c1', staff)).body.projects[0] + assert.equal(p.anfrage.status, 'offen') + assert.equal(p.anfrage.art, 'monteurzimmer') + assert.equal(p.anfrage.einheiten, 3) + assert.equal(p.anfrage.portale, false) + + assert.equal((await entscheide({ entscheidung: 'vielleicht' })).status, 400) + const ohneGrund = await entscheide({ entscheidung: 'ablehnen', begruendung: ' ' }) + assert.equal(ohneGrund.status, 400) + assert.equal(einst().anfrageStatus, 'offen') + + assert.deepEqual(await featureHaken('unterkunft').bestellpruefung({ customerId: 'c1', projectId: P }), [af.ANFRAGE_OFFEN_TEXT]) + const ab = await entscheide({ entscheidung: 'ablehnen', begruendung: 'Passt nicht.' }) + assert.equal(ab.status, 200) + assert.equal(ab.body.anfrage.status, 'abgelehnt') + assert.equal(aufrufe.kunde.length, 1) + assert.equal(aufrufe.kunde[0].template, 'K34') + assert.equal(aufrufe.kunde[0].an, customers.c1.email) + assert.equal(aufrufe.kunde[0].values.begruendung, 'Passt nicht.') + assert.deepEqual(aufrufe.kunde[0].blocks, { angenommen: false, abgelehnt: true }) + assert.equal((await status()).body.anfrage.begruendung, 'Passt nicht.') + assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 409, 'nicht mehr offen') + + await stelle() + assert.equal(einst().anfrageBegruendung, '', 'neue Anfrage loescht die Entscheidung') + aufrufe.kundeFehler = true + const an = await entscheide({ entscheidung: 'annehmen' }) + assert.equal(an.status, 200) + assert.equal(an.body.mailFehler, true) + assert.equal(an.body.anfrage.status, 'angenommen') + assert.equal(einst().anfrageEntschiedenVon, 'Andrej ') + assert.deepEqual(aufrufe.kunde.at(-1).blocks, { angenommen: true, abgelehnt: false }) + assert.deepEqual(await af.anfrageOffenePunkte({ customerId: 'c1', projectId: P }), []) + assert.deepEqual(await featureHaken('unterkunft').bestellpruefung({ customerId: 'c1', projectId: P }), []) + assert.equal((await status()).body.anfrage.status, 'angenommen') + // fremdes Projekt des Kunden + assert.equal((await rawPost('/api/team/unterkunft/c1/anfrage', { projectId: 'pfremd', entscheidung: 'annehmen' }, staff)).status, 403) +}) + +test('anfrageOffenePunkte: ohne Dokumente offen, Lesefehler wird weitergereicht', async () => { + assert.equal((await af.anfrageOffenePunkte({ customerId: 'c1', projectId: P })).length, 1) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false }) + assert.equal((await af.anfrageOffenePunkte({ customerId: 'c1', projectId: P })).length, 1) +}) + +// ------------------------------------------------------------------------------------------------ Freischaltung + +const EINST = { + firma: 'Donau Home', strasse: 'Weg 1', plz: '78576', ort: 'Stetten', email: 'mimi@example.com', steuernummer: '12/345/67890', ustId: '', + rechnungAuftragAt: '2026-10-01T00:00:00.000Z', +} +const PREISE = { + staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]), reinigungCent: 5000, personenInklusive: 2, aufpreisPersonNachtCent: 1500, + direktrabattProzent: 0, steuerart: 'regel', ustUebernachtung: 7, ustReinigung: 19, fristTage: 3, stornobedingungen: 'Kostenlos bis 7 Tage vor Anreise.', +} +function seedFrei({ einst = {}, aktiv = true, preise = {} } = {}) { + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false }) + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, ...einst }) + aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: P, name: 'Erste', maxPersonen: 4, aktiv, sort: 1 }) + aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, ...PREISE, ...preise }) +} +const doc = () => aw.docs('portalFeatures').get(FEATURE) + +test('freischaltbar: false ohne Daten, true mit vollstaendigen Daten, je Pflicht eine Gegenprobe', async () => { + assert.equal(await freischaltbar({ $id: FEATURE }), false) + assert.equal(await freischaltbar(null), false) + seedFrei() + assert.equal(await freischaltbar(doc()), true) + assert.equal(await featureHaken('unterkunft').freischaltbar(doc()), true) + const ust = { steuernummer: '', ustId: 'DE123456789' } + for (const [name, kaputt] of [ + ['Firma', { einst: { firma: '' } }], ['Strasse', { einst: { strasse: ' ' } }], ['PLZ', { einst: { plz: '' } }], ['Ort', { einst: { ort: '' } }], + ['E-Mail', { einst: { email: '' } }], ['Steuer', { einst: { steuernummer: '', ustId: '' } }], ['Auftrag', { einst: { rechnungAuftragAt: null } }], + ['Unterkunft inaktiv', { aktiv: false }], ['Preise leer', { preise: { staffeln: '[]' } }], ['Storno leer', { preise: { stornobedingungen: ' ' } }], + ]) { + await beforeEachClear() + seedFrei(kaputt) + assert.equal(await freischaltbar(doc()), false, name) + } + await beforeEachClear() + seedFrei({ einst: ust }) + assert.equal(await freischaltbar(doc()), true, 'ustId statt Steuernummer') +}) + +async function beforeEachClear() { + for (const c of ['portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise']) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) +} + +test('freischaltbar: Lesefehler => false (fail-closed)', async () => { + seedFrei() + const d = doc() + const echt = config.collections.ukPreise + config.collections.ukPreise = 'gibt_es_nicht' + try { + assert.equal(await freischaltbar(d), false) + } finally { + config.collections.ukPreise = echt + } +}) + +test('ticketDetails: siteKey, Snippet, Schritte, Verweis, Anfrage; wirft nie', async () => { + seedFrei() + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, anfrage: JSON.stringify({ art: 'pension', einheiten: 4, portale: ['airbnb'], nachricht: 'x' }), anfrageStatus: 'angenommen' }) + const text = await unterkunftTicketDetails({ doc: doc(), project: { projectName: 'Donau' }, customer: { name: 'Mimi' } }) + assert.match(text, /siteKey: abcdefghijklmnop/) + assert.ok(text.includes('
')) + assert.ok(text.includes('')) + assert.match(text, /docs\/funktionen\/unterkunft-einbau\.md/) + assert.match(text, /Art: Pension oder Gästezimmer, Einheiten: 4, Portale: Airbnb/) + assert.match(text, /Funktion: Buchungskalender/) + assert.match(text, /CSS-Variablen/) + assert.match(text, /Verweis: /) + const notfall = await unterkunftTicketDetails(null) + assert.match(notfall, /Buchungskalender/) + const viaConfig = await config.features.unterkunft.ticket.details({ doc: doc(), project: {}, customer: null, feature: { key: 'Buchungskalender' } }) + assert.ok(viaConfig.includes('abcdefghijklmnop')) +}) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js index 87cc241..5e94142 100644 --- a/test/unterkunftWiring.test.js +++ b/test/unterkunftWiring.test.js @@ -85,7 +85,7 @@ test('Haken: featureHaken(unterkunft) hat alle fünf, Stubs und Schalter wirken' assert.equal(h.verfuegbar('a@b.de'), false) config.unterkunft.mode = 'on' assert.equal(h.verfuegbar('a@b.de'), true) - assert.deepEqual(await h.bestellpruefung({ customerId: KUNDE, projectId: PROJEKT }), ['Anfrage noch nicht freigegeben']) + assert.deepEqual(await h.bestellpruefung({ customerId: KUNDE, projectId: PROJEKT }), [(await import('../server/services/unterkunft/anfrageFunktion.js')).ANFRAGE_OFFEN_TEXT]) assert.equal(await h.freischaltbar({ $id: FEATURE }), false) })