Team-Eingang: Gutschriften, Angebote, E-Mail-Speicher und Funktionen fuer alle Mitarbeiter, Kundenfilter, Zaehlroute
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a06c3bc8ef
commit
7d93ae0cd9
5 files changed
+129
-2
No files matched your search
@@ -20,7 +20,7 @@ function sendError(res, err, fallback) {
|
|||||||
return res.status(status).json({ error: err.message || fallback })
|
return res.status(status).json({ error: err.message || fallback })
|
||||||
}
|
}
|
||||||
|
|
||||||
offersAdminRouter.get('/', async (_req, res) => {
|
offersAdminRouter.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [docs, customers, subs] = await Promise.all([
|
const [docs, customers, subs] = await Promise.all([
|
||||||
listOffers(),
|
listOffers(),
|
||||||
@@ -30,8 +30,12 @@ offersAdminRouter.get('/', async (_req, res) => {
|
|||||||
])
|
])
|
||||||
const byId = new Map(customers.map((c) => [c.$id, c]))
|
const byId = new Map(customers.map((c) => [c.$id, c]))
|
||||||
const subById = new Map(subs.map((s) => [s.$id, s]))
|
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({
|
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) {
|
} catch (err) {
|
||||||
return sendError(res, err, 'Angebote konnten nicht geladen werden')
|
return sendError(res, err, 'Angebote konnten nicht geladen werden')
|
||||||
|
|||||||
@@ -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) => {
|
storageTasksAdminRouter.get('/', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
await reconcileOpenSwitches()
|
await reconcileOpenSwitches()
|
||||||
|
|||||||
@@ -124,7 +124,10 @@ takeoverAdminRouter.get('/', async (req, res) => {
|
|||||||
])
|
])
|
||||||
const byId = new Map(customers.map((c) => [c.$id, c]))
|
const byId = new Map(customers.map((c) => [c.$id, c]))
|
||||||
const onlyOpen = req.query.status !== 'all'
|
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
|
const requests = docs
|
||||||
|
.filter((d) => !customerId || d.customerId === customerId)
|
||||||
.filter((d) => !onlyOpen || d.status === STATUS.open || d.status === STATUS.question)
|
.filter((d) => !onlyOpen || d.status === STATUS.open || d.status === STATUS.question)
|
||||||
.map((d) => {
|
.map((d) => {
|
||||||
const c = byId.get(d.customerId)
|
const c = byId.get(d.customerId)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { requireStaffJwt } from '../../middleware/staffJwt.js'
|
import { requireStaffJwt } from '../../middleware/staffJwt.js'
|
||||||
import { chatAdminRouter } from '../chatAdmin.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 customersRoutes from './customers.js'
|
||||||
import filesRoutes from './files.js'
|
import filesRoutes from './files.js'
|
||||||
import previewRoutes from './preview.js'
|
import previewRoutes from './preview.js'
|
||||||
@@ -13,5 +17,10 @@ router.use(requireStaffJwt)
|
|||||||
router.use('/customers', customersRoutes)
|
router.use('/customers', customersRoutes)
|
||||||
router.use('/preview-link', previewRoutes)
|
router.use('/preview-link', previewRoutes)
|
||||||
router.use(chatAdminRouter)
|
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
|
export default router
|
||||||
@@ -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 <andrej@webklar.com>')
|
||||||
|
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 <andrej@webklar.com>')
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user