Files
Webklar-Kundenbereich/server/services/unterkunft/anfrageFunktion.js
T

294 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
/**
* 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:<projectId>.
* 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<object>} 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<object>} 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 <email>' (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). */
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
}
}