Admin: Reiter E-Mail-Speicher, Abo mit Produkt; Schnittstelle fürs Ticketsystem
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
19f9c747a2
commit
63d3bb11bf
10 files changed
+525
-4
No files matched your search
@@ -83,6 +83,9 @@ const mailStorage = {
|
||||
includedGb: 5,
|
||||
maxUnits: 10,
|
||||
priceNickname: 'E-Mail-Zusatzspeicher 5 GB monatlich',
|
||||
// Hetzner-Paket (konsoleH), auf dem alle Postfaecher liegen: Gesamtgroesse und die Schwelle, ab der
|
||||
// der Admin-Reiter "E-Mail-Speicher" rot warnt (Task 13)
|
||||
hetznerPackage: { name: 'Webhosting M', gb: 50, warnGb: 40 },
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { PRODUCT_KEYS } from '../../services/products.js'
|
||||
import { ACTIVE_STATUSES, getCurrentSubscription } from '../../services/stripeBilling.js'
|
||||
import {
|
||||
createCustomerWithPortalAccess,
|
||||
deleteCustomerWithPortalAccess,
|
||||
@@ -41,6 +43,23 @@ router.patch('/:customerId', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Schnittstelle fuers Ticketsystem (Task 13): Produkt und Stand des Abos eines Kunden. WOMS liest daraus,
|
||||
// ob der Kunde ein Wartungs-Abo hat (Arbeitszeit-Kontingent). Altbestand ohne productKey gilt als
|
||||
// Wartungs-Abo (wie offers.js/takeoverCredits.js); ohne Abo productKey null.
|
||||
router.get('/:customerId/abo', async (req, res) => {
|
||||
try {
|
||||
const sub = await getCurrentSubscription(req.params.customerId)
|
||||
if (!sub) return res.json({ productKey: null, active: false })
|
||||
return res.json({
|
||||
productKey: PRODUCT_KEYS.includes(sub.productKey) ? sub.productKey : 'wartung',
|
||||
active: ACTIVE_STATUSES.has(sub.status),
|
||||
})
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message || 'Abo konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:customerId', async (req, res) => {
|
||||
try {
|
||||
return res.json(await deleteCustomerWithPortalAccess(req.params.customerId))
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
uploadRateLimited,
|
||||
} from '../services/chatAttachments.js'
|
||||
import { offersAdminRouter } from './offers.js'
|
||||
import { storageTasksAdminRouter } from './storageTasks.js'
|
||||
import { takeoverAdminRouter } from './takeoverCredits.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -37,6 +38,8 @@ router.use(requireAdminSession)
|
||||
router.use('/takeover-credits', takeoverAdminRouter)
|
||||
// Individuelle Abo-Angebote (Tab "Abo-Angebote")
|
||||
router.use('/offers', offersAdminRouter)
|
||||
// Zusatzspeicher-Aufgaben fuers Team (Tab "E-Mail-Speicher")
|
||||
router.use('/storage-tasks', storageTasksAdminRouter)
|
||||
|
||||
function norm(value) {
|
||||
return String(value || '').toLowerCase()
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* Admin-Reiter "E-Mail-Speicher" (Task 13): 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/portalAdmin.js unter /api/portal-admin/storage-tasks, dort hinter
|
||||
* requireAdminSession.
|
||||
*
|
||||
* 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 || '',
|
||||
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: 5 GB je laufendem Abo plus 5 GB je gebuchtem Paket (F40). */
|
||||
async function allocatedGb() {
|
||||
const subs = await listDocuments(config.collections.subscriptions, [
|
||||
Query.equal('status', [...ACTIVE_STATUSES]),
|
||||
Query.limit(1000),
|
||||
])
|
||||
return subs.reduce((sum, sub) => sum + gbOf(sub.storageUnits), 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)
|
||||
}
|
||||
}
|
||||
|
||||
storageTasksAdminRouter.get('/', async (_req, res) => {
|
||||
try {
|
||||
await reconcileOpenSwitches()
|
||||
const since = new Date(Date.now() - FAILED_VISIBLE_MS).toISOString()
|
||||
const [open, failedAll, gb] = await Promise.all([
|
||||
listOpenStorageTasks(),
|
||||
listContractEventsOf('speicher', 'gescheitert'),
|
||||
allocatedGb(),
|
||||
])
|
||||
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 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, staffEmailOf(req))
|
||||
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')
|
||||
}
|
||||
})
|
||||
@@ -154,6 +154,20 @@ export async function listOpenStorageTasks() {
|
||||
return docs.map((doc) => ({ ...doc, details: parseJson(doc.details, {}) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ereignisse eines Typs in einem oder mehreren Status (z. B. offene Wechsel fuer den Abgleich, gescheiterte
|
||||
* Speicher-Aenderungen fuer den Admin-Reiter), neueste zuerst, mit geparstem details.
|
||||
*/
|
||||
export async function listContractEventsOf(type, status) {
|
||||
const docs = await listDocuments(COLLECTION(), [
|
||||
Query.equal('type', type),
|
||||
Query.equal('status', Array.isArray(status) ? status : [status]),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(1000),
|
||||
])
|
||||
return docs.map((doc) => ({ ...doc, details: parseJson(doc.details, {}) }))
|
||||
}
|
||||
|
||||
/** Zusatzspeicher-Aufgabe erledigt (Team hat die Postfachgroesse angepasst). */
|
||||
export async function completeContractEvent(id, staffEmail) {
|
||||
return updateDocument(COLLECTION(), id, {
|
||||
|
||||
@@ -739,6 +739,8 @@ export async function getAdminDetail(id) {
|
||||
active: isActiveSub(subscription),
|
||||
productKey: productKeyOf(subscription),
|
||||
productName: getProduct(productKeyOf(subscription)).name,
|
||||
// Zusatzspeicher (Pakete zu 5 GB) fuer die Zeile "Abo" im Admin (Task 13)
|
||||
storageUnits: Number(subscription.storageUnits) || 0,
|
||||
startedAt: subscription.startedAt,
|
||||
serviceStart: serviceStartOf(subscription),
|
||||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||
|
||||
Reference in new issue
Block a user