Files
Webklar-Kundenbereich/server/routes/takeoverCredits.js
T

259 lines
10 KiB
JavaScript

import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
import { signFileLink } from '../services/signedLinks.js'
import { withLocks } from '../services/keyedLock.js'
import { nachweisTexte } from '../services/bestelltexte.js'
import { kundenartWirksam } from '../services/kundenart.js'
import { fassungDerAnfrage, speichereNachweisFuerRoute } from '../services/vertragsNachweis.js'
import {
answerQuestion,
applyTakeoverCredit,
calculationText,
computeTakeoverCredit,
createTakeoverRequest,
customerView,
decideTakeoverRequest,
getAdminDetail,
getTakeoverRequest,
listTakeoverRequests,
parseUploads,
readInvoiceFigures,
readUpload,
servicesLabel,
shortRef,
STATUS,
validateRequestInput,
} from '../services/takeoverCredits.js'
/**
* Uebernahmegutschrift.
* Kunde: /api/abo/takeover-credits (Antrag stellen, Rueckfrage beantworten)
* Team: /api/team/takeover-credits (Ticketsystem: Liste, Pruefung, Entscheidung, Nachweise)
* Der JSON-Body darf hier groesser sein (Nachweise als Base64), siehe server/index.js.
*/
// Missbrauchsschutz: je Kunde hoechstens 20 Versuche pro Stunde (zusaetzlich max. 3 offene Antraege)
const recentByCustomer = new Map()
function rateLimited(customerId) {
const now = Date.now()
const list = (recentByCustomer.get(customerId) || []).filter((t) => now - t < 60 * 60 * 1000)
list.push(now)
recentByCustomer.set(customerId, list)
return list.length > 20
}
function sendError(res, err, fallback) {
const status = err.status || 500
if (status >= 500) console.error('[takeover]', fallback, err.message)
return res.status(status).json({ error: err.message || fallback })
}
// ---------------------------------------------------------------------------
// Kunde (eingeloggt)
// ---------------------------------------------------------------------------
const router = Router()
router.use(requireSession)
router.get('/', async (req, res) => {
try {
const docs = await listTakeoverRequests({ customerId: getSessionCustomerId(req) })
return res.json({
requests: docs.map(customerView),
conditionsVersion: config.takeover.conditionsVersion,
limits: {
maxFiles: config.takeover.maxFiles,
maxFileBytes: config.takeover.maxFileBytes,
maxTotalBytes: config.takeover.maxTotalBytes,
},
})
} catch (err) {
return sendError(res, err, 'Anträge konnten nicht geladen werden')
}
})
router.post('/', async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'In der Kundenansicht kann kein Antrag gestellt werden.' })
}
const customerId = getSessionCustomerId(req)
if (rateLimited(customerId)) {
return res.status(429).json({ error: 'Zu viele Anträge in kurzer Zeit. Bitte später erneut versuchen.' })
}
try {
const input = validateRequestInput(req.body || {})
const uploads = parseUploads(req.body.files, { required: true })
const customer = await getDocument(config.collections.customers, customerId)
// Fassung 6 (neue Ansicht, body.termsVersion = config.abo.termsVersionNeu): Satz und Knopf werden als Nachweis gespeichert, bevor der
// Antrag entsteht; die Kundenart kommt aus dem Kundendokument, nie aus dem Body. Ohne Fassung 6 bleibt alles wie bisher.
if (fassungDerAnfrage(req, req.body.termsVersion) === 'neu') {
await speichereNachweisFuerRoute({
customerId,
art: 'gutschrift',
bezug: '',
texte: nachweisTexte('gutschrift', {}),
fassungen: { zustimmung: config.takeover.conditionsVersion },
kundenart: kundenartWirksam(customer),
req,
at: new Date().toISOString(),
})
}
const doc = await createTakeoverRequest({ customer, input, uploads, consentIp: req.ip })
return res.status(201).json({ request: customerView(doc) })
} catch (err) {
return sendError(res, err, 'Antrag konnte nicht gespeichert werden')
}
})
router.post('/:id/reply', async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
const customerId = getSessionCustomerId(req)
if (rateLimited(customerId)) {
return res.status(429).json({ error: 'Zu viele Anfragen in kurzer Zeit. Bitte später erneut versuchen.' })
}
try {
const doc = await getTakeoverRequest(req.params.id).catch(() => null)
if (!doc || doc.customerId !== customerId) return res.status(404).json({ error: 'Antrag nicht gefunden' })
const uploads = parseUploads(req.body?.files, { required: false })
const customer = await getDocument(config.collections.customers, customerId)
const updated = await answerQuestion({ customer, doc, message: req.body?.message, uploads })
return res.json({ request: customerView(updated) })
} catch (err) {
return sendError(res, err, 'Antwort konnte nicht gespeichert werden')
}
})
export default router
// ---------------------------------------------------------------------------
// Team (eingehaengt in routes/team/index.js, dort hinter requireStaffJwt)
// ---------------------------------------------------------------------------
export const takeoverAdminRouter = Router()
takeoverAdminRouter.get('/', async (req, res) => {
try {
const [docs, customers] = await Promise.all([
listTakeoverRequests(),
listDocuments(config.collections.customers, [Query.limit(1000)]),
])
const byId = new Map(customers.map((c) => [c.$id, c]))
const onlyOpen = req.query.status !== 'all'
// ?customerId= (Kunden-Reiter im Ticketsystem); openCount zaehlt weiter alle offenen Antraege
const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : ''
const requests = docs
.filter((d) => !customerId || d.customerId === customerId)
.filter((d) => !onlyOpen || d.status === STATUS.open || d.status === STATUS.question)
.map((d) => {
const c = byId.get(d.customerId)
return {
id: d.$id,
ref: shortRef(d.$id),
createdAt: d.$createdAt,
customerId: d.customerId,
customerName: c?.name || c?.companyName || d.email,
customerCode: c?.code || '',
services: servicesLabel(d.services),
provider: d.provider || '',
domain: d.domain || '',
invoiceAmountCents: d.invoiceAmountCents || 0,
billingMonths: d.billingMonths || 0,
paidUntil: d.paidUntil || null,
status: d.status,
amountCents: d.amountCents ?? null,
appliedAt: d.appliedAt || null,
}
})
return res.json({ requests, openCount: docs.filter((d) => d.status === STATUS.open).length })
} catch (err) {
return sendError(res, err, 'Anträge konnten nicht geladen werden')
}
})
/** Rechner im Pruefformular: dieselbe Berechnung wie beim Genehmigen (eine Quelle der Wahrheit). */
takeoverAdminRouter.post('/calc', (req, res) => {
try {
const figures = readInvoiceFigures(req.body, {})
const calc = computeTakeoverCredit({ ...figures, takeoverFrom: req.body?.takeoverFrom })
if (!calc) return res.status(400).json({ error: 'Bitte das Datum „Übernahme ab“ angeben.' })
return res.json({ calc, text: calculationText(figures, calc, calc.suggestedCents) })
} catch (err) {
return sendError(res, err, 'Berechnung fehlgeschlagen')
}
})
takeoverAdminRouter.get('/open-count', async (_req, res) => {
try {
const docs = await listTakeoverRequests()
return res.json({ open: docs.filter((d) => d.status === STATUS.open).length })
} catch {
return res.json({ open: 0 })
}
})
// Nachweise bekommen einen signierten Link (15 min) auf den Team-Eingang: das Ticketsystem oeffnet sie ohne
// Authorization-Header (routes/team/files.js). Bei jedem Oeffnen des Antrags neu signiert.
function withSignedFileUrls(detail, now = Date.now()) {
const id = detail.request.id
const base = `${config.publicBaseUrl}/api/team/takeover-credits/${encodeURIComponent(id)}/files`
const files = detail.request.files.map((file) => {
const { exp, sig } = signFileLink({ kind: 'takeover', customerId: id, id: file.id, now })
return { ...file, url: `${base}/${encodeURIComponent(file.id)}?exp=${exp}&sig=${sig}` }
})
return { ...detail, request: { ...detail.request, files } }
}
takeoverAdminRouter.get('/:id', async (req, res) => {
try {
return res.json(withSignedFileUrls(await getAdminDetail(req.params.id)))
} catch (err) {
return sendError(res, err, 'Antrag konnte nicht geladen werden')
}
})
takeoverAdminRouter.get('/:id/files/:fileId', async (req, res) => {
try {
const doc = await getTakeoverRequest(req.params.id)
const { file, content } = await readUpload(doc, req.params.fileId)
res.setHeader('Content-Type', file.type)
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`)
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Cache-Control', 'private, no-store')
return res.send(content)
} catch (err) {
return res.status(err.status || 500).send(err.message || 'Datei nicht verfügbar')
}
})
takeoverAdminRouter.post('/:id/decision', async (req, res) => {
try {
const admin = { name: req.session.adminName || req.session.name, email: req.session.adminEmail || req.session.email }
// Je Antrag nur eine Entscheidung gleichzeitig: wer zweitens kommt, sieht den neuen Status (409 "bereits entschieden")
const result = await withLocks([`takeover:${req.params.id}`], () => decideTakeoverRequest(req.params.id, req.body || {}, admin))
return res.json({
success: true,
status: result.doc.status,
applied: result.applied ?? null,
reason: result.reason || '',
mailSent: result.mail?.sent ?? false,
mailError: result.mail?.reason || '',
})
} catch (err) {
return sendError(res, err, 'Entscheidung konnte nicht gespeichert werden')
}
})
/** Genehmigte, aber noch nicht verrechnete Gutschrift erneut an Stripe senden. */
takeoverAdminRouter.post('/:id/apply', async (req, res) => {
try {
const result = await withLocks([`takeover:${req.params.id}`], async () => applyTakeoverCredit(await getTakeoverRequest(req.params.id)))
return res.json({ success: result.applied, applied: result.applied, reason: result.reason || '' })
} catch (err) {
return sendError(res, err, 'Verrechnung fehlgeschlagen')
}
})