156 lines
7.0 KiB
JavaScript
156 lines
7.0 KiB
JavaScript
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')
|
|
}
|
|
})
|