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 { aboModellFuerKunde } from '../aboFunktionen.js' import { anfrageEntschiedenMail } from '../aboFunktionenMails.js' import { unterkunftFuerKunde } from './schalter.js' import { ladeEinstellungen, speichereEinstellungen } from './speicher.js' /** * 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: T23 ans Team (Anfrage eingegangen), K34 an den Kunden (Entscheidung). */ 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 'monteurzimmer'; 'monteur' (aeltere Fassung, ui-erwartungen.md) wird weiter als Alias angenommen und gelesen. */ 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). */ 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 T23 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) } /** T23 "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: 'T23', values: { kunde_name: kundeName || 'Kunde', email: person?.email || '', projekt: project?.projectName || projectId, // die Vorlage T23 kennt nur kunde_name, email, projekt, nachricht, woms_url: die Angaben stehen zusammen in der Nachricht nachricht: [ `Art: ${ART_NAMEN[anfrage.art]}`, `Einheiten: ${anfrage.einheiten}`, `Portale: ${anfrage.portale.length ? anfrage.portale.map((p) => PORTAL_NAMEN[p]).join(', ') : 'keine'}`, ...(anfrage.nachricht ? ['', anfrage.nachricht] : []), ].join('\n'), woms_url: `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId)}?tab=funktionen`, }, }) if (!result?.sent) console.warn(`[unterkunft] Team-Mail T23 nicht gesendet: ${result?.reason || 'unbekannt'}`) } catch (err) { console.warn(`[unterkunft] Team-Mail T23 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 || feature.state !== 'off') 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). Fuer Kunden der neuen Ansicht * (aboModellFuerKunde, Kundenansicht P6) geht stattdessen K43 ohne Preis und ohne "bestellen"; ist das nicht lesbar, geht keine Mail raus * (lieber keine als eine mit Preis, die Antwort traegt mailFehler). */ 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 } if (await aboModellFuerKunde(customerId)) { await _versand.kunde(kunde.email, anfrageEntschiedenMail({ kunde, projekt: project?.projectName || projectId, key: 'unterkunft', projectId, angenommen, begruendung })) return true } 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 } }