261 lines
11 KiB
JavaScript
261 lines
11 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 { duFehlerAntworten } from '../services/duFehler.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(duFehlerAntworten) // neue Ansicht: bekannte Fehlertexte in du (duFehler.js), sonst unverändert
|
|
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')
|
|
}
|
|
})
|