import { Router } from 'express' import { config } from '../config.js' import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js' import { completeContractEvent, getContractEvent, listContractEventsOf, listOpenStorageTasks } from '../services/contractEvents.js' import { storageTaskKind } from '../services/mailStorage.js' import { reconcilePendingSwitch } from '../services/planSwitch.js' import { PRODUCT_KEYS, productLabel } from '../services/products.js' import { ACTIVE_STATUSES } from '../services/stripeBilling.js' /** * "E-Mail-Speicher" im Ticketsystem (Kunden -> E-Mail-Speicher): offene Zusatzspeicher-Aufgaben fuers Team * (Postfachgrenze in konsoleH setzen), die auf dem Hetzner-Paket vergebenen GB und die gescheiterten Aenderungen * der letzten Wochen. Eingehaengt in routes/team/index.js unter /api/team/storage-tasks, dort hinter * requireStaffJwt. * * Jede Aufgabe traegt ihre Art (details.kind, storageTaskKind): "einrichten" = jetzt auf das Ziel setzen, * "periodenende" = zum Faelligkeitstag auf das Ziel setzen. Hoechstens eine offene Aufgabe je Art und Abo * (mailStorage.js). Ein teilweise gescheitertes Plus (details.partial: Stripe steht auf der bezahlten * Menge) bleibt eine offene Aufgabe, das Team setzt die Menge in Stripe von Hand nach (T13). * * Beim Laden gleicht der Reiter ausserdem offene Wechsel mit verfallener Zahlung mit Stripe ab * (reconcilePendingSwitch): ein verlorener Stripe-Webhook faellt so auch ohne den Kunden auf. */ export const storageTasksAdminRouter = Router() // Gescheiterte Aenderungen (Stripe lehnte ab, nichts berechnet): so lange sichtbar const FAILED_VISIBLE_MS = 30 * 24 * 60 * 60 * 1000 const staffEmailOf = (req) => req.session.adminEmail || req.session.email || '' function sendError(res, err, fallback) { const status = err.status || 500 if (status >= 500) console.error('[speicher-aufgaben]', err.message) return res.status(status).json({ error: status >= 500 ? fallback : err.message || fallback }) } /** Namen und Adressen der Kunden zu den Ereignissen, ein Abruf je Kunde. */ async function customersOf(events) { const ids = [...new Set(events.map((e) => e.customerId).filter(Boolean))] const docs = await Promise.all(ids.map((id) => getDocument(config.collections.customers, id).catch(() => null))) return new Map(ids.map((id, index) => [id, docs[index]])) } const gbOf = (units) => config.mailStorage.includedGb + config.mailStorage.gbPerUnit * (Number(units) || 0) /** Ansicht eines Ereignisses `speicher` fuer den Reiter. */ function taskView(event, customer) { const d = event.details || {} const productKey = PRODUCT_KEYS.includes(event.productKey) ? event.productKey : null return { id: event.$id, customerId: event.customerId, customerName: customer?.name || customer?.companyName || customer?.code || event.customerId, customerCode: customer?.code || '', email: customer?.email || '', // Das Portal kennt keine eigene Mail-Domain des Kunden; gezeigt wird die Domain der Kontakt-Adresse contactDomain: String(customer?.email || '').split('@')[1] || '', subscriptionId: event.subscriptionId, productKey, productLabel: productLabel(productKey), kind: storageTaskKind(event), fromUnits: Number(d.fromUnits) || 0, toUnits: Number(d.toUnits) || 0, targetGb: gbOf(d.toUnits), dueAt: event.dueAt || null, createdAt: event.$createdAt || null, status: event.status, doneAt: event.doneAt || null, doneBy: event.doneBy || '', partial: Boolean(d.partial), viaCancellation: Boolean(d.viaCancellation), error: d.error || '', } } /** * Auf dem Hetzner-Paket vergebene GB, so wie konsoleH sie haelt (F40): 5 GB je laufendem Abo plus 5 GB je * Paket. Eine Verringerung gilt in konsoleH erst, wenn die Aufgabe "zum Periodenende" erledigt ist; bis * dahin zaehlt je Abo die groessere Menge aus storageUnits und der Ausgangsmenge (fromUnits) der Aufgabe. */ async function allocatedGb(openTasks) { const held = new Map() for (const e of openTasks) { if (storageTaskKind(e) !== 'periodenende') continue held.set(e.subscriptionId, Math.max(held.get(e.subscriptionId) || 0, Number(e.details?.fromUnits) || 0)) } const subs = await listDocuments(config.collections.subscriptions, [ Query.equal('status', [...ACTIVE_STATUSES]), Query.limit(1000), ]) return subs.reduce((sum, sub) => sum + gbOf(Math.max(Number(sub.storageUnits) || 0, held.get(sub.$id) || 0)), 0) } /** Offene Wechsel mit Stripe abgleichen; reconcilePendingSwitch prueft selbst, ob die Zahlung verfallen ist. */ async function reconcileOpenSwitches() { try { const open = await listContractEventsOf('wechsel', 'offen') for (const event of open) await reconcilePendingSwitch(event) } catch (err) { console.error('[speicher-aufgaben] Wechsel-Abgleich beim Laden fehlgeschlagen:', err.message) } } // Zahl offener Aufgaben fuer die Sidebar des Ticketsystems: ohne Wechsel-Abgleich (kein Stripe), jede Minute abgefragt storageTasksAdminRouter.get('/open-count', async (_req, res) => { try { return res.json({ open: (await listOpenStorageTasks()).length }) } catch { return res.json({ open: 0 }) } }) storageTasksAdminRouter.get('/', async (_req, res) => { try { await reconcileOpenSwitches() const since = new Date(Date.now() - FAILED_VISIBLE_MS).toISOString() const [open, failedAll] = await Promise.all([ listOpenStorageTasks(), listContractEventsOf('speicher', 'gescheitert'), ]) const gb = await allocatedGb(open) const failed = failedAll.filter((e) => String(e.$createdAt || '') >= since) const customers = await customersOf([...open, ...failed]) const { name, gb: packageGb, warnGb } = config.mailStorage.hetznerPackage return res.json({ tasks: open.map((e) => taskView(e, customers.get(e.customerId))), failed: failed.map((e) => taskView(e, customers.get(e.customerId))), allocatedGb: gb, packageGb, warnGb, packageName: name, }) } catch (err) { return sendError(res, err, 'Speicher-Aufgaben konnten nicht geladen werden') } }) storageTasksAdminRouter.post('/:id/done', async (req, res) => { try { const staffEmail = staffEmailOf(req) if (!staffEmail) return res.status(400).json({ error: 'E-Mail-Adresse des Mitarbeiters fehlt in der Sitzung' }) const event = await getContractEvent(req.params.id).catch((err) => { if (err.status === 404) return null throw err }) if (!event || event.type !== 'speicher') return res.status(404).json({ error: 'Speicher-Aufgabe nicht gefunden' }) if (event.status !== 'offen') return res.status(409).json({ error: `Diese Aufgabe ist nicht offen (Status: ${event.status})` }) await completeContractEvent(event.$id, staffEmail) const done = await getContractEvent(event.$id) const customer = await getDocument(config.collections.customers, done.customerId).catch(() => null) return res.json({ task: taskView(done, customer) }) } catch (err) { return sendError(res, err, 'Aufgabe konnte nicht als erledigt gespeichert werden') } })