Portal: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen entfernt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 03:50:14 +00:00
1 parent 3a4fa581c0
commit 7c00633db0
43 files changed
+229 -4001

No files matched your search

-6
View File
@@ -13,7 +13,6 @@ import authRoutes from './routes/auth.js'
import projectsRoutes from './routes/projects.js'
import featuresRoutes from './routes/features.js'
import customerDataRoutes from './routes/customerData.js'
import portalAdminRoutes from './routes/portalAdmin.js'
import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js'
@@ -33,12 +32,10 @@ import adminServicesRoutes from './routes/admin/services.js'
import adminCustomersRoutes from './routes/admin/customers.js'
import adminEmployeesRoutes from './routes/admin/employees.js'
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
import newsRoutes from './routes/news.js'
import publicNewsRoutes from './routes/publicNews.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import { analyseLegalRouter } from './services/analyseLegal.js'
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js'
import teamRoutes from './routes/team/index.js'
import { corsMiddleware } from './middleware/cors.js'
@@ -74,7 +71,6 @@ app.use('/api/chat', chatRoutes)
app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/portal-admin', portalAdminRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminPreviewRoutes)
@@ -83,8 +79,6 @@ app.use('/api/admin/services', adminServicesRoutes)
app.use('/api/admin/customers', adminCustomersRoutes)
app.use('/api/admin/employees', adminEmployeesRoutes)
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
app.use('/api/admin/analyse', adminAnalyseRoutes)
app.use('/api/admin/finanzen', adminFinanzenRoutes)
app.use('/webhook', giteaWebhookRoutes)
+11 -15
View File
@@ -3,11 +3,11 @@ import { VIEW_EXPIRED_MESSAGE, endKundenansicht, isExpired, isKundenansicht, log
/**
* Waechter der Kundenansicht vor allen Kunden-Routen (Spec 2026-10-01, Abschnitt 3):
* 1. abgelaufen (2 h) -> 401 view_expired, Cookies weg
* 1. abgelaufen (2 h) -> 401 view_expired, Cookies weg; ebenso alte Ansichten ohne viewId ("Als Kunde ansehen" aus
* dem frueheren Admin-Panel)
* 2. fremde Kennung im Header X-Kundenansicht -> 409 view_replaced (Tab einer anderen Kundenansicht)
* 3. Lesen ist erlaubt; Schreiben nur mit passender Kennung und nur, was auf der Erlaubnisliste steht.
* Neue Schreib-Routen sind damit in der Kundenansicht gesperrt, bis sie bewusst auf die Liste kommen.
* Alte Ansichten aus dem Admin-Panel (ohne viewId) duerfen nichts schreiben ausser Ab- und Anmelden.
*/
export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/news\/?$/],
@@ -19,13 +19,9 @@ export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
]
const LEGACY_ALLOWED = [
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
]
// Eigene Pruefung (Ticketsystem-JWT, Admin-Sitzung) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/portal-admin/']
// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
const PASS_PREFIXES = ['/api/team/', '/api/admin/']
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
@@ -41,20 +37,20 @@ export async function kundenansichtGuard(req, res, next) {
if (!session) return next()
const current = isKundenansicht(session)
if (current && isExpired(session)) {
// Abgelaufen oder alte Ansicht ohne Kennung (frueheres Admin-Panel): beenden, nichts mehr lesen oder schreiben
if (session.viewAs && (!current || isExpired(session))) {
endKundenansicht(res)
await logOnce(session, 'abgelaufen')
if (current) await logOnce(session, 'abgelaufen')
return res.status(401).json({ code: 'view_expired', error: VIEW_EXPIRED_MESSAGE })
}
// Tab einer Kundenansicht, deren Cookie inzwischen zu etwas anderem gehoert (andere Kundenansicht, alte
// Admin-Ansicht, Kunden-Login): keine fremden Daten in diesen Tab
// Tab einer Kundenansicht, deren Cookie inzwischen zu etwas anderem gehoert (andere Kundenansicht,
// Kunden-Login): keine fremden Daten in diesen Tab
const header = req.get('X-Kundenansicht')
if (header && header !== session.viewId) return replaced(res, session)
if (!session.viewAs) return next()
if (READ_METHODS.includes(req.method)) return next()
if (current && !header) return replaced(res, session)
if (!header) return replaced(res, session)
const list = current ? KUNDENANSICHT_ALLOWED : LEGACY_ALLOWED
if (list.some(([method, re]) => method === req.method && re.test(pathname))) return next()
if (KUNDENANSICHT_ALLOWED.some(([method, re]) => method === req.method && re.test(pathname))) return next()
return res.status(403).json({ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' })
}
-10
View File
@@ -58,16 +58,6 @@ export async function requireSession(req, res, next) {
next()
}
/** Admin-Session: Ticketsystem-Mitarbeiter (Appwrite-Label "admin") im Portal. */
export function requireAdminSession(req, res, next) {
const session = parsePortalSession(req)
if (!session?.appwriteUserId || session.role !== 'admin') {
return res.status(401).json({ error: 'Admin-Anmeldung erforderlich' })
}
req.session = session
next()
}
export function getSessionCustomerId(req) {
return req.session?.customerId
}
+6 -6
View File
@@ -216,7 +216,7 @@ router.get('/', requireSession, async (req, res) => {
router.post('/checkout', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
}
const { productKey, acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {}
if (!PRODUCT_KEYS.includes(productKey)) {
@@ -280,7 +280,7 @@ router.post('/checkout', requireSession, async (req, res) => {
router.post('/portal', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
const url = await createBillingPortalUrl(getSessionCustomerId(req))
return res.json({ url })
@@ -297,7 +297,7 @@ router.post('/portal', requireSession, async (req, res) => {
router.post('/storage/preview', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
return res.json(await previewStorageChange(getSessionCustomerId(req), req.body?.units))
} catch (err) {
@@ -314,7 +314,7 @@ router.post('/storage/preview', requireSession, async (req, res) => {
router.post('/storage', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann der Zusatzspeicher nicht geändert werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann der Zusatzspeicher nicht geändert werden.' })
}
const customerId = getSessionCustomerId(req)
const { units, acceptTerms, termsVersion } = req.body || {}
@@ -350,7 +350,7 @@ router.post('/storage', requireSession, async (req, res) => {
router.post('/switch/preview', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
return res.json(await previewSwitch(getSessionCustomerId(req)))
} catch (err) {
@@ -368,7 +368,7 @@ router.post('/switch/preview', requireSession, async (req, res) => {
router.post('/switch', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht gewechselt werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann nicht gewechselt werden.' })
}
const { acceptTerms, earlyStart, termsVersion, prorationDate, amountDueCents } = req.body || {}
if (acceptTerms !== true) {
+2 -2
View File
@@ -5,8 +5,8 @@ import { withLocks } from '../../services/keyedLock.js'
import { adminActivate, assertOwnProject, disable, getFeatureDoc, listCustomerFeatures } from '../../services/siteFeatures.js'
/**
* Funktionen (News-Pinnwand) je Kundenprojekt im Admin-Panel.
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/funktionen, dort hinter requireAdminSession.
* Funktionen (News-Pinnwand) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
* Eingehaengt in routes/team/index.js unter /api/team/funktionen, dort hinter requireStaffJwt.
*/
export const funktionenAdminRouter = Router()
-37
View File
@@ -1,37 +0,0 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { appwriteAccountFromJwt } from '../../middleware/staffJwt.js'
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
import { resolveStaffAccount } from '../../services/staffAccount.js'
const router = Router()
/**
* Wechsel Ticketsystem -> Projekt-Admin ohne zweiten Login. Wie requireAppwriteStaff wird das JWT
* ueber /account geprueft (eine Sitzung ohne zweiten Faktor bekommt dort 401), aber ohne Label-Pflicht:
* jeder Mitarbeiter darf rueber, wie beim normalen Portal-Login.
*/
router.post('/', async (req, res) => {
try {
const user = await appwriteAccountFromJwt(req)
if (!user?.$id) return res.status(401).json({ error: 'Bitte im Ticketsystem neu anmelden.' })
const staff = await resolveStaffAccount(user.$id, user.email || '', user)
if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' })
const token = createStaffPreviewToken({
purpose: 'switch',
userId: user.$id,
email: staff.email,
name: staff.name,
isAdmin: staff.isAdmin,
})
return res.json({
openUrl: `https://${config.preview.baseHost}/api/auth/staff-switch?token=${encodeURIComponent(token)}`,
})
} catch (err) {
return res.status(500).json({ error: err.message || 'Wechsel fehlgeschlagen' })
}
})
export default router
+2 -2
View File
@@ -16,8 +16,8 @@ import {
import { signFileLink } from '../services/signedLinks.js'
/**
* Chat des Teams mit den Kunden. Eingehaengt im Admin-Panel (/api/portal-admin, Cookie) und im Team-Eingang des
* Ticketsystems (/api/team, JWT); beide liefern req.session mit appwriteUserId und Namen des Mitarbeiters.
* Chat des Teams mit den Kunden. Eingehaengt im Team-Eingang des Ticketsystems (/api/team, JWT); requireStaffJwt
* liefert req.session mit appwriteUserId und Namen des Mitarbeiters.
*/
export const chatAdminRouter = Router()
const router = chatAdminRouter
+3 -3
View File
@@ -134,7 +134,7 @@ async function openOrderSession(doc) {
router.post('/news/checkout', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
}
const { projectId, consent } = req.body || {}
if (consent !== true) {
@@ -182,7 +182,7 @@ const analyseGuard = [requireSession, requireAnalyseEnabled]
router.post('/analyse/activate', analyseGuard, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
}
const { projectId, accentColor, consent } = req.body || {}
const { doc } = await activateAnalyse({
@@ -202,7 +202,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
router.post('/analyse/deactivate', analyseGuard, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
}
const { doc } = await deactivateAnalyse({
customerId: getSessionCustomerId(req),
+2 -2
View File
@@ -4,8 +4,8 @@ import { listDocuments, Query } from '../services/appwriteAdmin.js'
import { adminOfferView, createOffer, listOffers, offerContext, resendOfferMail, withdrawOffer } from '../services/offers.js'
/**
* Individuelle Abo-Angebote im Admin-Panel (Tab "Abo-Angebote").
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/offers, dort hinter requireAdminSession.
* Individuelle Abo-Angebote im Ticketsystem (Kunden -> Abo-Angebote, Kunden-Reiter "Abo & Angebote").
* Eingehaengt in routes/team/index.js unter /api/team/offers, dort hinter requireStaffJwt.
*/
export const offersAdminRouter = Router()
-331
View File
@@ -1,331 +0,0 @@
import { Router } from 'express'
import { config } from '../config.js'
import {
parsePortalSession,
requireAdminSession,
setPortalSession,
} from '../middleware/session.js'
import {
getDocument,
listDocuments,
updateDocument,
Query,
} from '../services/appwriteAdmin.js'
import { listAppointments, saveAppointment } from '../services/appointments.js'
import { getCustomerActivity } from '../services/customerActivity.js'
import { listInvoicesForClient } from '../services/invoiceNinja.js'
import { chatAdminRouter, customerNameMap } from './chatAdmin.js'
import { funktionenAdminRouter } from './admin/funktionenAdmin.js'
import { offersAdminRouter } from './offers.js'
import { storageTasksAdminRouter } from './storageTasks.js'
import { takeoverAdminRouter } from './takeoverCredits.js'
const router = Router()
router.use(requireAdminSession)
// Uebernahmegutschriften: Pruefung und Entscheidung (Tab "Gutschriften")
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)
// Funktionen (News-Pinnwand) je Kundenprojekt aktivieren/abschalten (Kundenliste, Zeile "Funktionen")
router.use('/funktionen', funktionenAdminRouter)
function norm(value) {
return String(value || '').toLowerCase()
}
function isDeployed(project) {
return ['ready', 'deployed'].includes(norm(project.provisioningStatus || project.status))
}
// ---------------------------------------------------------------- Übersicht --
router.get('/overview', async (_req, res) => {
try {
const [projects, customers, access] = await Promise.all([
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
listDocuments(config.collections.customers, [Query.limit(500)]),
listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]),
])
const nameById = customerNameMap(customers)
const stageOf = (c) => {
const s = norm(c.customerStatus)
if (s === 'lead') return 'lead'
if (s === 'lost') return 'lost'
return 'customer'
}
const recentLogins = access
.filter((a) => a.lastLoginAt)
.sort((a, b) => String(b.lastLoginAt).localeCompare(String(a.lastLoginAt)))
.slice(0, 5)
.map((a) => ({
customerName: nameById[a.customerId] || a.customerId,
lastLoginAt: a.lastLoginAt,
}))
const recentProjects = [...projects]
.sort((a, b) =>
String(b.updatedAt || b.$updatedAt || '').localeCompare(String(a.updatedAt || a.$updatedAt || ''))
)
.slice(0, 5)
.map((p) => ({
projectName: p.projectName || p.subdomain || p.repoFullName || '',
subdomain: p.subdomain || '',
status: p.status || '',
customerName: p.customerId ? nameById[p.customerId] || '' : '',
updatedAt: p.updatedAt || p.$updatedAt || '',
}))
return res.json({
projects: {
total: projects.length,
deployed: projects.filter(isDeployed).length,
withoutWebsite: projects.filter((p) => !p.subdomain).length,
assigned: projects.filter((p) => p.customerId).length,
},
customers: {
total: customers.length,
leads: customers.filter((c) => stageOf(c) === 'lead').length,
fixed: customers.filter((c) => stageOf(c) === 'customer').length,
portalEnabled: customers.filter((c) => c.portalAccessEnabled).length,
},
recentLogins,
recentProjects,
})
} catch (err) {
return res.status(500).json({ error: err.message || 'Übersicht konnte nicht geladen werden' })
}
})
// ------------------------------------------------------------- Projektsuche --
router.get('/projects', async (req, res) => {
const search = norm(req.query.search)
try {
const [projects, customers] = await Promise.all([
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
listDocuments(config.collections.customers, [Query.limit(500)]),
])
const nameById = customerNameMap(customers)
let items = projects
.filter((p) => !p.archived)
.map((p) => ({
id: p.$id,
projectName: p.projectName || '',
subdomain: p.subdomain || '',
previewUrl: p.previewUrl || '',
liveDomain: p.liveDomain || '',
status: p.status || '',
provisioningStatus: p.provisioningStatus || '',
projectType: p.projectType || (p.subdomain ? 'website' : 'repo'),
giteaRepoUrl: p.giteaRepoUrl || '',
repoFullName: p.repoFullName || '',
customerId: p.customerId || '',
customerName: p.customerId ? nameById[p.customerId] || p.customerId : '',
updatedAt: p.updatedAt || p.$updatedAt || '',
}))
if (search) {
items = items.filter((p) =>
[p.projectName, p.subdomain, p.repoFullName, p.customerName, p.status].some((v) =>
norm(v).includes(search)
)
)
}
items.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
return res.json({ projects: items })
} catch (err) {
return res.status(500).json({ error: err.message || 'Projekte konnten nicht geladen werden' })
}
})
// -------------------------------------------------------------- Kundenliste --
router.get('/customers', async (req, res) => {
const search = norm(req.query.search)
try {
const [customers, access, projects] = await Promise.all([
listDocuments(config.collections.customers, [Query.orderAsc('name'), Query.limit(500)]),
listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]),
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
])
const lastLoginByCustomer = {}
for (const a of access) {
if (a.customerId && a.lastLoginAt) lastLoginByCustomer[a.customerId] = a.lastLoginAt
}
const projectCount = {}
for (const p of projects) {
if (p.customerId) projectCount[p.customerId] = (projectCount[p.customerId] || 0) + 1
}
let items = customers
.filter((c) => !c.archived)
.map((c) => ({
id: c.$id,
code: c.code || '',
name: c.name || '',
email: c.email || '',
phone: c.phone || '',
location: c.location || '',
customerStatus: c.customerStatus || '',
portalAccessEnabled: Boolean(c.portalAccessEnabled),
invoiceNinjaLinked: Boolean(c.invoiceNinjaClientId),
lastLoginAt: lastLoginByCustomer[c.$id] || '',
projectCount: projectCount[c.$id] || 0,
}))
if (search) {
items = items.filter((c) =>
[c.code, c.name, c.email, c.location].some((v) => norm(v).includes(search))
)
}
return res.json({ customers: items })
} catch (err) {
return res.status(500).json({ error: err.message || 'Kunden konnten nicht geladen werden' })
}
})
// ----------------------------------------------------------------- Termine --
// Termine haengen am Akquise-Ticket (workorders) und werden vom WOMS-Planboard
// gelesen. Siehe server/services/appointments.js.
router.get('/appointments', async (req, res) => {
try {
const appointments = await listAppointments({
from: String(req.query.from || ''),
to: String(req.query.to || ''),
})
return res.json({ appointments })
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Termine konnten nicht geladen werden' })
}
})
router.post('/customers/:customerId/appointment', async (req, res) => {
const { date, time, employeeEmail } = req.body || {}
try {
const appointment = await saveAppointment({
customerId: req.params.customerId,
employeeEmail,
date,
time,
})
return res.json({ success: true, appointment })
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Termin konnte nicht gespeichert werden' })
}
})
// --------------------------------------------- Einzelner Kunde (Stammdaten) --
// Fuer die Rechnungsanalyse (kalkulation.html), die den Lead vorbefuellt.
router.get('/customers/:customerId', async (req, res) => {
try {
const customer = await getDocument(config.collections.customers, req.params.customerId)
return res.json({
customer: {
id: customer.$id,
code: customer.code || '',
name: customer.name || '',
email: customer.email || '',
phone: customer.phone || '',
location: customer.location || '',
customerStatus: customer.customerStatus || '',
},
})
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Kunde konnte nicht geladen werden' })
}
})
// ------------------------------------------- Kundennummer/Stammdaten pflegen --
router.patch('/customers/:customerId', async (req, res) => {
const { code, location, phone } = req.body || {}
const data = { updatedAt: new Date().toISOString() }
if (code !== undefined) data.code = String(code).trim()
if (location !== undefined) data.location = String(location).trim()
if (phone !== undefined) data.phone = String(phone).trim()
try {
const updated = await updateDocument(config.collections.customers, req.params.customerId, data)
return res.json({
success: true,
customer: { id: updated.$id, code: updated.code || '', name: updated.name || '' },
})
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Kunde konnte nicht aktualisiert werden' })
}
})
// -------------------------------------- Aktivität + Rechnungen eines Kunden --
router.get('/customers/:customerId/activity', async (req, res) => {
try {
const tickets = await getCustomerActivity(req.params.customerId, { ticketLimit: 50 })
return res.json({ tickets })
} catch (err) {
return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' })
}
})
router.get('/customers/:customerId/invoices', async (req, res) => {
try {
const customer = await getDocument(config.collections.customers, req.params.customerId)
if (!customer.invoiceNinjaClientId) {
return res.json({ linked: false, invoices: [] })
}
const invoices = await listInvoicesForClient(customer.invoiceNinjaClientId, { includeDrafts: true })
return res.json({ linked: true, invoices })
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Rechnungen konnten nicht geladen werden' })
}
})
// Chat (Tab "Chat"): eigener Router, auch im Team-Eingang des Ticketsystems eingehaengt
router.use(chatAdminRouter)
// ------------------------------------------------------- Als Kunde ansehen --
router.post('/view-as/:customerId', async (req, res) => {
try {
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
if (!customer) {
return res.status(404).json({ error: 'Kunde nicht gefunden' })
}
const session = req.session
setPortalSession(res, {
role: 'admin',
viewAs: true,
isAdmin: session.isAdmin,
appwriteUserId: session.appwriteUserId,
adminName: session.adminName || session.name || 'Admin',
adminEmail: session.adminEmail || session.email || '',
customerId: customer.$id,
name: customer.name || '',
email: customer.email || '',
})
return res.json({ success: true, customer: { id: customer.$id, name: customer.name || '' } })
} catch (err) {
return res.status(500).json({ error: err.message || 'Ansicht konnte nicht gewechselt werden' })
}
})
router.post('/exit-view-as', (req, res) => {
const session = parsePortalSession(req) || req.session
setPortalSession(res, {
role: 'admin',
isAdmin: session.isAdmin,
appwriteUserId: session.appwriteUserId,
name: session.adminName || session.name || 'Admin',
email: session.adminEmail || session.email || '',
})
return res.json({ success: true })
})
export default router
+4 -4
View File
@@ -8,10 +8,10 @@ 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.
* "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
+4 -4
View File
@@ -27,7 +27,7 @@ import {
/**
* Uebernahmegutschrift.
* Kunde: /api/abo/takeover-credits (Antrag stellen, Rueckfrage beantworten)
* Admin: /api/portal-admin/takeover-credits (Liste, Pruefung, Entscheidung, Nachweise)
* Team: /api/team/takeover-credits (Ticketsystem: Liste, Pruefung, Entscheidung, Nachweise)
* Der JSON-Body darf hier groesser sein (Nachweise als Base64), siehe server/index.js.
*/
@@ -73,7 +73,7 @@ router.get('/', async (req, res) => {
router.post('/', async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann kein Antrag gestellt werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann kein Antrag gestellt werden.' })
}
const customerId = getSessionCustomerId(req)
if (rateLimited(customerId)) {
@@ -92,7 +92,7 @@ router.post('/', async (req, res) => {
router.post('/:id/reply', async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
const customerId = getSessionCustomerId(req)
if (rateLimited(customerId)) {
@@ -113,7 +113,7 @@ router.post('/:id/reply', async (req, res) => {
export default router
// ---------------------------------------------------------------------------
// Admin (eingehaengt in routes/portalAdmin.js, dort hinter requireAdminSession)
// Team (eingehaengt in routes/team/index.js, dort hinter requireStaffJwt)
// ---------------------------------------------------------------------------
export const takeoverAdminRouter = Router()
+1 -1
View File
@@ -17,7 +17,7 @@ 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
// Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen (frueher im Admin-Panel des Portals)
router.use('/takeover-credits', takeoverAdminRouter)
router.use('/offers', offersAdminRouter)
router.use('/storage-tasks', storageTasksAdminRouter)
+1 -1
View File
@@ -1,5 +1,5 @@
import { Router } from 'express'
import { buildStaffPreviewLink } from '../admin/previewOpen.js'
import { buildStaffPreviewLink } from '../../services/staffPreviewLink.js'
// Preview aus dem Ticketsystem oeffnen (alle Mitarbeiter): Einmal-Link statt Mitarbeiter-Login im Portal
const router = Router()
-159
View File
@@ -1,159 +0,0 @@
/* Termine der Rechnungsanalyse.
Ein Termin ist kein eigener Datensatz, sondern haengt am Akquise-Ticket des
Kunden (workorders): startDate + startTime + endTime + assignedTo. Das WOMS-
Planboard liest genau diese Felder, damit Termin und Ticket nie auseinander
laufen. Achtung: WOMS speichert Datum als Text "dd.mm.yyyy" und Uhrzeit als
"hhmm" - beides wird hier erzeugt und beim Lesen tolerant geparst. */
import { config } from '../config.js'
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
const TICKET_LIMIT = 500
/* ------------------------------------------------------------- Formate --- */
// "2026-08-12" -> "12.08.2026"
export function isoToGerman(iso) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''))
if (!m) return ''
return `${m[3]}.${m[2]}.${m[1]}`
}
// "12.08.2026" und "12.8.2026" -> "2026-08-12"
export function germanToIso(value) {
const m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(String(value || '').trim())
if (!m) return ''
return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`
}
// "09:00" -> "0900"
export function timeToWoms(value) {
const m = /^(\d{1,2}):?(\d{2})$/.exec(String(value || '').trim())
if (!m) return ''
return `${m[1].padStart(2, '0')}${m[2]}`
}
// "0900" -> "09:00"
export function womsToTime(value) {
const raw = String(value || '').trim()
if (!raw) return ''
const m = /^(\d{1,2}):?(\d{2})$/.exec(raw)
if (!m) return ''
return `${m[1].padStart(2, '0')}:${m[2]}`
}
function addHour(time) {
const m = /^(\d{2}):(\d{2})$/.exec(womsToTime(time))
if (!m) return ''
const hour = (Number(m[1]) + 1) % 24
return `${String(hour).padStart(2, '0')}${m[2]}`
}
/* ---------------------------------------------------------- Mitarbeiter --- */
export async function findEmployeeByEmail(email) {
const wanted = String(email || '').trim().toLowerCase()
if (!wanted) return null
const employees = await listDocuments(config.collections.employees, [Query.limit(100)])
return employees.find((e) => String(e.email || '').toLowerCase() === wanted) || null
}
/* -------------------------------------------------------------- Tickets --- */
function isClosed(ticket) {
return String(ticket.status || '').toLowerCase() === 'closed'
}
/* Das Ticket, an das der Termin gehoert: bevorzugt das offene Akquise-Ticket.
Gibt es keins, kommt nur ein anderes offenes Ticket in Frage, das noch KEIN
Datum traegt - bei einem Projekt- oder Webpage-Ticket bedeutet startDate den
Projektstart, den wuerden wir sonst still ueberschreiben. */
export async function findAcquisitionTicket(customerId) {
const tickets = await listDocuments(config.collections.workorders, [
Query.equal('customerId', customerId),
Query.limit(100),
])
if (!tickets.length) return null
const newestFirst = [...tickets].sort((a, b) => String(b.$createdAt || '').localeCompare(String(a.$createdAt || '')))
const akquise = newestFirst.filter((t) => String(t.type || '').toLowerCase() === 'akquise')
if (akquise.length) return akquise.find((t) => !isClosed(t)) || akquise[0]
return newestFirst.find((t) => !isClosed(t) && !t.startDate) || null
}
function toAppointment(ticket, employeesByUserId) {
const dateIso = germanToIso(ticket.startDate)
const time = womsToTime(ticket.startTime)
if (!dateIso) return null
const employee = employeesByUserId[ticket.assignedTo] || null
return {
ticketId: ticket.$id,
woid: ticket.woid || '',
topic: ticket.topic || ticket.title || '',
type: ticket.type || '',
status: ticket.status || '',
customerId: ticket.customerId || '',
customerName: ticket.customerName || '',
customerLocation: ticket.customerLocation || '',
date: dateIso,
time,
endTime: womsToTime(ticket.endTime),
employeeUserId: ticket.assignedTo || '',
employeeName: employee?.displayName || ticket.assignedName || '',
employeeEmail: employee?.email || '',
}
}
/* Alle terminierten Tickets in einem Zeitraum (ISO-Datum, inklusive). */
export async function listAppointments({ from = '', to = '' } = {}) {
const [tickets, employees] = await Promise.all([
listDocuments(config.collections.workorders, [Query.limit(TICKET_LIMIT)]),
listDocuments(config.collections.employees, [Query.limit(100)]),
])
const employeesByUserId = {}
for (const e of employees) employeesByUserId[e.userId] = e
return tickets
.map((t) => toAppointment(t, employeesByUserId))
.filter((a) => a && (!from || a.date >= from) && (!to || a.date <= to))
.sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time))
}
/* Termin am Akquise-Ticket eintragen. */
export async function saveAppointment({ customerId, employeeEmail, date, time }) {
const germanDate = isoToGerman(date)
const womsTime = timeToWoms(time)
if (!germanDate) throw badRequest('Ungültiges Datum')
if (!womsTime) throw badRequest('Ungültige Uhrzeit')
const employee = await findEmployeeByEmail(employeeEmail)
if (!employee) throw badRequest(`Kein Mitarbeiter mit der E-Mail ${employeeEmail} in WOMS gefunden`)
const ticket = await findAcquisitionTicket(customerId)
if (!ticket) {
throw badRequest('Kein passendes Ticket gefunden. Bitte in WOMS ein Akquise-Ticket für diesen Kunden anlegen.')
}
const updated = await updateDocument(config.collections.workorders, ticket.$id, {
startDate: germanDate,
startTime: womsTime,
endTime: addHour(womsTime),
assignedTo: employee.userId,
assignedName: employee.displayName || '',
})
return {
ticketId: updated.$id,
woid: updated.woid || '',
topic: updated.topic || '',
type: updated.type || '',
date,
time: womsToTime(womsTime),
employeeName: employee.displayName || '',
}
}
function badRequest(message) {
const error = new Error(message)
error.status = 400
return error
}
+2 -1
View File
@@ -11,7 +11,8 @@ import {
/**
* Kundenansicht aus dem Ticketsystem (Spec 2026-10-01, Abschnitt 3): ein Mitarbeiter oeffnet per Einmal-Link das
* Portal eines Kunden. Die Sitzung traegt eine zufaellige Kennung (viewId) gegen Verwechslung zweier Tabs und
* endet nach 2 Stunden. Sitzungen ohne viewId stammen aus "Als Kunde ansehen" im alten Admin-Panel.
* endet nach 2 Stunden. Sitzungen ohne viewId ("Als Kunde ansehen" im frueheren Admin-Panel) gelten als beendet
* (kundenansichtGuard, /api/auth/me).
*/
export const KUNDENANSICHT_TTL_MS = 2 * 60 * 60 * 1000
const COOKIE_GRACE_MS = 60 * 60 * 1000
+1 -1
View File
@@ -83,7 +83,7 @@ const lastCustomerNoticeByCustomer = new Map() // customerId -> timestamp
/**
* Benachrichtigt den Kunden per E-Mail ueber eine neue Chatnachricht vom Team (K17), ohne BCC.
* Wirft nie; das Ergebnis zeigt das Admin-Panel unter der Nachricht an.
* Wirft nie; das Ergebnis zeigt das Ticketsystem unter der Nachricht an.
* @returns {Promise<{ sent: boolean, reason?: string, to?: string }>}
*/
export async function sendCustomerChatNotice(customer, { sender, text }) {
+2 -2
View File
@@ -11,7 +11,7 @@ import { shortRef } from './takeoverCredits.js'
* Individuelle Abo-Angebote.
*
* Der Admin vereinbart mit einem Kunden einen Sonderpreis fuer die ersten Monate (z. B. 2 Monate
* je 25 € statt 50 €) und legt ihn vorab im Admin-Panel an. Jedes Angebot bekommt sofort einen
* je 25 € statt 50 €) und legt ihn vorab im Ticketsystem an. Jedes Angebot bekommt sofort einen
* eigenen, einmal einloesbaren Stripe-Coupon (amount_off, duration "repeating" ueber die
* Angebotsmonate); danach gilt automatisch wieder der regulaere Preis.
* - Kunde ohne Abo: Portal zeigt das Angebot, die Bestelluebersicht nennt den Angebotspreis und
@@ -24,7 +24,7 @@ const COLLECTION = () => config.collections.aboOffers
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
// Anzeige im Team-Hinweis (wie im Admin-Bereich)
// Anzeige im Team-Hinweis (wie im Ticketsystem)
const OFFER_STATUS_LABEL = { offen: 'offen', aktiv: 'aktiv (schon eingelöst)', zurueckgezogen: 'zurückgezogen' }
function httpError(status, message) {
+2 -2
View File
@@ -45,8 +45,8 @@ async function currentPasswordVersion(userId) {
}
/**
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Admin-Sessions
* ("Als Kunde ansehen") und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Sessions der
* Kundenansicht (Mitarbeiter) und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
* erreichbar, gilt die Session weiter (sonst waeren bei jeder Stoerung alle abgemeldet).
*/
export async function isSessionPasswordCurrent(session) {
@@ -1,15 +1,7 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
import { isPreviewProjectReady } from '../../services/previewReadiness.js'
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
const router = Router()
function isStaffAdmin(user) {
return (user?.labels || []).includes('admin')
}
import { config } from '../config.js'
import { listDocuments, Query } from './appwriteAdmin.js'
import { isPreviewProjectReady } from './previewReadiness.js'
import { createStaffPreviewToken } from './staffPreviewTokens.js'
function safePreviewUrl(url) {
try {
@@ -36,8 +28,8 @@ function previewUrlForSubdomain(subdomain) {
}
/**
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview). Genutzt von dieser
* Admin-Route und vom Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview), genutzt vom
* Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
*/
export async function buildStaffPreviewLink({ userId, email, name, subdomain: rawSubdomain, previewUrl: rawPreviewUrl }) {
const subdomain = String(rawSubdomain || '')
@@ -76,22 +68,3 @@ export async function buildStaffPreviewLink({ userId, email, name, subdomain: ra
return { status: err.status || 500, body: { error: err.message || 'Preview-Login fehlgeschlagen' } }
}
}
router.use(requireAppwriteStaff)
router.post('/', async (req, res) => {
const user = req.appwriteUser
if (!isStaffAdmin(user)) {
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
}
const { status, body } = await buildStaffPreviewLink({
userId: user.$id,
email: user.email,
name: user.name,
subdomain: req.body?.subdomain,
previewUrl: req.body?.previewUrl,
})
return res.status(status).json(body)
})
export default router
+2 -3
View File
@@ -21,9 +21,8 @@ export function createStaffPreviewToken(payload) {
}
/**
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) vom Wechsel ins Projekt-Admin
* ('switch') und von der Kundenansicht aus dem Ticketsystem ('kundenansicht'); ein Token fuer den falschen
* Zweck ist trotzdem verbraucht.
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) von der Kundenansicht aus dem
* Ticketsystem ('kundenansicht'); ein Token fuer den falschen Zweck ist trotzdem verbraucht.
*/
export function consumeStaffPreviewToken(token, purpose = 'preview') {
purgeExpired()