Buchungskalender: Bestellung auf Anfrage, Freischaltung und Ticket
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
827ee54875
commit
9d01d38a45
7 files changed
+798
-14
No files matched your search
@@ -1,10 +1,87 @@
|
|||||||
import { Router } from 'express'
|
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
|
* 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).
|
* 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. Geruest (Task 5): Task 11 fuellt es.
|
* Eingehaengt in routes/team/index.js unter /unterkunft, dort hinter requireStaffJwt.
|
||||||
*/
|
*/
|
||||||
const router = Router()
|
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
|
export default router
|
||||||
@@ -10,6 +10,7 @@ import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../se
|
|||||||
import * as ablauf from '../services/unterkunft/ablauf.js'
|
import * as ablauf from '../services/unterkunft/ablauf.js'
|
||||||
import { istFrei } from '../services/unterkunft/belegung.js'
|
import { istFrei } from '../services/unterkunft/belegung.js'
|
||||||
import { istTag, naechte } from '../services/unterkunft/datum.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 { UkFehler } from '../services/unterkunft/fehler.js'
|
||||||
import { ladeFuerProjekt } from '../services/unterkunft/kontext.js'
|
import { ladeFuerProjekt } from '../services/unterkunft/kontext.js'
|
||||||
import { berechnePreis, preiseVollstaendig, summiere } from '../services/unterkunft/preis.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).
|
* 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
|
* 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).
|
* (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()
|
const router = Router()
|
||||||
// Jede Route hier braucht eine Sitzung; Kunden-Pruefung je Route (nurKunde)
|
// 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_WRITES_PER_HOUR = 300
|
||||||
|
const MAX_ANFRAGE_WRITES_PER_HOUR = 20
|
||||||
const MELDUNG = {
|
const MELDUNG = {
|
||||||
kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.',
|
kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.',
|
||||||
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
|
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 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)
|
const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
|
||||||
export function resetUnterkunftRoutesForTests() {
|
export function resetUnterkunftRoutesForTests() {
|
||||||
schreibLimiter.reset()
|
schreibLimiter.reset()
|
||||||
|
anfrageLimiter.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
const nurKunde = (req, res, next) => {
|
const nurKunde = (req, res, next) => {
|
||||||
@@ -227,7 +232,12 @@ router.get('/status', route(async (req, res) => {
|
|||||||
const doc = await getFeatureDoc(customerId, projectId, 'unterkunft')
|
const doc = await getFeatureDoc(customerId, projectId, 'unterkunft')
|
||||||
const state = doc?.state ?? 'off'
|
const state = doc?.state ?? 'off'
|
||||||
const antwort = { state, stripe: { status: 'keins', vorhandenesShopKonto: false }, onlineZahlung: false, unterkuenfte: 0, rechnungenVorhanden: false }
|
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([
|
const [einst, unterkuenfte, rechnungen, shop] = await Promise.all([
|
||||||
sp.ladeEinstellungen(doc.$id),
|
sp.ladeEinstellungen(doc.$id),
|
||||||
sp.listeUnterkuenfte(doc.$id),
|
sp.listeUnterkuenfte(doc.$id),
|
||||||
@@ -244,6 +254,37 @@ router.get('/status', route(async (req, res) => {
|
|||||||
res.json(antwort)
|
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
|
// ---- Einstellungen
|
||||||
|
|
||||||
async function einstellungenAntwort(ctx, einst) {
|
async function einstellungenAntwort(ctx, einst) {
|
||||||
|
|||||||
@@ -1,7 +1,293 @@
|
|||||||
// Stub (Task 5): Anfrage-Funktion des Buchungskalenders. Task 11 ersetzt dieses Modul.
|
import { config } from '../../config.js'
|
||||||
export const ANFRAGE_OFFEN_TEXT = 'Anfrage noch nicht freigegeben'
|
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() {
|
* Anfrage der Funktion "Buchungskalender" (Muster services/shop/anfrage.js, Spezifikation 2026-10-07-buchungskalender-design.md,
|
||||||
return [ANFRAGE_OFFEN_TEXT]
|
* 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) 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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() {
|
* 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<boolean>}
|
||||||
|
*/
|
||||||
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@@ -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):',
|
||||||
|
`<div data-webklar-unterkunft="${siteKey}" data-api="https://project.webklar.com"></div>`,
|
||||||
|
'<script src="https://project.webklar.com/embed/unterkunft.js" defer></script>',
|
||||||
|
'',
|
||||||
|
'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).'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <andrej@webklar.com>')
|
||||||
|
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('<div data-webklar-unterkunft="abcdefghijklmnop" data-api="https://project.webklar.com"></div>'))
|
||||||
|
assert.ok(text.includes('<script src="https://project.webklar.com/embed/unterkunft.js" defer></script>'))
|
||||||
|
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'))
|
||||||
|
})
|
||||||
@@ -85,7 +85,7 @@ test('Haken: featureHaken(unterkunft) hat alle fünf, Stubs und Schalter wirken'
|
|||||||
assert.equal(h.verfuegbar('a@b.de'), false)
|
assert.equal(h.verfuegbar('a@b.de'), false)
|
||||||
config.unterkunft.mode = 'on'
|
config.unterkunft.mode = 'on'
|
||||||
assert.equal(h.verfuegbar('a@b.de'), true)
|
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)
|
assert.equal(await h.freischaltbar({ $id: FEATURE }), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user