Admin E-Mail-Speicher: belegter Speicher wie in konsoleH, klare Vorgabe je Kunde

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-29 14:48:49 +00:00
1 parent 12a24514a4
commit cc42ab16c1
4 files changed
+69 -13

No files matched your search

+1 -1
View File
@@ -289,7 +289,7 @@
<h4>Gescheiterte Änderungen (letzte 30 Tage)</h4> <h4>Gescheiterte Änderungen (letzte 30 Tage)</h4>
<ul class="recent-list" id="storage-failed-list"></ul> <ul class="recent-list" id="storage-failed-list"></ul>
</div> </div>
<p class="admin-note">Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.</p> <p class="admin-note">Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Die Grenzen gelten für den Gesamtspeicher des Kunden (5 GB + 5 GB je Paket); er verteilt ihn nach seiner Vorgabe auf seine Postfächer (§ 3 Abs. 4). Angezeigt wird die Domain der Kontakt-Adresse, eine eigene Mail-Domain ist im Portal nicht gespeichert. „Vergeben“ zählt, was konsoleH gerade hält: eine Verringerung zählt erst nach „Erledigt“. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.</p>
</section> </section>
</main> </main>
+4 -2
View File
@@ -808,7 +808,8 @@ function storageTaskHtml(t) {
const partial = t.partial const partial = t.partial
? `<br><span class="storage-partial">Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf ${escapeHtml(t.toUnits)} Paket(en) – Stückzahl in Stripe von Hand nachziehen.${t.error ? ` Fehler: ${escapeHtml(t.error)}` : ''}</span>` ? `<br><span class="storage-partial">Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf ${escapeHtml(t.toUnits)} Paket(en) – Stückzahl in Stripe von Hand nachziehen.${t.error ? ` Fehler: ${escapeHtml(t.error)}` : ''}</span>`
: '' : ''
return `<div class="storage-task">${action}<br><span class="muted">${escapeHtml(storageLabel(t.fromUnits))} → ${escapeHtml(storageLabel(t.toUnits))}${t.viaCancellation ? ' · über die Kündigungsseite' : ''}</span>${partial}</div>` const scope = `<br><span class="muted">Gesamtspeicher des Kunden: <strong>${escapeHtml(t.targetGb)} GB</strong>, auf seine Postfächer verteilt nach seiner Vorgabe – § 3 Abs. 4${t.contactDomain ? ` · Kontakt: ${escapeHtml(t.contactDomain)}` : ''}</span>`
return `<div class="storage-task">${action}${scope}<br><span class="muted">${escapeHtml(storageLabel(t.fromUnits))} → ${escapeHtml(storageLabel(t.toUnits))}${t.viaCancellation ? ' · über die Kündigungsseite' : ''}</span>${partial}</div>`
} }
async function loadStorageTasks() { async function loadStorageTasks() {
@@ -830,7 +831,7 @@ async function loadStorageTasks() {
<td><strong>${escapeHtml(t.customerName)}</strong>${t.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(t.customerCode)}</span>` : ''}${t.email ? `<br><span class="muted">${escapeHtml(t.email).replace('@', '@<wbr>')}</span>` : ''}<br><span class="muted">${escapeHtml(t.productLabel)}</span></td> <td><strong>${escapeHtml(t.customerName)}</strong>${t.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(t.customerCode)}</span>` : ''}${t.email ? `<br><span class="muted">${escapeHtml(t.email).replace('@', '@<wbr>')}</span>` : ''}<br><span class="muted">${escapeHtml(t.productLabel)}</span></td>
<td>${storageTaskHtml(t)}</td> <td>${storageTaskHtml(t)}</td>
<td>${fmtDateTime(t.createdAt)}<br><span class="muted">${escapeHtml(t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')}</span></td> <td>${fmtDateTime(t.createdAt)}<br><span class="muted">${escapeHtml(t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')}</span></td>
<td><button class="mini-btn primary" data-storage-done="${escapeHtml(t.id)}">Erledigt</button></td> <td><button class="mini-btn primary" data-storage-done="${escapeHtml(t.id)}"${t.partial ? ' data-partial="1"' : ''}>Erledigt</button></td>
</tr>`).join('') </tr>`).join('')
: '<tr><td colspan="5" class="muted">Keine offenen Speicher-Aufgaben.</td></tr>' : '<tr><td colspan="5" class="muted">Keine offenen Speicher-Aufgaben.</td></tr>'
failedBox.classList.toggle('hidden', !d.failed.length) failedBox.classList.toggle('hidden', !d.failed.length)
@@ -846,6 +847,7 @@ async function loadStorageTasks() {
} }
async function completeStorageTask(id, btn) { async function completeStorageTask(id, btn) {
if (btn.dataset.partial === '1' && !confirm('Stripe-Menge von Hand korrigiert?')) return
btn.disabled = true btn.disabled = true
try { try {
await api(`/api/portal-admin/storage-tasks/${encodeURIComponent(id)}/done`, { method: 'POST', body: '{}' }) await api(`/api/portal-admin/storage-tasks/${encodeURIComponent(id)}/done`, { method: 'POST', body: '{}' })
+19 -6
View File
@@ -53,6 +53,8 @@ function taskView(event, customer) {
customerName: customer?.name || customer?.companyName || customer?.code || event.customerId, customerName: customer?.name || customer?.companyName || customer?.code || event.customerId,
customerCode: customer?.code || '', customerCode: customer?.code || '',
email: customer?.email || '', 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, subscriptionId: event.subscriptionId,
productKey, productKey,
productLabel: productLabel(productKey), productLabel: productLabel(productKey),
@@ -71,13 +73,22 @@ function taskView(event, customer) {
} }
} }
/** Auf dem Hetzner-Paket vergebene GB: 5 GB je laufendem Abo plus 5 GB je gebuchtem Paket (F40). */ /**
async function allocatedGb() { * 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, [ const subs = await listDocuments(config.collections.subscriptions, [
Query.equal('status', [...ACTIVE_STATUSES]), Query.equal('status', [...ACTIVE_STATUSES]),
Query.limit(1000), Query.limit(1000),
]) ])
return subs.reduce((sum, sub) => sum + gbOf(sub.storageUnits), 0) 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. */ /** Offene Wechsel mit Stripe abgleichen; reconcilePendingSwitch prueft selbst, ob die Zahlung verfallen ist. */
@@ -94,11 +105,11 @@ storageTasksAdminRouter.get('/', async (_req, res) => {
try { try {
await reconcileOpenSwitches() await reconcileOpenSwitches()
const since = new Date(Date.now() - FAILED_VISIBLE_MS).toISOString() const since = new Date(Date.now() - FAILED_VISIBLE_MS).toISOString()
const [open, failedAll, gb] = await Promise.all([ const [open, failedAll] = await Promise.all([
listOpenStorageTasks(), listOpenStorageTasks(),
listContractEventsOf('speicher', 'gescheitert'), listContractEventsOf('speicher', 'gescheitert'),
allocatedGb(),
]) ])
const gb = await allocatedGb(open)
const failed = failedAll.filter((e) => String(e.$createdAt || '') >= since) const failed = failedAll.filter((e) => String(e.$createdAt || '') >= since)
const customers = await customersOf([...open, ...failed]) const customers = await customersOf([...open, ...failed])
const { name, gb: packageGb, warnGb } = config.mailStorage.hetznerPackage const { name, gb: packageGb, warnGb } = config.mailStorage.hetznerPackage
@@ -117,13 +128,15 @@ storageTasksAdminRouter.get('/', async (_req, res) => {
storageTasksAdminRouter.post('/:id/done', async (req, res) => { storageTasksAdminRouter.post('/:id/done', async (req, res) => {
try { 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) => { const event = await getContractEvent(req.params.id).catch((err) => {
if (err.status === 404) return null if (err.status === 404) return null
throw err throw err
}) })
if (!event || event.type !== 'speicher') return res.status(404).json({ error: 'Speicher-Aufgabe nicht gefunden' }) 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})` }) if (event.status !== 'offen') return res.status(409).json({ error: `Diese Aufgabe ist nicht offen (Status: ${event.status})` })
await completeContractEvent(event.$id, staffEmailOf(req)) await completeContractEvent(event.$id, staffEmail)
const done = await getContractEvent(event.$id) const done = await getContractEvent(event.$id)
const customer = await getDocument(config.collections.customers, done.customerId).catch(() => null) const customer = await getDocument(config.collections.customers, done.customerId).catch(() => null)
return res.json({ task: taskView(done, customer) }) return res.json({ task: taskView(done, customer) })
+45 -4
View File
@@ -21,7 +21,7 @@ const aw = await startAppwriteMock({
const st = await startFakeStripe() const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js') const { config } = await import('../server/config.js')
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js') const { startTestApp, get, post, rawGet, sessionCookie } = await import('./support/testApp.js')
const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, adminCookie, seedFixtures, stripeSubscription } = const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, adminCookie, seedFixtures, stripeSubscription } =
await import('./support/fixtures.js') await import('./support/fixtures.js')
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js') const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
@@ -77,6 +77,8 @@ beforeEach(async () => {
await clearEvents() await clearEvents()
mails.sent.length = 0 mails.sent.length = 0
seedFixtures(aw) seedFixtures(aw)
// sub_h wurde von 3 auf 1 Paket verringert; konsoleH haelt bis zum Periodenende noch 3 (offene Aufgabe unten)
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: hostingDoc.currentPeriodEnd })
aw.seed('subscriptions', { ...hosting2Doc, storageUnits: 2, storagePaidUnits: 2, storagePaidPeriodEnd: hosting2Doc.currentPeriodEnd }) aw.seed('subscriptions', { ...hosting2Doc, storageUnits: 2, storagePaidUnits: 2, storagePaidPeriodEnd: hosting2Doc.currentPeriodEnd })
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting' })) st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting' }))
st.subscriptions.set('sub_h2', stripeSubscription({ id: 'sub_h2', customerId: 'c2', productKey: 'hosting', storageUnits: 2 })) st.subscriptions.set('sub_h2', stripeSubscription({ id: 'sub_h2', customerId: 'c2', productKey: 'hosting', storageUnits: 2 }))
@@ -85,9 +87,9 @@ beforeEach(async () => {
}) })
test('Admin: offene Speicher-Aufgaben und vergebene GB', async () => { test('Admin: offene Speicher-Aufgaben und vergebene GB', async () => {
// zwei aktive Abos: storageUnits 0 und 2 → 5 + 15 = 20 GB // sub_h: 1 Paket, aber 3→1 erst zum Periodenende → konsoleH haelt 5 + 15 = 20 GB; sub_h2: 5 + 10 = 15 GB
const r = await get('/api/portal-admin/storage-tasks', ADMIN) const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(r.allocatedGb, 20) assert.equal(r.allocatedGb, 35)
assert.equal(r.packageGb, 50) assert.equal(r.packageGb, 50)
assert.equal(r.warnGb, 40) assert.equal(r.warnGb, 40)
assert.equal(r.packageName, 'Webhosting M') assert.equal(r.packageName, 'Webhosting M')
@@ -115,13 +117,52 @@ test('Admin: vergebene GB zaehlen nur laufende Abos (active, trialing, past_due)
aw.seed('subscriptions', subscriptionDoc('sub_pd', { ...wartungDoc, customerId: 'c3', status: 'past_due', storageUnits: 1 })) aw.seed('subscriptions', subscriptionDoc('sub_pd', { ...wartungDoc, customerId: 'c3', status: 'past_due', storageUnits: 1 }))
try { try {
const r = await get('/api/portal-admin/storage-tasks', ADMIN) const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(r.allocatedGb, 30) assert.equal(r.allocatedGb, 45)
} finally { } finally {
await deleteDocument(config.collections.subscriptions, 'sub_alt') await deleteDocument(config.collections.subscriptions, 'sub_alt')
await deleteDocument(config.collections.subscriptions, 'sub_pd') await deleteDocument(config.collections.subscriptions, 'sub_pd')
} }
}) })
test('Admin: belegter Speicher zaehlt die Verringerung erst nach Erledigt', async () => {
const before = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(before.allocatedGb, 35, 'offene Aufgabe 3→1: konsoleH haelt noch 20 GB fuer sub_h')
const task = before.tasks.find((t) => t.kind === 'periodenende')
await post(`/api/portal-admin/storage-tasks/${task.id}/done`, {}, ADMIN)
const after = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(after.allocatedGb, 25, 'nach Erledigt: 5 + 5 = 10 GB fuer sub_h, dazu 15 GB fuer sub_h2')
})
test('Admin: Aufgabe nennt Gesamtspeicher des Kunden und Kontakt-Domain', async () => {
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(tasks[0].targetGb, 15)
assert.equal(tasks[0].contactDomain, 'example.invalid')
assert.equal(tasks[1].targetGb, 10)
})
test('Admin: Erledigt ohne E-Mail des Mitarbeiters wird abgelehnt (400)', async () => {
const noEmail = { cookie: sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso' }) }
const r = await post('/api/portal-admin/storage-tasks/speicher-einrichten-c2/done', {}, noEmail)
assert.equal(r.status, 400)
assert.equal((await getContractEvent('speicher-einrichten-c2')).status, 'offen')
})
test('Admin: Erledigt auf ein gescheitertes Ereignis → 409', async () => {
await storageEvent({ id: 'speicher-gescheitert-x', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert' })
const r = await post('/api/portal-admin/storage-tasks/speicher-gescheitert-x/done', {}, ADMIN)
assert.equal(r.status, 409)
assert.equal((await getContractEvent('speicher-gescheitert-x')).status, 'gescheitert')
})
test('Admin: gescheiterte Aenderungen nur der letzten 30 Tage', async () => {
await storageEvent({ id: 'speicher-gescheitert-neu', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert' })
await storageEvent({ id: 'speicher-gescheitert-alt', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 5, dueAt: iso(-2000), status: 'gescheitert' })
aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-alt')), $createdAt: iso(-31 * 24 * HOUR) })
aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-neu')), $createdAt: iso(-29 * 24 * HOUR) })
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.deepEqual(r.failed.map((f) => f.id), ['speicher-gescheitert-neu'])
})
test('Admin: Aufgabe erledigen', async () => { test('Admin: Aufgabe erledigen', async () => {
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN) const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
const { status, body: { task } } = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN) const { status, body: { task } } = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)