diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..716f194
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,129 @@
+
+
+
+ Keine Tickets für diesen Kunden.
'
+ return tickets.map((t) => `
+ Kunde ist nicht mit InvoiceNinja verknüpft.
'
+ if (!data.invoices.length) return 'Keine Rechnungen vorhanden.
'
+ return `
@@ -21,12 +42,24 @@
+
+
Meine Website-Projekte
Projekte werden geladen…
Noch keine Website-Projekte zugewiesen.
+
+
+
+
diff --git a/server/index.js b/server/index.js
index 18915bb..257a657 100644
--- a/server/index.js
+++ b/server/index.js
@@ -8,6 +8,8 @@ import { sessionMiddleware } from './middleware/session.js'
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 giteaWebhookRoutes from './routes/webhook/gitea.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
@@ -43,6 +45,8 @@ app.use((req, res, next) => {
app.use('/api/auth', authRoutes)
app.use('/api/projects', projectsRoutes)
app.use('/api/features', featuresRoutes)
+app.use('/api', customerDataRoutes)
+app.use('/api/portal-admin', portalAdminRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
app.use('/api/admin/customers', adminCustomersRoutes)
diff --git a/server/middleware/session.js b/server/middleware/session.js
index bd8b6c9..93ea81d 100644
--- a/server/middleware/session.js
+++ b/server/middleware/session.js
@@ -26,22 +26,36 @@ export function clearPortalSession(res) {
})
}
-export function requireSession(req, res, next) {
+export function parsePortalSession(req) {
const raw = req.signedCookies?.[config.cookieName]
- if (!raw) {
+ if (!raw) return null
+ try {
+ return JSON.parse(raw)
+ } catch {
+ return null
+ }
+}
+
+export function requireSession(req, res, next) {
+ const session = parsePortalSession(req)
+ if (!session) {
return res.status(401).json({ error: 'Nicht angemeldet' })
}
-
- try {
- const session = JSON.parse(raw)
- if (!session.customerId || !session.appwriteUserId) {
- return res.status(401).json({ error: 'Ungültige Session' })
- }
- req.session = session
- next()
- } catch {
+ if (!session.customerId || !session.appwriteUserId) {
return res.status(401).json({ error: 'Ungültige Session' })
}
+ req.session = session
+ 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) {
diff --git a/server/routes/auth.js b/server/routes/auth.js
index d154c82..11314b0 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -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) {
diff --git a/server/routes/customerData.js b/server/routes/customerData.js
new file mode 100644
index 0000000..b561c54
--- /dev/null
+++ b/server/routes/customerData.js
@@ -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
diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js
new file mode 100644
index 0000000..86b36ea
--- /dev/null
+++ b/server/routes/portalAdmin.js
@@ -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
diff --git a/server/services/appwriteClient.js b/server/services/appwriteClient.js
index 00ad50e..ca74f50 100644
--- a/server/services/appwriteClient.js
+++ b/server/services/appwriteClient.js
@@ -1,22 +1,5 @@
import { config } from '../config.js'
-const DEBUG_LOG = (location, message, data, hypothesisId) => {
- // #region agent log
- fetch('http://127.0.0.1:7281/ingest/30e8e71c-b377-4e72-84f9-593826c6d234', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '80bbfc' },
- body: JSON.stringify({
- sessionId: '80bbfc',
- location,
- message,
- data,
- hypothesisId,
- timestamp: Date.now(),
- }),
- }).catch(() => {})
- // #endregion
-}
-
function appwriteHeaders() {
return {
'Content-Type': 'application/json',
@@ -78,7 +61,6 @@ export async function loginWithAppwrite(email, password) {
const now = Date.now()
if (now < appwriteLoginBlockedUntil) {
const waitSec = Math.ceil((appwriteLoginBlockedUntil - now) / 1000)
- DEBUG_LOG('appwriteClient.js:cooldown', 'login blocked locally', { waitSec }, 'H8')
const error = new Error(
`Zu viele Anmeldeversuche. Bitte warte noch ${waitSec} Sekunden.`
)
@@ -92,15 +74,7 @@ export async function loginWithAppwrite(email, password) {
method: 'POST',
body: { email, password },
})
- DEBUG_LOG('appwriteClient.js:session', 'createEmailPasswordSession ok', {
- hasUserId: Boolean(session?.userId),
- sessionId: session?.$id || null,
- }, 'H6')
} catch (err) {
- DEBUG_LOG('appwriteClient.js:session', 'createEmailPasswordSession fail', {
- message: err?.message?.slice(0, 120),
- code: err?.code,
- }, 'H1')
if (err.status === 429) {
appwriteLoginBlockedUntil = Date.now() + APPWRITE_RATE_LIMIT_COOLDOWN_MS
}
@@ -116,7 +90,6 @@ export async function loginWithAppwrite(email, password) {
}
const user = { $id: session.userId, email, name: '' }
- DEBUG_LOG('appwriteClient.js:user', 'using session userId', { userId: user.$id }, 'H7')
clearLoginCooldown()
return user
diff --git a/server/services/customerActivity.js b/server/services/customerActivity.js
new file mode 100644
index 0000000..4e9e8cc
--- /dev/null
+++ b/server/services/customerActivity.js
@@ -0,0 +1,57 @@
+import { config } from '../config.js'
+import { listDocuments, Query } from './appwriteAdmin.js'
+
+/**
+ * Tickets + zugehoerige Worksheets eines Kunden ("was wurde gemacht").
+ * excludeTypes: Ticket-Typen, die der Kunde nicht sehen soll (z. B. Akquise).
+ */
+export async function getCustomerActivity(customerId, { ticketLimit = 25, excludeTypes = [] } = {}) {
+ if (!customerId) return []
+
+ let workorders = await listDocuments(config.collections.workorders, [
+ Query.equal('customerId', customerId),
+ Query.orderDesc('$createdAt'),
+ Query.limit(ticketLimit),
+ ])
+
+ if (excludeTypes.length) {
+ const blocked = new Set(excludeTypes.map((t) => t.toLowerCase()))
+ workorders = workorders.filter((w) => !blocked.has(String(w.type || '').toLowerCase()))
+ }
+
+ const ids = workorders.map((w) => w.$id)
+ let worksheets = []
+ if (ids.length) {
+ worksheets = await listDocuments(config.collections.worksheets, [
+ Query.equal('workorderId', ids),
+ Query.orderDesc('$createdAt'),
+ Query.limit(300),
+ ]).catch(() => [])
+ }
+
+ const sheetsByWorkorder = {}
+ for (const ws of worksheets) {
+ if (ws.isComment) continue
+ const list = (sheetsByWorkorder[ws.workorderId] ||= [])
+ list.push({
+ wsid: ws.wsid || '',
+ employeeName: ws.employeeName || ws.employeeShort || '',
+ serviceType: ws.serviceType || '',
+ newStatus: ws.newStatus || '',
+ totalTime: ws.totalTime || '',
+ date: ws.endDate || ws.startDate || '',
+ details: ws.details || '',
+ createdAt: ws.createdAt || ws.$createdAt || '',
+ })
+ }
+
+ return workorders.map((w) => ({
+ id: w.$id,
+ woid: w.woid || '',
+ topic: w.topic || w.title || '',
+ status: w.status || '',
+ type: w.type || '',
+ createdAt: w.createdAt || w.$createdAt || '',
+ worksheets: (sheetsByWorkorder[w.$id] || []).slice(0, 20),
+ }))
+}
diff --git a/server/services/invoiceNinja.js b/server/services/invoiceNinja.js
index 3222295..7c722f6 100644
--- a/server/services/invoiceNinja.js
+++ b/server/services/invoiceNinja.js
@@ -35,6 +35,75 @@ export function sanitizeInvoiceClient(client) {
}
}
+const INVOICE_STATUS_LABELS = {
+ 1: 'Entwurf',
+ 2: 'Offen',
+ 3: 'Teilzahlung',
+ 4: 'Bezahlt',
+ 5: 'Storniert',
+ 6: 'Storniert',
+}
+
+export function sanitizeInvoice(inv) {
+ const today = new Date().toISOString().slice(0, 10)
+ const balance = Number(inv.balance ?? 0)
+ const statusId = Number(inv.status_id ?? 0)
+ const overdue = balance > 0 && Boolean(inv.due_date) && inv.due_date < today && statusId !== 5
+ const invitationKey = inv.invitations?.[0]?.key || ''
+
+ return {
+ id: inv.id,
+ number: inv.number || '',
+ date: inv.date || '',
+ dueDate: inv.due_date || '',
+ amount: Number(inv.amount ?? 0),
+ balance,
+ statusId,
+ status: overdue ? 'Überfällig' : INVOICE_STATUS_LABELS[statusId] || 'Offen',
+ overdue,
+ poNumber: inv.po_number || '',
+ publicNotes: inv.public_notes || '',
+ // Link ins InvoiceNinja-Kundenportal (Rechnung ansehen/zahlen)
+ portalUrl: invitationKey ? `${config.invoiceNinja.url}/client/invoice/${invitationKey}` : '',
+ }
+}
+
+/** Rechnungen eines InvoiceNinja-Clients (fuer Kundenportal + Admin-Ansicht). */
+export async function listInvoicesForClient(clientId, { includeDrafts = false } = {}) {
+ const token = config.invoiceNinja.apiToken
+ if (!token) {
+ const error = new Error('INVOICE_NINJA_API_TOKEN ist nicht konfiguriert')
+ error.status = 503
+ throw error
+ }
+ if (!clientId) return []
+
+ const url = new URL(`${config.invoiceNinja.url}/api/v1/invoices`)
+ url.searchParams.set('client_id', String(clientId))
+ url.searchParams.set('per_page', '100')
+ url.searchParams.set('sort', 'date|desc')
+
+ const response = await fetch(url, {
+ headers: {
+ 'X-API-TOKEN': token,
+ 'X-Requested-With': 'XMLHttpRequest',
+ Accept: 'application/json',
+ },
+ })
+
+ const data = await response.json().catch(() => ({}))
+ if (!response.ok) {
+ const error = new Error(data.message || 'Invoice Ninja API Fehler')
+ error.status = response.status === 401 ? 502 : response.status
+ throw error
+ }
+
+ return (data.data || [])
+ .filter((inv) => !inv.is_deleted)
+ .map(sanitizeInvoice)
+ .filter((inv) => includeDrafts || inv.statusId !== 1)
+}
+
export async function listInvoiceNinjaClients() {
const token = config.invoiceNinja.apiToken
if (!token) {