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 { 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) 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') } })