From 7d93ae0cd9c76dc416f5d259f904dffe1f2ba639 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 02:50:55 +0000 Subject: [PATCH] Team-Eingang: Gutschriften, Angebote, E-Mail-Speicher und Funktionen fuer alle Mitarbeiter, Kundenfilter, Zaehlroute Co-Authored-By: Claude Opus 5.5 --- server/routes/offers.js | 8 ++- server/routes/storageTasks.js | 9 +++ server/routes/takeoverCredits.js | 3 + server/routes/team/index.js | 9 +++ test/teamAdminRoutes.test.js | 102 +++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 test/teamAdminRoutes.test.js diff --git a/server/routes/offers.js b/server/routes/offers.js index 2effa21..8bda62f 100644 --- a/server/routes/offers.js +++ b/server/routes/offers.js @@ -20,7 +20,7 @@ function sendError(res, err, fallback) { return res.status(status).json({ error: err.message || fallback }) } -offersAdminRouter.get('/', async (_req, res) => { +offersAdminRouter.get('/', async (req, res) => { try { const [docs, customers, subs] = await Promise.all([ listOffers(), @@ -30,8 +30,12 @@ offersAdminRouter.get('/', async (_req, res) => { ]) const byId = new Map(customers.map((c) => [c.$id, c])) const subById = new Map(subs.map((s) => [s.$id, s])) + // ?customerId= (Kunden-Reiter "Abo & Angebote" im Ticketsystem) + const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : '' return res.json({ - offers: docs.map((d) => adminOfferView(d, byId.get(d.customerId), subById.get(d.stripeSubscriptionId))), + offers: docs + .filter((d) => !customerId || d.customerId === customerId) + .map((d) => adminOfferView(d, byId.get(d.customerId), subById.get(d.stripeSubscriptionId))), }) } catch (err) { return sendError(res, err, 'Angebote konnten nicht geladen werden') diff --git a/server/routes/storageTasks.js b/server/routes/storageTasks.js index b286070..bb5e431 100644 --- a/server/routes/storageTasks.js +++ b/server/routes/storageTasks.js @@ -101,6 +101,15 @@ async function reconcileOpenSwitches() { } } +// 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() diff --git a/server/routes/takeoverCredits.js b/server/routes/takeoverCredits.js index 95f0015..04c1022 100644 --- a/server/routes/takeoverCredits.js +++ b/server/routes/takeoverCredits.js @@ -124,7 +124,10 @@ takeoverAdminRouter.get('/', async (req, res) => { ]) 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) diff --git a/server/routes/team/index.js b/server/routes/team/index.js index c413985..06a21d0 100644 --- a/server/routes/team/index.js +++ b/server/routes/team/index.js @@ -1,6 +1,10 @@ import { Router } from 'express' import { requireStaffJwt } from '../../middleware/staffJwt.js' import { chatAdminRouter } from '../chatAdmin.js' +import { funktionenAdminRouter } from '../admin/funktionenAdmin.js' +import { offersAdminRouter } from '../offers.js' +import { storageTasksAdminRouter } from '../storageTasks.js' +import { takeoverAdminRouter } from '../takeoverCredits.js' import customersRoutes from './customers.js' import filesRoutes from './files.js' import previewRoutes from './preview.js' @@ -13,5 +17,10 @@ router.use(requireStaffJwt) router.use('/customers', customersRoutes) router.use('/preview-link', previewRoutes) router.use(chatAdminRouter) +// Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen: dieselben Router wie im Admin-Panel +router.use('/takeover-credits', takeoverAdminRouter) +router.use('/offers', offersAdminRouter) +router.use('/storage-tasks', storageTasksAdminRouter) +router.use('/funktionen', funktionenAdminRouter) export default router diff --git a/test/teamAdminRoutes.test.js b/test/teamAdminRoutes.test.js new file mode 100644 index 0000000..cbd1a64 --- /dev/null +++ b/test/teamAdminRoutes.test.js @@ -0,0 +1,102 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import os from 'node:os' +import path from 'node:path' +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { mailSink } from './support/mailSink.js' + +// Etappe 3: Gutschriften, Abo-Angebote, E-Mail-Speicher und Funktionen ueber den Team-Eingang (/api/team, JWT) +process.env.TAKEOVER_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'team-takeover-')) +const aw = await startAppwriteMock({ + schema: { + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + takeoverCredits: schemaKeys('takeoverCredits'), + aboOffers: schemaKeys('aboOffers'), + portalFeatures: schemaKeys('portalFeatures'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { startTestApp, rawGet, post } = await import('./support/testApp.js') +const { seedFixtures, consent } = await import('./support/fixtures.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { recordContractEvent } = await import('../server/services/contractEvents.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') +const app = await startTestApp({ '/api/team': teamRoutes }) +setMailTransportForTests(mailSink()) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) + +seedFixtures(aw) +aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) +const credit = (id, customerId, status) => + aw.seed('takeoverCredits', { + $id: id, customerId, email: 'k@example.invalid', services: JSON.stringify(['hosting']), provider: 'IONOS', domain: 'kunde.de', + invoiceAmountCents: 12000, billingMonths: 12, paidUntil: '2026-12-31T00:00:00.000Z', message: 'Bitte verrechnen', + files: '[]', status, history: '[]', takeoverFrom: '2026-10-01T00:00:00.000Z', + }) +credit('tc1', 'c1', 'offen') +credit('tc2', 'c1', 'abgelehnt') +credit('tc3', 'c2', 'offen') +await recordContractEvent({ + id: 'speicher-c2', customerId: 'c2', subscriptionId: 'sub_h2', type: 'speicher', productKey: 'hosting', + details: { kind: 'einrichten', fromUnits: 0, toUnits: 2, effectiveAt: new Date().toISOString(), totalGb: 15, monthlyCents: 200 }, + consent, status: 'offen', dueAt: new Date().toISOString(), +}) +aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Team-Test' }) + +const staff = { headers: { authorization: 'Bearer jwt-staff' } } + +test('alle vier Bereiche mit jwt-staff erreichbar (kein Admin-Label noetig)', async () => { + for (const p of ['/api/team/takeover-credits', '/api/team/offers', '/api/team/storage-tasks', '/api/team/funktionen/c1']) { + assert.equal((await rawGet(p, staff)).status, 200, p) + } +}) + +test('Kundenfilter: Gutschriften nur des Kunden, openCount weiter ueber alle', async () => { + const all = (await rawGet('/api/team/takeover-credits?status=all&customerId=c1', staff)).body + assert.deepEqual(all.requests.map((r) => r.id).sort(), ['tc1', 'tc2']) + assert.equal(all.openCount, 2) + const open = (await rawGet('/api/team/takeover-credits?customerId=c2', staff)).body + assert.deepEqual(open.requests.map((r) => r.id), ['tc3']) +}) + +test('Angebot anlegen als Mitarbeiter, Kundenfilter der Angebotsliste', async () => { + const res = await post('/api/team/offers', { customerId: 'c2', productKey: 'wartung', monthlyPriceCents: 2500, months: 2, notifyCustomer: false }, staff) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(res.body.offer.createdBy, 'Andrej ') + assert.deepEqual((await rawGet('/api/team/offers?customerId=c2', staff)).body.offers.map((o) => o.customerId), ['c2']) + assert.deepEqual((await rawGet('/api/team/offers?customerId=c1', staff)).body.offers, []) +}) + +test('E-Mail-Speicher open-count ohne Stripe-Abgleich', async () => { + const before = st.calls.length + assert.deepEqual((await rawGet('/api/team/storage-tasks/open-count', staff)).body, { open: 1 }) + assert.equal(st.calls.length, before) +}) + +test('Mitarbeiter aus dem JWT: Rueckfrage, Erledigt und Funktionen-Notiz tragen Andrej', async () => { + const q = await post('/api/team/takeover-credits/tc3/decision', { action: 'question', note: 'Bitte Rechnung schicken' }, staff) + assert.equal(q.status, 200, JSON.stringify(q.body)) + const detail = (await rawGet('/api/team/takeover-credits/tc3', staff)).body + assert.equal(detail.request.history.at(-1).by, 'Andrej ') + const done = await post('/api/team/storage-tasks/speicher-c2/done', {}, staff) + assert.equal(done.status, 200, JSON.stringify(done.body)) + assert.equal(done.body.task.doneBy, 'andrej@webklar.com') + const news = await post('/api/team/funktionen/c1/news', { projectId: 'pa1', action: 'activate', note: 'Pilot' }, staff) + assert.equal(news.status, 200) + assert.match(news.body.projects[0].features.news.adminNote, /Andrej: activate – Pilot$/) +}) + +test('ohne JWT 401 fuer alle neuen Pfade', async () => { + for (const p of ['/api/team/takeover-credits', '/api/team/takeover-credits/open-count', '/api/team/offers', '/api/team/storage-tasks', '/api/team/storage-tasks/open-count', '/api/team/funktionen/c1']) { + assert.equal((await rawGet(p)).status, 401, p) + } +})