Buchungskalender: Bestellung auf Anfrage, Freischaltung und Ticket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:44:21 +00:00
1 parent 827ee54875
commit 9d01d38a45
7 files changed
+799 -15

No files matched your search

+80 -3
View File
@@ -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
+43 -2
View File
@@ -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) {