feat: Admin-Bereich im Kundenportal (Ticketsystem-Login, Projektsuche, Kundenverwaltung)
- Login: Appwrite-User mit Label 'admin' (Ticketsystem-Mitarbeiter) bekommen eine Admin-Session und landen auf /admin.html - Admin-Dashboard: Statistik-Uebersicht, Projektsuche ueber alle Projekte (inkl. Preview-/Gitea-Links, Vorschau ohne Kunden-Login), Kundenliste mit Portal-Status, letztem Login und Projektanzahl - Kundennummer (code) direkt im Admin-Bereich pflegbar - 'Als Kunde ansehen': Admin sieht das Portal exakt wie der Kunde (Banner mit Rueckweg zur Admin-Uebersicht) - Kunden-Dashboard: neue Bereiche 'Meine Rechnungen' (InvoiceNinja inkl. Link ins Rechnungsportal) und 'Was wurde gemacht' (Tickets + Worksheets aus dem Ticketsystem, ohne interne Akquise-Tickets); Kundennummer im Header - Admin-API /api/portal-admin/* (Session-basiert), Kunden-API /api/invoices und /api/activity - appwriteClient: uebrig gebliebene Debug-Instrumentierung entfernt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
getCustomerByEmail,
|
||||
getDocument,
|
||||
getPortalAccessByCustomerId,
|
||||
getUserById,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
Query,
|
||||
@@ -147,6 +148,31 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
try {
|
||||
const user = await loginWithAppwrite(email.trim(), password)
|
||||
|
||||
// Ticketsystem-Admins (Appwrite-Label "admin") bekommen die Admin-Ansicht
|
||||
const account = await getUserById(user.$id).catch(() => null)
|
||||
if (Array.isArray(account?.labels) && account.labels.includes('admin')) {
|
||||
const adminName = account.name || 'Admin'
|
||||
const adminEmail = account.email || email.trim()
|
||||
setPortalSession(res, {
|
||||
role: 'admin',
|
||||
appwriteUserId: user.$id,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
})
|
||||
const adminPreviewToken = await createPreviewSessionToken({
|
||||
userId: user.$id,
|
||||
customerId: '',
|
||||
email: adminEmail,
|
||||
name: adminName,
|
||||
role: 'admin',
|
||||
labels: ['admin'],
|
||||
authSource: 'kundenbereich-admin',
|
||||
})
|
||||
res.cookie(PREVIEW_COOKIE_NAME, adminPreviewToken, previewSessionCookieOptions())
|
||||
return res.json({ success: true, role: 'admin', admin: { name: adminName, email: adminEmail } })
|
||||
}
|
||||
|
||||
let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim())
|
||||
customer = await syncCustomerAppwriteUserId(customer, user.$id)
|
||||
|
||||
@@ -176,7 +202,7 @@ router.post('/login', async (req, res) => {
|
||||
console.warn('[auth] lastLoginAt update failed:', err.message)
|
||||
}
|
||||
|
||||
return res.json({ success: true, customer: sanitizeCustomer(customer) })
|
||||
return res.json({ success: true, role: 'customer', customer: sanitizeCustomer(customer) })
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
if (status === 429) {
|
||||
@@ -279,6 +305,26 @@ router.get('/me', async (req, res) => {
|
||||
|
||||
try {
|
||||
const session = JSON.parse(raw)
|
||||
|
||||
// Admin-Session (Ticketsystem-Mitarbeiter), optional im "Als Kunde ansehen"-Modus
|
||||
if (session.role === 'admin' && session.appwriteUserId) {
|
||||
let customer = null
|
||||
if (session.customerId) {
|
||||
const doc = await getDocument(config.collections.customers, session.customerId).catch(() => null)
|
||||
if (doc) customer = sanitizeCustomer(doc)
|
||||
}
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
role: 'admin',
|
||||
viewAs: Boolean(session.viewAs && customer),
|
||||
admin: {
|
||||
name: session.adminName || session.name || 'Admin',
|
||||
email: session.adminEmail || session.email || '',
|
||||
},
|
||||
customer,
|
||||
})
|
||||
}
|
||||
|
||||
if (!session.customerId || !session.appwriteUserId) {
|
||||
return res.json({ authenticated: false })
|
||||
}
|
||||
@@ -304,6 +350,7 @@ router.get('/me', async (req, res) => {
|
||||
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
role: 'customer',
|
||||
customer: sanitizeCustomer(customer),
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
44
server/routes/customerData.js
Normal file
44
server/routes/customerData.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** Rechnungen des eingeloggten Kunden (InvoiceNinja, sofern verknuepft). */
|
||||
router.get('/invoices', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customer = await getDocument(
|
||||
config.collections.customers,
|
||||
getSessionCustomerId(req)
|
||||
).catch(() => null)
|
||||
if (!customer) {
|
||||
return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
}
|
||||
if (!customer.invoiceNinjaClientId) {
|
||||
return res.json({ linked: false, invoices: [] })
|
||||
}
|
||||
const invoices = await listInvoicesForClient(customer.invoiceNinjaClientId)
|
||||
return res.json({ linked: true, invoices })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Rechnungen konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Ticket-/Arbeitshistorie des eingeloggten Kunden ("was wurde gemacht"). */
|
||||
router.get('/activity', requireSession, async (req, res) => {
|
||||
try {
|
||||
const tickets = await getCustomerActivity(getSessionCustomerId(req), {
|
||||
ticketLimit: 25,
|
||||
// Interne Akquise-Tickets gehoeren nicht in die Kundenansicht
|
||||
excludeTypes: ['Akquise'],
|
||||
})
|
||||
return res.json({ tickets })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
261
server/routes/portalAdmin.js
Normal file
261
server/routes/portalAdmin.js
Normal file
@@ -0,0 +1,261 @@
|
||||
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 { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAdminSession)
|
||||
|
||||
function norm(value) {
|
||||
return String(value || '').toLowerCase()
|
||||
}
|
||||
|
||||
function isDeployed(project) {
|
||||
return ['ready', 'deployed'].includes(norm(project.provisioningStatus || project.status))
|
||||
}
|
||||
|
||||
function customerNameMap(customers) {
|
||||
const map = {}
|
||||
for (const c of customers) map[c.$id] = c.name || c.code || c.$id
|
||||
return map
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ü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.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.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' })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------- 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' })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------- 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,
|
||||
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',
|
||||
appwriteUserId: session.appwriteUserId,
|
||||
name: session.adminName || session.name || 'Admin',
|
||||
email: session.adminEmail || session.email || '',
|
||||
})
|
||||
return res.json({ success: true })
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user