import { Router } from 'express' import { createAvatar } from '@dicebear/core' import * as bigSmile from '@dicebear/big-smile' import { config } from '../config.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js' import { getCustomerActivity } from '../services/customerActivity.js' import { customerOrder, customerWorkEntries } from '../services/customerProjectView.js' import { CustomerInvoiceError, TRY_LATER, isStripeInvoiceId, listCustomerInvoices, stripeInvoicePdf } from '../services/customerInvoices.js' import { fetchInvoicePdf, getInvoiceRaw } from '../services/invoiceNinja.js' const router = Router() /** * Rechnungen des eingeloggten Kunden: Stripe (Abo, Funktionen, Projektrechnungen aus dem WOMS) und InvoiceNinja * (Altbestand, sofern verknuepft), neueste zuerst. incomplete: eine der Quellen hat nicht geantwortet. */ 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' }) } return res.json(await listCustomerInvoices(customer)) } catch (err) { if (err instanceof CustomerInvoiceError) return res.status(err.status).json({ error: err.message }) console.error('[rechnungen] Liste fehlgeschlagen:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '') return res.status(500).json({ error: TRY_LATER }) } }) /** PDF an den Browser (Download), nie zwischengespeichert. */ function sendPdf(res, pdf, filename) { res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="${filename}"`, 'Cache-Control': 'no-store', }) return res.end(pdf) } /** Rechnungs-PDF des eingeloggten Kunden (Download ueber Server-Proxy; InvoiceNinja oder Stripe). */ router.get('/invoices/:invoiceId/pdf', requireSession, async (req, res) => { // Stripe-Rechnung (in_...): Besitz ueber die Stripe-Kunden des Kunden, PDF frisch von Stripe if (isStripeInvoiceId(req.params.invoiceId)) { try { const { pdf, filename } = await stripeInvoicePdf(getSessionCustomerId(req), req.params.invoiceId) return sendPdf(res, pdf, filename) } catch (err) { if (err instanceof CustomerInvoiceError) return res.status(err.status).json({ error: err.message }) console.error('[rechnungen] PDF-Abruf fehlgeschlagen:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '') return res.status(500).json({ error: 'PDF konnte nicht geladen werden' }) } } try { const customer = await getDocument( config.collections.customers, getSessionCustomerId(req) ).catch(() => null) if (!customer?.invoiceNinjaClientId) { return res.status(404).json({ error: 'Rechnung nicht gefunden' }) } const invoice = await getInvoiceRaw(req.params.invoiceId).catch(() => null) // Ownership-Pruefung: Rechnung muss zum InvoiceNinja-Client des Kunden gehoeren if (!invoice || invoice.client_id !== customer.invoiceNinjaClientId) { return res.status(404).json({ error: 'Rechnung nicht gefunden' }) } const invitationKey = invoice.invitations?.[0]?.key if (!invitationKey) { return res.status(404).json({ error: 'Kein PDF verfuegbar' }) } const pdf = await fetchInvoicePdf(invitationKey) return sendPdf(res, pdf, `Rechnung-${invoice.number || invoice.id}.pdf`) } catch (err) { return res.status(err.status || 500).json({ error: err.message || 'PDF konnte 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'], }) // Status auf Deutsch, Arbeiten ohne Git-Technik (customerProjectView.js) const orders = tickets .map((t) => ({ order: customerOrder(t), createdAt: t.createdAt, entries: customerWorkEntries(t.worksheets) })) .filter((t) => t.order) .map(({ order, createdAt, entries }) => ({ ...order, createdAt, entries })) return res.json({ tickets: orders }) } catch (err) { return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' }) } }) /** * "Dein Ansprechpartner" in der Seitenleiste: der Mitarbeiter, dem das juengste zugewiesene * Ticket des Kunden gehoert (workorders.assignedTo -> employees). Sonst das Team. */ router.get('/contact', requireSession, async (req, res) => { const fallback = { name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' } try { const [tickets, employees] = await Promise.all([ listDocuments(config.collections.workorders, [ Query.equal('customerId', getSessionCustomerId(req)), Query.orderDesc('$createdAt'), Query.limit(50), ]), listDocuments(config.collections.employees, [Query.limit(100)]), ]) const nameOf = new Map(employees.flatMap((e) => [[e.userId, e.displayName], [e.$id, e.displayName]])) const assignee = (t) => nameOf.get(String(t.assignedTo || '')) || t.assignedName || '' const ticket = tickets.find((t) => assignee(t)) return res.json({ ...fallback, name: ticket ? assignee(ticket) : fallback.name }) } catch { return res.json(fallback) } }) /** Profilbild: DiceBear "Big Smile", lokal erzeugt (keine Anfrage an dicebear.com). Die Kunden-ID ist der Seed - zufaellig pro Kunde, aber bei jedem Login dasselbe Bild. */ router.get('/avatar.svg', requireSession, (req, res) => { const svg = createAvatar(bigSmile, { seed: String(getSessionCustomerId(req) || 'webklar'), backgroundColor: ['b6e3f4', 'c0aede', 'd1d4f9', 'ffd5dc', 'ffdfbf'], }).toString() res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8') res.setHeader('Cache-Control', 'private, max-age=86400') return res.send(svg) }) export default router