From d4bd510dcd4a60e366640719c5cfb301145fe423 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Sat, 11 Jul 2026 18:01:24 +0000 Subject: [PATCH] feat: Kundenportal-Ausbau - Tabs, Projekt-Einblicke, Git-Zeiterfassung, Rechnungen, Chat Kunden-Dashboard: - Tab-Navigation: Projekte | Was wurde gemacht | Rechnungen | Chat - Projekt-Details pro Projekt: letzte Git-Commits (Titel+Beschreibung), Projektgroesse, Datei-Uebersicht (Top-Level aggregiert), Ticket-Arbeiten - Rechnungen: gestylte Liste mit Status-Pillen, Ansehen/Zahlen-Link, PDF-Download ueber Server-Proxy (IDOR-geschuetzt) - Chat mit dem Webklar-Team (Polling, Ungelesen-Badge, viewAs blockiert) Admin-Dashboard: - Chat-Tab: Konversationsliste + Thread + Antwortfeld, Ungelesen-Badge Backend: - giteaAdmin: getRepoInfo, listRepoCommits, getRepoTreeSummary - projectInsights: 5-min-Cache, Invalidierung per Gitea-Webhook - /api/projects/:id/git|files|work mit Ownership-Check (404) - /api/chat/* (Kunde) + /api/portal-admin/chats/* (Admin), portalMessages-Collection - mailer: E-Mail-Benachrichtigung an Admins bei Kundennachricht (15-min-Throttle) - gitWorksheet: dd.mm.yyyy, voller Commit-Body, startTime leer (Zeit-Nachtrag), Auto-Webpage-Ticket bei Push auf Projekt ohne Ticket (ensureProjectTicket) - customerActivity: Git-Push-Eintraege fuer Kunden sichtbar Co-Authored-By: Claude Fable 5 --- package-lock.json | 12 +- package.json | 3 +- public/admin.html | 40 +++ public/admin.js | 148 +++++++++ public/app.js | 399 ++++++++++++++++++++---- public/dashboard.html | 106 ++++++- scripts/migrate-git-worksheet-dates.mjs | 50 +++ scripts/setup-chat-collection.mjs | 111 +++++++ server/config.js | 18 ++ server/index.js | 2 + server/routes/chat.js | 123 ++++++++ server/routes/customerData.js | 36 ++- server/routes/portalAdmin.js | 117 +++++++ server/routes/projects.js | 77 ++++- server/routes/webhook/gitea.js | 32 +- server/services/appwriteAdmin.js | 1 + server/services/customerActivity.js | 5 +- server/services/gitWorksheet.js | 27 +- server/services/giteaAdmin.js | 88 ++++++ server/services/invoiceNinja.js | 47 +++ server/services/mailer.js | 56 ++++ server/services/projectInsights.js | 47 +++ server/services/ticketAdmin.js | 65 ++++ 23 files changed, 1510 insertions(+), 100 deletions(-) create mode 100644 scripts/migrate-git-worksheet-dates.mjs create mode 100644 scripts/setup-chat-collection.mjs create mode 100644 server/routes/chat.js create mode 100644 server/services/mailer.js create mode 100644 server/services/projectInsights.js create mode 100644 server/services/ticketAdmin.js diff --git a/package-lock.json b/package-lock.json index 185bb37..d81f37d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "jose": "^6.0.11", - "node-appwrite": "^14.1.0" + "node-appwrite": "^14.1.0", + "nodemailer": "^9.0.3" }, "engines": { "node": ">=18" @@ -581,6 +582,15 @@ "integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index 3b43429..d09c671 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "jose": "^6.0.11", - "node-appwrite": "^14.1.0" + "node-appwrite": "^14.1.0", + "nodemailer": "^9.0.3" } } diff --git a/public/admin.html b/public/admin.html index 716f194..9ae7ad1 100644 --- a/public/admin.html +++ b/public/admin.html @@ -49,6 +49,33 @@ .recent-list li { padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,0.07); font-size: 14px; } .recent-list li:last-child { border-bottom: none; } .two-col { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } + + /* Chat */ + .badge-pill { + display: inline-flex; min-width: 20px; height: 20px; padding: 0 6px; + align-items: center; justify-content: center; border-radius: 999px; + background: #f87171; color: #fff; font-size: 12px; font-weight: 700; + } + .chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 14px; min-height: 480px; } + @media (max-width: 760px) { .chat-layout { grid-template-columns: 1fr; } } + .chat-list { border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; overflow-y: auto; max-height: 560px; } + .chat-list-item { padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.07); cursor: pointer; } + .chat-list-item:hover { background: rgba(255,255,255,0.05); } + .chat-list-item.active { background: rgba(94,234,212,0.1); } + .chat-list-item .last { font-size: 12px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .chat-thread { display: flex; flex-direction: column; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; } + .chat-messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding: 4px; max-height: 440px; } + .chat-bubble { max-width: 78%; padding: 10px 14px; border-radius: 16px; font-size: 14px; white-space: pre-wrap; word-break: break-word; } + .chat-bubble .chat-meta { display: block; font-size: 11px; opacity: 0.6; margin-top: 4px; } + .chat-bubble.mine { align-self: flex-end; background: rgba(94,234,212,0.18); border: 1px solid rgba(94,234,212,0.45); border-bottom-right-radius: 4px; } + .chat-bubble.theirs { align-self: flex-start; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); border-bottom-left-radius: 4px; } + .chat-input-row { display: flex; gap: 10px; margin-top: 12px; align-items: flex-end; } + .chat-input-row textarea { + flex: 1; resize: none; min-height: 44px; max-height: 120px; padding: 10px 12px; + border-radius: 12px; border: 1px solid rgba(255,255,255,0.18); + background: rgba(255,255,255,0.06); color: inherit; font: inherit; + } + .chat-empty { display: flex; align-items: center; justify-content: center; flex: 1; opacity: 0.6; } @@ -73,6 +100,7 @@ + @@ -119,6 +147,18 @@

Kundennummer direkt im Feld ändern und mit ✓ speichern. „Als Kunde ansehen“ zeigt dir das Portal genau so, wie der Kunde es sieht.

+ + +
+
+
+
Lade Unterhaltungen…
+
+
+
Unterhaltung links auswählen
+
+
+
diff --git a/public/admin.js b/public/admin.js index 33d7546..d49501e 100644 --- a/public/admin.js +++ b/public/admin.js @@ -249,6 +249,145 @@ async function loadCustomers(search = '') { } } +/* ----------------------------------------------------------------- Chat --- */ +const adminChat = { + activeCustomerId: '', + activeCustomerName: '', + lastCreatedAt: '', + threadTimer: null, +} + +function updateAdminChatBadge(unread) { + const badge = document.getElementById('admin-chat-badge') + if (!badge) return + if (unread > 0) { + badge.textContent = unread + badge.classList.remove('hidden') + } else { + badge.classList.add('hidden') + } +} + +async function pollAdminChatBadge() { + if (document.hidden) return + try { + const { unread } = await api('/api/portal-admin/chats/unread-count') + updateAdminChatBadge(unread) + } catch { + /* ignore */ + } +} + +async function loadChatList() { + const list = document.getElementById('admin-chat-list') + try { + const { chats } = await api('/api/portal-admin/chats') + if (!chats.length) { + list.innerHTML = '
Noch keine Unterhaltungen.
' + return + } + list.innerHTML = chats.map((c) => ` +
+
+ ${escapeHtml(c.customerName)} + ${c.unreadFromCustomer ? `${c.unreadFromCustomer}` : ''} +
+
${c.lastSender === 'admin' ? 'Du: ' : ''}${escapeHtml(c.lastText)}
+
${fmtDateTime(c.lastAt)}
+
`).join('') + list.querySelectorAll('[data-chat-customer]').forEach((el) => + el.addEventListener('click', () => openChatThread(el.dataset.chatCustomer, el.dataset.chatName))) + } catch (err) { + list.innerHTML = `
${escapeHtml(err.message)}
` + } +} + +function appendAdminChatMessages(messages) { + const box = document.getElementById('admin-chat-messages') + if (!box || !messages.length) return + for (const msg of messages) { + const div = document.createElement('div') + div.className = `chat-bubble ${msg.sender === 'admin' ? 'mine' : 'theirs'}` + div.innerHTML = `${escapeHtml(msg.text)}${msg.sender === 'admin' ? escapeHtml(msg.senderName || 'Admin') : escapeHtml(adminChat.activeCustomerName)} · ${fmtDateTime(msg.createdAt)}` + box.appendChild(div) + if (msg.createdAt > adminChat.lastCreatedAt) adminChat.lastCreatedAt = msg.createdAt + } + box.scrollTop = box.scrollHeight +} + +async function pollAdminThread() { + if (!adminChat.activeCustomerId || document.hidden) return + try { + const q = adminChat.lastCreatedAt ? `?since=${encodeURIComponent(adminChat.lastCreatedAt)}` : '' + const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages${q}`) + if (messages.length) { + appendAdminChatMessages(messages) + await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/read`, { method: 'POST' }).catch(() => {}) + } + } catch { + /* naechster Poll */ + } +} + +async function sendAdminChatMessage() { + const input = document.getElementById('admin-chat-input') + const btn = document.getElementById('admin-chat-send') + const text = input.value.trim() + if (!text || !adminChat.activeCustomerId) return + btn.disabled = true + try { + const { message } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages`, { + method: 'POST', + body: JSON.stringify({ text }), + }) + input.value = '' + appendAdminChatMessages([message]) + loadChatList() + } catch (err) { + alert('Nachricht konnte nicht gesendet werden: ' + err.message) + } finally { + btn.disabled = false + input.focus() + } +} + +async function openChatThread(customerId, customerName) { + adminChat.activeCustomerId = customerId + adminChat.activeCustomerName = customerName || 'Kunde' + adminChat.lastCreatedAt = '' + + const thread = document.getElementById('admin-chat-thread') + thread.innerHTML = ` +
${escapeHtml(adminChat.activeCustomerName)}
+
+
+ + +
` + + document.getElementById('admin-chat-send').addEventListener('click', sendAdminChatMessage) + document.getElementById('admin-chat-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendAdminChatMessage() + } + }) + + try { + const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages`) + appendAdminChatMessages(messages) + await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/read`, { method: 'POST' }).catch(() => {}) + updateAdminChatBadge(0) + pollAdminChatBadge() + loadChatList() + } catch (err) { + document.getElementById('admin-chat-messages').innerHTML = `${escapeHtml(err.message)}` + } + + clearInterval(adminChat.threadTimer) + adminChat.threadTimer = setInterval(pollAdminThread, 15000) +} + /* ----------------------------------------------------------------- Init --- */ async function initAdminPage() { document.getElementById('logout-btn')?.addEventListener('click', async () => { @@ -273,12 +412,17 @@ async function initAdminPage() { return } + let chatListLoaded = false document.querySelectorAll('.admin-tab').forEach((tab) => { tab.addEventListener('click', () => { document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active')) document.querySelectorAll('.admin-section').forEach((s) => s.classList.remove('active')) tab.classList.add('active') document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active') + if (tab.dataset.tab === 'chat' && !chatListLoaded) { + chatListLoaded = true + loadChatList() + } }) }) @@ -294,6 +438,10 @@ async function initAdminPage() { loadOverview() loadProjects() loadCustomers() + + // Chat-Badge global pollen + pollAdminChatBadge() + setInterval(pollAdminChatBadge, 60000) } window.initAdminPage = initAdminPage diff --git a/public/app.js b/public/app.js index 7b6c823..2c4c80f 100644 --- a/public/app.js +++ b/public/app.js @@ -28,34 +28,8 @@ function hideError(el) { if (el) el.classList.add('hidden') } -function featuresForProject(features, projectId) { - return features.filter((f) => !f.projectId || f.projectId === projectId) -} - -function renderProjectCard(project, features) { - const li = document.createElement('li') - li.className = 'project-card' - const projectFeatures = featuresForProject(features, project.id) - const featureHtml = projectFeatures.length - ? `
${projectFeatures.map((f) => `${escapeHtml(f.featureKey)}`).join('')}
` - : '

Keine zusätzlichen Features freigeschaltet.

' - - li.innerHTML = ` -

${escapeHtml(project.projectName || project.subdomain || 'Projekt')}

-
-
Subdomain
${escapeHtml(project.subdomain || '–')}
-
Vorschau
${project.previewUrl ? `Projekt-Webseite öffnen` : '–'}
-
Live-Domain
${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}
-
Status
${escapeHtml(project.status || '–')}
-
Bereitstellung
${escapeHtml(project.provisioningStatus || '–')}
-
- ${featureHtml} - ` - return li -} - function escapeHtml(str) { - return String(str) + return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') @@ -70,6 +44,161 @@ function getQueryParam(name) { return new URLSearchParams(window.location.search).get(name) || '' } +function fmtDateShort(value) { + if (!value) return '–' + if (/^\d{2}\.\d{2}\.\d{4}$/.test(String(value))) return escapeHtml(value) + const d = new Date(value) + if (Number.isNaN(d.getTime())) return escapeHtml(value) + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) +} + +function fmtDateTime(value) { + if (!value) return '–' + const d = new Date(value) + if (Number.isNaN(d.getTime())) return escapeHtml(value) + return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) +} + +function fmtEuro(value) { + return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) +} + +function fmtBytes(bytes) { + const n = Number(bytes || 0) + if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB` + if (n >= 1024) return `${(n / 1024).toFixed(1)} KB` + return `${n} B` +} + +function fmtMinutes(min) { + const n = Number(min || 0) + if (!n) return '' + const h = Math.floor(n / 60) + const m = n % 60 + return h ? `${h} h ${m} min` : `${m} min` +} + +/* -------------------------------------------------------------- Projekte --- */ +function featuresForProject(features, projectId) { + return features.filter((f) => !f.projectId || f.projectId === projectId) +} + +function renderProjectCard(project, features) { + const li = document.createElement('li') + li.className = 'project-card' + const projectFeatures = featuresForProject(features, project.id) + const featureHtml = projectFeatures.length + ? `
${projectFeatures.map((f) => `${escapeHtml(f.featureKey)}`).join('')}
` + : '' + + li.innerHTML = ` +

${escapeHtml(project.projectName || project.subdomain || 'Projekt')}

+
+
Subdomain
${escapeHtml(project.subdomain || '–')}
+
Vorschau
${project.previewUrl ? `Projekt-Webseite öffnen` : '–'}
+
Live-Domain
${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}
+
Status
${escapeHtml(project.status || '–')}
+
+ ${featureHtml} +
+ +
+ + ` + + const btn = li.querySelector('[data-details-btn]') + const box = li.querySelector('[data-details-box]') + let loaded = false + btn.addEventListener('click', async () => { + const isHidden = box.classList.contains('hidden') + box.classList.toggle('hidden', !isHidden) + btn.textContent = isHidden ? 'Details ausblenden' : 'Details anzeigen' + if (isHidden && !loaded) { + loaded = true + await loadProjectDetails(project, box) + } + }) + + return li +} + +function renderCommits(git) { + const sizeInfo = git.repo + ? `

Projektgröße: ${fmtBytes((git.repo.sizeKb || 0) * 1024)} · Branch: ${escapeHtml(git.repo.defaultBranch || 'main')}

` + : '' + if (!git.commits || !git.commits.length) { + return `${sizeInfo}

Noch keine Änderungen vorhanden.

` + } + return sizeInfo + git.commits.map((c) => { + const [title, ...body] = String(c.message || '').trim().split('\n') + const bodyText = body.map((l) => l.trim()).filter(Boolean).join('\n') + return ` +
+ ${escapeHtml(title)} + ${bodyText ? `
${escapeHtml(bodyText)}
` : ''} +
${escapeHtml(c.shortSha)} · ${escapeHtml(c.authorName)} · ${fmtDateTime(c.date)}
+
` + }).join('') +} + +function renderFileOverview(files) { + if (!files || !files.totalFiles) { + return '

Keine Datei-Informationen verfügbar.

' + } + const rows = (files.topLevel || []).map((entry) => ` + + ${entry.type === 'dir' ? '📁' : '📄'} ${escapeHtml(entry.path)} + ${entry.fileCount} + ${fmtBytes(entry.sizeBytes)} + `).join('') + return ` +

${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}

+ + + ${rows} +
Ordner / DateiDateienGröße
` +} + +function renderWorkEntries(work) { + if (!work.ticket) { + return '

Für dieses Projekt gibt es noch kein Ticket.

' + } + const head = `

Ticket #${escapeHtml(work.ticket.woid)} „${escapeHtml(work.ticket.topic)}" · Status: ${escapeHtml(work.ticket.status)}

` + if (!work.worksheets.length) { + return `${head}

Noch keine dokumentierten Arbeiten.

` + } + return head + work.worksheets.map((ws) => ` +
+
+ ${ws.isGit ? 'Git-Push' : `${escapeHtml(ws.serviceType || 'Arbeit')}`} + ${escapeHtml(ws.employeeName)} + · ${fmtDateShort(ws.date)} + ${ws.totalTime ? `${fmtMinutes(ws.totalTime)}` : ''} + ${ws.pending ? 'Zeiterfassung folgt' : ''} +
+
${escapeHtml(ws.details)}
+
`).join('') +} + +async function loadProjectDetails(project, box) { + box.innerHTML = '

Lade Details…

' + const [git, files, work] = await Promise.all([ + api(`/api/projects/${encodeURIComponent(project.id)}/git`).catch(() => null), + api(`/api/projects/${encodeURIComponent(project.id)}/files`).catch(() => null), + api(`/api/projects/${encodeURIComponent(project.id)}/work`).catch(() => null), + ]) + + box.innerHTML = ` +

Letzte Änderungen

+ ${git ? renderCommits(git) : '

Git-Daten derzeit nicht verfügbar.

'} +

Datei-Übersicht

+ ${files ? renderFileOverview(files) : '

Datei-Übersicht derzeit nicht verfügbar.

'} +

Arbeiten am Projekt

+ ${work ? renderWorkEntries(work) : '

Keine Ticket-Daten verfügbar.

'} + ` +} + +/* -------------------------------------------------------- Login-Redirects --- */ function safeRedirectUrl(url) { try { const u = new URL(url) @@ -116,17 +245,6 @@ async function resolvePostLoginTarget() { if (next && (await canAccessPreviewUrl(next))) { return next } - - try { - const { projects } = await api('/api/projects') - const readyProjects = (projects || []).filter(isProjectPreviewReady) - if (readyProjects.length === 1) { - return readyProjects[0].previewUrl - } - } catch { - /* dashboard fallback */ - } - return '/dashboard.html' } @@ -134,6 +252,7 @@ async function postLoginRedirect() { window.location.href = await resolvePostLoginTarget() } +/* -------------------------------------------------------------- Login-Page --- */ async function initLoginPage() { const form = document.getElementById('login-form') const errorEl = document.getElementById('login-error') @@ -226,17 +345,7 @@ async function initLoginPage() { }) } -function fmtDateShort(value) { - if (!value) return '–' - const d = new Date(value) - if (Number.isNaN(d.getTime())) return escapeHtml(value) - return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) -} - -function fmtEuro(value) { - return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) -} - +/* --------------------------------------------------------------- View-as --- */ function renderViewAsBanner(me) { const banner = document.getElementById('viewas-banner') if (!banner || !me.viewAs) return @@ -251,14 +360,20 @@ function renderViewAsBanner(me) { }) } +/* ------------------------------------------------------------- Rechnungen --- */ async function loadCustomerInvoices() { - const section = document.getElementById('invoices-section') const body = document.getElementById('invoices-body') - if (!section || !body) return + if (!body) return try { const data = await api('/api/invoices') - if (!data.linked || !data.invoices.length) return - section.classList.remove('hidden') + if (!data.linked) { + body.innerHTML = '

Für dein Konto sind noch keine Rechnungen hinterlegt.

' + return + } + if (!data.invoices.length) { + body.innerHTML = '

Es liegen noch keine Rechnungen vor.

' + return + } body.innerHTML = ` @@ -269,24 +384,29 @@ async function loadCustomerInvoices() { - - + + `).join('')}
Nr.DatumBetragOffenStatus
${fmtDateShort(i.date)} ${fmtEuro(i.amount)} ${fmtEuro(i.balance)}${escapeHtml(i.status)}${i.portalUrl ? `Rechnung ansehen` : ''}${escapeHtml(i.status)} + ${i.portalUrl ? `Ansehen / Zahlen · ` : ''} + PDF +
` - } catch { - /* Rechnungen sind optional */ + } catch (err) { + body.innerHTML = `

Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}

` } } +/* -------------------------------------------------------------- Aktivität --- */ async function loadCustomerActivity() { - const section = document.getElementById('activity-section') const body = document.getElementById('activity-body') - if (!section || !body) return + if (!body) return try { const { tickets } = await api('/api/activity') - if (!tickets || !tickets.length) return - section.classList.remove('hidden') + if (!tickets || !tickets.length) { + body.innerHTML = '

Es wurden noch keine Arbeiten dokumentiert.

' + return + } body.innerHTML = tickets.map((t) => `
@@ -295,14 +415,134 @@ async function loadCustomerActivity() { · ${fmtDateShort(t.createdAt)}
${(t.worksheets || []).map((w) => ` -
→ ${fmtDateShort(w.date)}${w.employeeName ? ` ${escapeHtml(w.employeeName)}` : ''}${w.serviceType ? ` (${escapeHtml(w.serviceType)}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''})` : ''}${w.details ? `: ${escapeHtml(w.details.slice(0, 200))}` : ''}
+
+ ${w.isGit ? 'Git-Push' : `${escapeHtml(w.serviceType || 'Arbeit')}`} + ${w.employeeName ? ` ${escapeHtml(w.employeeName)}` : ''} + · ${fmtDateShort(w.date)} + ${Number(w.totalTime) ? `${fmtMinutes(w.totalTime)}` : ''} + ${w.pending ? 'Zeiterfassung folgt' : ''} + ${w.details ? `
${escapeHtml(w.details)}
` : ''} +
`).join('')}
`).join('') - } catch { - /* Aktivität ist optional */ + } catch (err) { + body.innerHTML = `

Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}

` } } +/* ------------------------------------------------------------------ Chat --- */ +const chatState = { + lastCreatedAt: '', + pollTimer: null, + badgeTimer: null, + initialized: false, + viewAs: false, +} + +function appendChatMessages(messages) { + const box = document.getElementById('chat-messages') + if (!box || !messages.length) return + for (const msg of messages) { + const div = document.createElement('div') + div.className = `chat-bubble ${msg.sender === 'customer' ? 'mine' : 'theirs'}` + div.innerHTML = `${escapeHtml(msg.text)}${msg.sender === 'customer' ? 'Du' : escapeHtml(msg.senderName || 'Webklar-Team')} · ${fmtDateTime(msg.createdAt)}` + box.appendChild(div) + if (msg.createdAt > chatState.lastCreatedAt) chatState.lastCreatedAt = msg.createdAt + } + box.scrollTop = box.scrollHeight +} + +function updateChatBadge(unread) { + const badge = document.getElementById('chat-badge') + if (!badge) return + if (unread > 0) { + badge.textContent = unread + badge.classList.remove('hidden') + } else { + badge.classList.add('hidden') + } +} + +async function pollChatMessages() { + if (document.hidden) return + try { + const q = chatState.lastCreatedAt ? `?since=${encodeURIComponent(chatState.lastCreatedAt)}` : '' + const data = await api(`/api/chat/messages${q}`) + appendChatMessages(data.messages || []) + if (isChatTabActive() && (data.unread || 0) > 0) { + await api('/api/chat/read', { method: 'POST' }).catch(() => {}) + updateChatBadge(0) + } + } catch { + /* naechster Poll versucht es erneut */ + } +} + +function isChatTabActive() { + return document.querySelector('.portal-tab[data-tab="chat"]')?.classList.contains('active') +} + +async function sendChatMessage() { + const input = document.getElementById('chat-input') + const btn = document.getElementById('chat-send') + const text = input.value.trim() + if (!text) return + btn.disabled = true + try { + const { message } = await api('/api/chat/messages', { + method: 'POST', + body: JSON.stringify({ text }), + }) + input.value = '' + appendChatMessages([message]) + } catch (err) { + alert(err.status === 403 ? err.message : 'Nachricht konnte nicht gesendet werden: ' + err.message) + } finally { + btn.disabled = false + input.focus() + } +} + +async function initChat() { + if (chatState.initialized) return + chatState.initialized = true + + const input = document.getElementById('chat-input') + const btn = document.getElementById('chat-send') + + if (chatState.viewAs) { + input.disabled = true + btn.disabled = true + input.placeholder = 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.' + } else { + btn.addEventListener('click', sendChatMessage) + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendChatMessage() + } + }) + } + + await pollChatMessages() + await api('/api/chat/read', { method: 'POST' }).catch(() => {}) + updateChatBadge(0) + chatState.pollTimer = setInterval(() => { + if (isChatTabActive()) pollChatMessages() + }, 10000) +} + +async function pollChatBadge() { + if (document.hidden || isChatTabActive()) return + try { + const { unread } = await api('/api/chat/unread-count') + updateChatBadge(unread) + } catch { + /* ignore */ + } +} + +/* -------------------------------------------------------------- Dashboard --- */ async function initDashboardPage() { const meta = document.getElementById('customer-meta') const list = document.getElementById('projects') @@ -316,6 +556,31 @@ async function initDashboardPage() { window.location.href = '/login.html' }) + // Tabs (Muster wie Admin-Dashboard) + const loadedTabs = new Set(['projects']) + const lazyLoaders = { + activity: loadCustomerActivity, + invoices: loadCustomerInvoices, + chat: initChat, + } + document.querySelectorAll('.portal-tab').forEach((tab) => { + tab.addEventListener('click', () => { + document.querySelectorAll('.portal-tab').forEach((t) => t.classList.remove('active')) + document.querySelectorAll('.portal-section').forEach((s) => s.classList.remove('active')) + tab.classList.add('active') + document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active') + const name = tab.dataset.tab + if (!loadedTabs.has(name) && lazyLoaders[name]) { + loadedTabs.add(name) + lazyLoaders[name]() + } + if (name === 'chat') { + api('/api/chat/read', { method: 'POST' }).catch(() => {}) + updateChatBadge(0) + } + }) + }) + try { const me = await api('/api/auth/me') @@ -333,6 +598,7 @@ async function initDashboardPage() { } const customer = me.customer + chatState.viewAs = Boolean(me.viewAs) renderViewAsBanner(me) const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : '' @@ -355,8 +621,9 @@ async function initDashboardPage() { empty.classList.remove('hidden') } - loadCustomerInvoices() - loadCustomerActivity() + // Chat-Badge global pollen (auch wenn Chat-Tab nicht offen ist) + pollChatBadge() + chatState.badgeTimer = setInterval(pollChatBadge, 45000) } catch (err) { loading.classList.add('hidden') if (err.status === 401 || err.message.includes('Nicht angemeldet')) { diff --git a/public/dashboard.html b/public/dashboard.html index 9b43770..0c38604 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -3,7 +3,7 @@ - Meine Projekte – Webklar Kundenbereich + Kundenbereich – Webklar @@ -44,21 +99,46 @@
-

Meine Website-Projekte

- +

Kundenbereich

-

Projekte werden geladen…

- - -
diff --git a/scripts/migrate-git-worksheet-dates.mjs b/scripts/migrate-git-worksheet-dates.mjs new file mode 100644 index 0000000..03034ce --- /dev/null +++ b/scripts/migrate-git-worksheet-dates.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Konvertiert startDate/endDate von GIT-Worksheets aus YYYYMMDD nach dd.mm.yyyy + * (Format der Ticketsystem-SPA). Idempotent. + * Usage: APPWRITE_API_KEY=... node scripts/migrate-git-worksheet-dates.mjs + */ +const endpoint = (process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1').replace(/\/$/, '') +const projectId = process.env.APPWRITE_PROJECT_ID || '6a1058610003c5a13a05' +const apiKey = process.env.APPWRITE_API_KEY +const databaseId = process.env.APPWRITE_DATABASE_ID || 'woms-database' + +if (!apiKey) { + console.error('APPWRITE_API_KEY erforderlich') + process.exit(1) +} + +const headers = { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': projectId, + 'X-Appwrite-Key': apiKey, +} + +function convert(value) { + const m = String(value || '').match(/^(\d{4})(\d{2})(\d{2})$/) + return m ? `${m[3]}.${m[2]}.${m[1]}` : null +} + +const listUrl = `${endpoint}/databases/${databaseId}/collections/worksheets/documents?queries[]=${encodeURIComponent( + JSON.stringify({ method: 'limit', values: [500] }) +)}` +const { documents } = await fetch(listUrl, { headers }).then((r) => r.json()) + +let migrated = 0 +for (const doc of documents || []) { + const data = {} + const start = convert(doc.startDate) + const end = convert(doc.endDate) + if (start) data.startDate = start + if (end) data.endDate = end + if (!Object.keys(data).length) continue + + await fetch(`${endpoint}/databases/${databaseId}/collections/worksheets/documents/${doc.$id}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ data }), + }) + migrated += 1 + console.log(`migriert: WSID ${doc.wsid} (${doc.$id}) -> ${data.startDate || doc.startDate}`) +} +console.log(`Fertig: ${migrated} Worksheet(s) migriert.`) diff --git a/scripts/setup-chat-collection.mjs b/scripts/setup-chat-collection.mjs new file mode 100644 index 0000000..20550ed --- /dev/null +++ b/scripts/setup-chat-collection.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * Legt die Collection portalMessages (Kunde-Admin-Chat) in woms-database an. + * Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen. + * Usage: APPWRITE_API_KEY=... node scripts/setup-chat-collection.mjs + */ +const endpoint = (process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1').replace(/\/$/, '') +const projectId = process.env.APPWRITE_PROJECT_ID || '6a1058610003c5a13a05' +const apiKey = process.env.APPWRITE_API_KEY +const databaseId = process.env.APPWRITE_DATABASE_ID || 'woms-database' +const COLLECTION_ID = 'portalMessages' + +if (!apiKey) { + console.error('APPWRITE_API_KEY erforderlich') + process.exit(1) +} + +async function aw(path, { method = 'GET', body } = {}) { + const response = await fetch(`${endpoint}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': projectId, + 'X-Appwrite-Key': apiKey, + }, + body: body ? JSON.stringify(body) : undefined, + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + const err = new Error(data.message || `Appwrite ${response.status}`) + err.status = response.status + err.type = data.type + throw err + } + return data +} + +async function ensure(label, fn) { + try { + await fn() + console.log(`+ ${label}`) + } catch (e) { + if (e.status === 409 || /already exists/i.test(e.message)) { + console.log(`= ${label} (existiert)`) + } else { + throw new Error(`${label}: ${e.message}`) + } + } +} + +async function waitForAttribute(key, maxSec = 30) { + for (let i = 0; i < maxSec; i += 1) { + const attr = await aw( + `/databases/${databaseId}/collections/${COLLECTION_ID}/attributes/${key}` + ).catch(() => null) + if (attr?.status === 'available') return + await new Promise((r) => setTimeout(r, 1000)) + } + throw new Error(`Attribut ${key} wurde nicht verfuegbar`) +} + +const base = `/databases/${databaseId}/collections` + +await ensure(`Collection ${COLLECTION_ID}`, () => + aw(base, { + method: 'POST', + body: { + collectionId: COLLECTION_ID, + name: 'Portal Messages (Kunde-Admin-Chat)', + documentSecurity: false, + permissions: [], + }, + }) +) + +const attributes = [ + { kind: 'string', key: 'customerId', size: 64, required: true }, + { kind: 'string', key: 'sender', size: 16, required: true }, + { kind: 'string', key: 'senderName', size: 128, required: false }, + { kind: 'string', key: 'text', size: 4000, required: true }, + { kind: 'boolean', key: 'readByAdmin', required: false }, + { kind: 'boolean', key: 'readByCustomer', required: false }, + { kind: 'datetime', key: 'createdAt', required: true }, +] + +for (const attr of attributes) { + const { kind, ...body } = attr + await ensure(`Attribut ${attr.key}`, () => + aw(`${base}/${COLLECTION_ID}/attributes/${kind}`, { method: 'POST', body }) + ) +} + +for (const attr of attributes) { + await waitForAttribute(attr.key) +} +console.log('Alle Attribute verfuegbar.') + +const indexes = [ + { key: 'idx_customer_created', type: 'key', attributes: ['customerId', 'createdAt'], orders: ['ASC', 'DESC'] }, + { key: 'idx_created', type: 'key', attributes: ['createdAt'], orders: ['DESC'] }, + { key: 'idx_read_admin', type: 'key', attributes: ['readByAdmin', 'sender'], orders: ['ASC', 'ASC'] }, + { key: 'idx_customer_read', type: 'key', attributes: ['customerId', 'readByCustomer'], orders: ['ASC', 'ASC'] }, +] + +for (const idx of indexes) { + await ensure(`Index ${idx.key}`, () => + aw(`${base}/${COLLECTION_ID}/indexes`, { method: 'POST', body: idx }) + ) +} + +console.log('Fertig.') diff --git a/server/config.js b/server/config.js index 5d67741..2230bf9 100644 --- a/server/config.js +++ b/server/config.js @@ -46,6 +46,24 @@ export const config = { workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders', worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets', employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees', + portalMessages: process.env.APPWRITE_COLLECTION_PORTAL_MESSAGES || 'portalMessages', + }, + + smtp: { + host: process.env.SMTP_HOST || '', + port: Number(process.env.SMTP_PORT) || 587, + secure: (process.env.SMTP_SECURE || 'false') === 'true', + user: process.env.SMTP_USERNAME || '', + pass: process.env.SMTP_PASSWORD || '', + from: process.env.SMTP_FROM || process.env.SMTP_USERNAME || '', + }, + + chatNotify: { + emails: (process.env.CHAT_NOTIFY_EMAILS || 'kenso@webklar.com,andrej@webklar.com') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + throttleMin: Number(process.env.CHAT_NOTIFY_THROTTLE_MIN) || 15, }, gitea: { diff --git a/server/index.js b/server/index.js index 257a657..595c673 100644 --- a/server/index.js +++ b/server/index.js @@ -10,6 +10,7 @@ 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 giteaWebhookRoutes from './routes/webhook/gitea.js' import adminGiteaRoutes from './routes/admin/gitea.js' import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js' @@ -46,6 +47,7 @@ app.use('/api/auth', authRoutes) app.use('/api/projects', projectsRoutes) app.use('/api/features', featuresRoutes) app.use('/api', customerDataRoutes) +app.use('/api/chat', chatRoutes) app.use('/api/portal-admin', portalAdminRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes) diff --git a/server/routes/chat.js b/server/routes/chat.js new file mode 100644 index 0000000..05e1f21 --- /dev/null +++ b/server/routes/chat.js @@ -0,0 +1,123 @@ +import { Router } from 'express' +import { config } from '../config.js' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { + createDocument, + getDocument, + listDocuments, + updateDocument, + Query, + ID, +} from '../services/appwriteAdmin.js' +import { sendChatNotification } from '../services/mailer.js' + +const router = Router() + +export function sanitizeMessage(doc) { + return { + id: doc.$id, + sender: doc.sender || '', + senderName: doc.senderName || '', + text: doc.text || '', + createdAt: doc.createdAt || doc.$createdAt || '', + } +} + +async function countUnreadForCustomer(customerId) { + const docs = await listDocuments(config.collections.portalMessages, [ + Query.equal('customerId', customerId), + Query.equal('readByCustomer', false), + Query.equal('sender', 'admin'), + Query.limit(100), + ]).catch(() => []) + return docs.length +} + +/** Nachrichten des eingeloggten Kunden (optional nur neue seit ?since=ISO). */ +router.get('/messages', requireSession, async (req, res) => { + const customerId = getSessionCustomerId(req) + try { + const queries = [ + Query.equal('customerId', customerId), + Query.orderAsc('createdAt'), + Query.limit(200), + ] + const since = String(req.query.since || '').trim() + if (since) queries.push(Query.greaterThan('createdAt', since)) + + const docs = await listDocuments(config.collections.portalMessages, queries) + const unread = await countUnreadForCustomer(customerId) + return res.json({ messages: docs.map(sanitizeMessage), unread }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Nachrichten konnten nicht geladen werden' }) + } +}) + +/** Nachricht des Kunden an die Administratoren. */ +router.post('/messages', requireSession, async (req, res) => { + if (req.session.viewAs) { + return res.status(403).json({ error: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.' }) + } + + const text = String(req.body?.text || '').trim() + if (!text || text.length > 2000) { + return res.status(400).json({ error: 'Nachricht muss zwischen 1 und 2000 Zeichen lang sein.' }) + } + + const customerId = getSessionCustomerId(req) + try { + const doc = await createDocument( + config.collections.portalMessages, + { + customerId, + sender: 'customer', + senderName: req.session.name || '', + text, + readByAdmin: false, + readByCustomer: true, + createdAt: new Date().toISOString(), + }, + ID.unique() + ) + + // Admins benachrichtigen (gedrosselt, Fehler nicht durchreichen) + getDocument(config.collections.customers, customerId) + .then((customer) => sendChatNotification(customer, text)) + .catch(() => {}) + + return res.status(201).json({ message: sanitizeMessage(doc) }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' }) + } +}) + +/** Admin-Nachrichten als gelesen markieren. */ +router.post('/read', requireSession, async (req, res) => { + const customerId = getSessionCustomerId(req) + try { + const docs = await listDocuments(config.collections.portalMessages, [ + Query.equal('customerId', customerId), + Query.equal('readByCustomer', false), + Query.equal('sender', 'admin'), + Query.limit(100), + ]) + for (const doc of docs) { + await updateDocument(config.collections.portalMessages, doc.$id, { readByCustomer: true }) + } + return res.json({ updated: docs.length }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Fehler beim Markieren' }) + } +}) + +/** Billiger Badge-Poll. */ +router.get('/unread-count', requireSession, async (req, res) => { + try { + const unread = await countUnreadForCustomer(getSessionCustomerId(req)) + return res.json({ unread }) + } catch { + return res.json({ unread: 0 }) + } +}) + +export default router diff --git a/server/routes/customerData.js b/server/routes/customerData.js index b561c54..2cc4a10 100644 --- a/server/routes/customerData.js +++ b/server/routes/customerData.js @@ -3,7 +3,7 @@ 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' +import { fetchInvoicePdf, getInvoiceRaw, listInvoicesForClient } from '../services/invoiceNinja.js' const router = Router() @@ -27,6 +27,40 @@ router.get('/invoices', requireSession, async (req, res) => { } }) +/** Rechnungs-PDF des eingeloggten Kunden (Download ueber Server-Proxy). */ +router.get('/invoices/:invoiceId/pdf', requireSession, async (req, res) => { + 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) + res.writeHead(200, { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="Rechnung-${invoice.number || invoice.id}.pdf"`, + 'Cache-Control': 'no-store', + }) + return res.end(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 { diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js index 86b36ea..6328d0b 100644 --- a/server/routes/portalAdmin.js +++ b/server/routes/portalAdmin.js @@ -6,13 +6,16 @@ import { setPortalSession, } from '../middleware/session.js' import { + createDocument, getDocument, listDocuments, updateDocument, Query, + ID, } from '../services/appwriteAdmin.js' import { getCustomerActivity } from '../services/customerActivity.js' import { listInvoicesForClient } from '../services/invoiceNinja.js' +import { sanitizeMessage } from './chat.js' const router = Router() @@ -222,6 +225,120 @@ router.get('/customers/:customerId/invoices', async (req, res) => { } }) +// ------------------------------------------------------------------- Chat -- +router.get('/chats', async (_req, res) => { + try { + const [messages, customers] = await Promise.all([ + listDocuments(config.collections.portalMessages, [ + Query.orderDesc('createdAt'), + Query.limit(500), + ]), + listDocuments(config.collections.customers, [Query.limit(500)]), + ]) + const nameById = customerNameMap(customers) + + const byCustomer = new Map() + for (const msg of messages) { + const entry = byCustomer.get(msg.customerId) || { + customerId: msg.customerId, + customerName: nameById[msg.customerId] || msg.customerId, + lastText: msg.text || '', + lastSender: msg.sender || '', + lastAt: msg.createdAt || '', + unreadFromCustomer: 0, + } + if (msg.sender === 'customer' && !msg.readByAdmin) entry.unreadFromCustomer += 1 + byCustomer.set(msg.customerId, entry) + } + + const chats = [...byCustomer.values()].sort((a, b) => + String(b.lastAt).localeCompare(String(a.lastAt)) + ) + return res.json({ chats }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Chats konnten nicht geladen werden' }) + } +}) + +router.get('/chats/unread-count', async (_req, res) => { + try { + const docs = await listDocuments(config.collections.portalMessages, [ + Query.equal('readByAdmin', false), + Query.equal('sender', 'customer'), + Query.limit(100), + ]) + return res.json({ unread: docs.length }) + } catch { + return res.json({ unread: 0 }) + } +}) + +router.get('/chats/:customerId/messages', async (req, res) => { + try { + const queries = [ + Query.equal('customerId', req.params.customerId), + Query.orderAsc('createdAt'), + Query.limit(200), + ] + const since = String(req.query.since || '').trim() + if (since) queries.push(Query.greaterThan('createdAt', since)) + + const docs = await listDocuments(config.collections.portalMessages, queries) + return res.json({ messages: docs.map(sanitizeMessage) }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Nachrichten konnten nicht geladen werden' }) + } +}) + +router.post('/chats/:customerId/messages', async (req, res) => { + const text = String(req.body?.text || '').trim() + if (!text || text.length > 2000) { + return res.status(400).json({ error: 'Nachricht muss zwischen 1 und 2000 Zeichen lang sein.' }) + } + + 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 + const doc = await createDocument( + config.collections.portalMessages, + { + customerId: customer.$id, + sender: 'admin', + senderName: session.adminName || session.name || 'Webklar-Team', + text, + readByAdmin: true, + readByCustomer: false, + createdAt: new Date().toISOString(), + }, + ID.unique() + ) + return res.status(201).json({ message: sanitizeMessage(doc) }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' }) + } +}) + +router.post('/chats/:customerId/read', async (req, res) => { + try { + const docs = await listDocuments(config.collections.portalMessages, [ + Query.equal('customerId', req.params.customerId), + Query.equal('readByAdmin', false), + Query.equal('sender', 'customer'), + Query.limit(100), + ]) + for (const doc of docs) { + await updateDocument(config.collections.portalMessages, doc.$id, { readByAdmin: true }) + } + return res.json({ updated: docs.length }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Fehler beim Markieren' }) + } +}) + // ------------------------------------------------------- Als Kunde ansehen -- router.post('/view-as/:customerId', async (req, res) => { try { diff --git a/server/routes/projects.js b/server/routes/projects.js index a858f15..3abef4e 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -1,10 +1,25 @@ import { Router } from 'express' import { config } from '../config.js' -import { listDocuments, Query } from '../services/appwriteAdmin.js' +import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { getProjectGitInsights, getProjectFileOverview } from '../services/projectInsights.js' const router = Router() +/** + * Laedt das Projekt und stellt sicher, dass es dem eingeloggten Kunden gehoert. + * Fremde/unbekannte IDs antworten einheitlich mit 404 (kein Enumeration-Leak). + */ +async function loadOwnProject(req, res, next) { + const customerId = getSessionCustomerId(req) + const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null) + if (!project || !customerId || project.customerId !== customerId) { + return res.status(404).json({ error: 'Projekt nicht gefunden' }) + } + req.project = project + return next() +} + router.get('/', requireSession, async (req, res) => { const customerId = getSessionCustomerId(req) if (!customerId) { @@ -36,4 +51,64 @@ router.get('/', requireSession, async (req, res) => { } }) +/** Letzte Git-Pushes + Projektgroesse. */ +router.get('/:id/git', requireSession, loadOwnProject, async (req, res) => { + try { + const { repo, commits } = await getProjectGitInsights(req.project) + return res.json({ repo, commits }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Git-Daten konnten nicht geladen werden' }) + } +}) + +/** Datei-Uebersicht (Top-Level, Groessensummen). */ +router.get('/:id/files', requireSession, loadOwnProject, async (req, res) => { + try { + const summary = await getProjectFileOverview(req.project) + if (!summary) return res.json({ totalFiles: 0, totalSizeBytes: 0, truncated: false, topLevel: [] }) + return res.json(summary) + } catch (err) { + return res.status(500).json({ error: err.message || 'Datei-Uebersicht konnte nicht geladen werden' }) + } +}) + +/** Ticket-Arbeiten am Projekt (inkl. Git-Push-Worksheets, "Zeit offen" markiert). */ +router.get('/:id/work', requireSession, loadOwnProject, async (req, res) => { + try { + const project = req.project + if (!project.ticketId) { + return res.json({ ticket: null, worksheets: [] }) + } + + const ticket = await getDocument(config.collections.workorders, project.ticketId).catch(() => null) + const worksheets = await listDocuments(config.collections.worksheets, [ + Query.equal('workorderId', project.ticketId), + Query.orderDesc('$createdAt'), + Query.limit(50), + ]).catch(() => []) + + const sanitized = worksheets + // GIT-Eintraege einschliessen, sonstige reine Kommentare ausblenden + .filter((ws) => ws.serviceType === 'GIT' || !ws.isComment) + .map((ws) => ({ + wsid: ws.wsid || '', + date: ws.endDate || ws.startDate || '', + time: ws.endTime || ws.startTime || '', + employeeName: ws.employeeName || ws.employeeShort || '', + serviceType: ws.serviceType || '', + details: ws.details || '', + totalTime: Number(ws.totalTime || 0), + isGit: ws.serviceType === 'GIT', + pending: ws.serviceType === 'GIT' && !ws.startTime, + })) + + return res.json({ + ticket: ticket ? { woid: ticket.woid || '', topic: ticket.topic || '', status: ticket.status || '' } : null, + worksheets: sanitized, + }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Arbeiten konnten nicht geladen werden' }) + } +}) + export default router diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index bf214cc..63dc5b1 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -9,6 +9,8 @@ import { buildPreviewUrl, } from '../../services/previewDeploy.js' import { createGitPushWorksheet } from '../../services/gitWorksheet.js' +import { ensureProjectTicket } from '../../services/ticketAdmin.js' +import { invalidateProjectCache } from '../../services/projectInsights.js' const router = Router() @@ -93,17 +95,29 @@ router.post('/gitea', async (req, res) => { } const project = await upsertWebsiteProjectByRepo(repoFullName, projectPayload) + invalidateProjectCache(repoFullName) let gitWorksheet = null - if (project.ticketId && commitSha && commitSha !== '0000000000000000000000000000000000000000') { - gitWorksheet = await createGitPushWorksheet({ - ticketId: project.ticketId, - repoFullName, - branch, - commits, - pusher, - commitSha, - }) + if (commitSha && commitSha !== '0000000000000000000000000000000000000000') { + // Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde) + let ticketId = project.ticketId + if (!ticketId) { + try { + ticketId = await ensureProjectTicket(project, { repoFullName }) + } catch (err) { + console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message) + } + } + if (ticketId) { + gitWorksheet = await createGitPushWorksheet({ + ticketId, + repoFullName, + branch, + commits, + pusher, + commitSha, + }) + } } return res.json({ diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index a777002..2e04bdc 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -75,6 +75,7 @@ export const Query = { values: Array.isArray(value) ? value : [value], }), limit: (n) => ({ method: 'limit', values: [n] }), + greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }), orderDesc: (attribute) => ({ method: 'orderDesc', attribute }), orderAsc: (attribute) => ({ method: 'orderAsc', attribute }), } diff --git a/server/services/customerActivity.js b/server/services/customerActivity.js index 4e9e8cc..afc088a 100644 --- a/server/services/customerActivity.js +++ b/server/services/customerActivity.js @@ -31,7 +31,8 @@ export async function getCustomerActivity(customerId, { ticketLimit = 25, exclud const sheetsByWorkorder = {} for (const ws of worksheets) { - if (ws.isComment) continue + // Git-Push-Eintraege dem Kunden sofort zeigen, sonstige reine Kommentare nicht + if (ws.isComment && ws.serviceType !== 'GIT') continue const list = (sheetsByWorkorder[ws.workorderId] ||= []) list.push({ wsid: ws.wsid || '', @@ -41,6 +42,8 @@ export async function getCustomerActivity(customerId, { ticketLimit = 25, exclud totalTime: ws.totalTime || '', date: ws.endDate || ws.startDate || '', details: ws.details || '', + isGit: ws.serviceType === 'GIT', + pending: ws.serviceType === 'GIT' && !ws.startTime, createdAt: ws.createdAt || ws.$createdAt || '', }) } diff --git a/server/services/gitWorksheet.js b/server/services/gitWorksheet.js index 6261e75..066481c 100644 --- a/server/services/gitWorksheet.js +++ b/server/services/gitWorksheet.js @@ -8,13 +8,15 @@ import { } from './appwriteAdmin.js' const WSID_START = 100000 +// worksheets.details ist auf 4000 Zeichen begrenzt +const DETAILS_MAX_LENGTH = 3900 function formatToday() { + // dd.mm.yyyy - gleiches Format wie CreateWorksheetModal der Ticketsystem-SPA const d = new Date() - const y = d.getFullYear() - const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') - return `${y}${m}${day}` + const m = String(d.getMonth() + 1).padStart(2, '0') + return `${day}.${m}.${d.getFullYear()}` } function formatTimeNow() { @@ -49,10 +51,16 @@ function formatCommitLines(commits, repoFullName) { return commits .map((commit) => { const sha = (commit.id || commit.sha || '').slice(0, 7) - const message = (commit.message || '').split('\n')[0] + // Commit-Titel + Beschreibung (Body) uebernehmen + const [title, ...bodyLines] = String(commit.message || '').trim().split('\n') const author = commit.author?.name || commit.committer?.name || 'Unbekannt' const url = commit.url || `${config.gitea.baseUrl}/${repoFullName}/commit/${commit.id || commit.sha}` - return `- [${sha}](${url}): ${message} (${author})` + const body = bodyLines + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => ` ${line}`) + .join('\n') + return `- [${sha}](${url}): ${title} (${author})${body ? `\n${body}` : ''}` }) .join('\n') } @@ -85,13 +93,18 @@ export async function createGitPushWorksheet({ const wsid = await generateNextWsid() const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea' const latestCommit = commits[commits.length - 1] || commits[0] - const details = [ + let details = [ `Git Push auf \`${repoFullName}\` (Branch: ${branch})`, `Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`, '', formatCommitLines(commits, repoFullName), ].join('\n') + if (details.length > DETAILS_MAX_LENGTH) { + details = `${details.slice(0, DETAILS_MAX_LENGTH)}\n… (gekuerzt)` + } + // Endzeit = Push-Zeitpunkt. Startzeit bleibt leer und wird im Ticketsystem + // nachgetragen ("Zeit nachtragen") - erst dann zaehlt die Arbeitszeit. const today = formatToday() const worksheet = await createDocument( config.collections.worksheets, @@ -109,7 +122,7 @@ export async function createGitPushWorksheet({ newResponseLevel: workorder.response || '', totalTime: 0, startDate: today, - startTime: formatTimeNow(), + startTime: '', endDate: today, endTime: formatTimeNow(), details, diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index bd38629..b98c2b3 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -300,6 +300,94 @@ export async function createProjectFromTemplate({ } } +/** Repo-Basisdaten (Groesse in KB, Default-Branch). */ +export async function getRepoInfo(repoFullName) { + const [owner, repo] = String(repoFullName || '').split('/') + if (!owner || !repo) return null + const data = await giteaFetch(`/repos/${owner}/${repo}`) + return { + sizeKb: Number(data.size || 0), + defaultBranch: data.default_branch || 'main', + updatedAt: data.updated_at || '', + htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}`, + } +} + +/** Letzte Commits eines Repos (volle Message inkl. Body). */ +export async function listRepoCommits(repoFullName, { branch = 'main', limit = 20 } = {}) { + const [owner, repo] = String(repoFullName || '').split('/') + if (!owner || !repo) return [] + // stat/verification/files abschalten - sonst berechnet Gitea Diff-Stats pro Commit + const data = await giteaFetch( + `/repos/${owner}/${repo}/commits?sha=${encodeURIComponent(branch)}&limit=${limit}&stat=false&verification=false&files=false` + ) + return (Array.isArray(data) ? data : []).map((entry) => ({ + sha: entry.sha || '', + shortSha: (entry.sha || '').slice(0, 7), + message: entry.commit?.message || '', + authorName: + entry.commit?.author?.name || entry.author?.login || entry.committer?.login || 'Unbekannt', + date: entry.commit?.author?.date || entry.created || '', + url: entry.html_url || `${config.gitea.baseUrl}/${repoFullName}/commit/${entry.sha}`, + })) +} + +/** + * Datei-Uebersicht: aggregiert den Git-Baum serverseitig nach Top-Level-Eintraegen + * (nie den rohen Baum ans Frontend geben - kann tausende Eintraege haben). + */ +export async function getRepoTreeSummary(repoFullName, ref = 'main') { + const [owner, repo] = String(repoFullName || '').split('/') + if (!owner || !repo) return null + const data = await giteaFetch( + `/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=1000` + ) + + const entries = Array.isArray(data?.tree) ? data.tree : [] + const topLevelMap = new Map() + let totalFiles = 0 + let totalSizeBytes = 0 + + for (const entry of entries) { + const isBlob = entry.type === 'blob' + const size = isBlob ? Number(entry.size || 0) : 0 + const path = String(entry.path || '') + const slash = path.indexOf('/') + const topSegment = slash === -1 ? path : path.slice(0, slash) + const isDir = slash !== -1 || entry.type === 'tree' + + if (isBlob) { + totalFiles += 1 + totalSizeBytes += size + } + + if (!topSegment) continue + const existing = topLevelMap.get(topSegment) || { + path: topSegment, + type: isDir ? 'dir' : 'file', + fileCount: 0, + sizeBytes: 0, + } + if (isDir) existing.type = 'dir' + if (isBlob) { + existing.fileCount += 1 + existing.sizeBytes += size + } + topLevelMap.set(topSegment, existing) + } + + const topLevel = [...topLevelMap.values()] + .sort((a, b) => b.sizeBytes - a.sizeBytes) + .slice(0, 50) + + return { + totalFiles, + totalSizeBytes, + truncated: Boolean(data?.truncated), + topLevel, + } +} + const README_CANDIDATES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README'] export async function fetchRepoReadme(repoFullName) { diff --git a/server/services/invoiceNinja.js b/server/services/invoiceNinja.js index 7c722f6..40e5d9d 100644 --- a/server/services/invoiceNinja.js +++ b/server/services/invoiceNinja.js @@ -68,6 +68,53 @@ export function sanitizeInvoice(inv) { } } +/** Rohe Rechnung inkl. Invitations (fuer PDF-Download + Ownership-Pruefung). */ +export async function getInvoiceRaw(invoiceId) { + const token = config.invoiceNinja.apiToken + if (!token) { + const error = new Error('INVOICE_NINJA_API_TOKEN ist nicht konfiguriert') + error.status = 503 + throw error + } + const response = await fetch( + `${config.invoiceNinja.url}/api/v1/invoices/${encodeURIComponent(invoiceId)}?include=invitations`, + { + 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 || 'Rechnung nicht gefunden') + error.status = response.status === 401 ? 502 : response.status + throw error + } + return data.data +} + +/** PDF einer Rechnung ueber den Client-Portal-Invitation-Link streamen. */ +export async function fetchInvoicePdf(invitationKey) { + const token = config.invoiceNinja.apiToken + const response = await fetch( + `${config.invoiceNinja.url}/client/invoice/${encodeURIComponent(invitationKey)}/download_pdf`, + { + headers: { + 'X-Api-Token': token, + 'X-Requested-With': 'XMLHttpRequest', + }, + } + ) + if (!response.ok) { + const error = new Error('PDF-Download fehlgeschlagen') + error.status = response.status + throw error + } + return Buffer.from(await response.arrayBuffer()) +} + /** Rechnungen eines InvoiceNinja-Clients (fuer Kundenportal + Admin-Ansicht). */ export async function listInvoicesForClient(clientId, { includeDrafts = false } = {}) { const token = config.invoiceNinja.apiToken diff --git a/server/services/mailer.js b/server/services/mailer.js new file mode 100644 index 0000000..a53aeeb --- /dev/null +++ b/server/services/mailer.js @@ -0,0 +1,56 @@ +import nodemailer from 'nodemailer' +import { config } from '../config.js' + +let transporter = null + +function getTransporter() { + if (!config.smtp.host || !config.smtp.user) return null + if (!transporter) { + transporter = nodemailer.createTransport({ + host: config.smtp.host, + port: config.smtp.port, + secure: config.smtp.secure, + auth: { user: config.smtp.user, pass: config.smtp.pass }, + }) + } + return transporter +} + +// Throttle: pro Kunde maximal eine Benachrichtigung je Zeitfenster, +// damit ein aktiver Chat nicht pro Nachricht eine Mail ausloest. +const lastNotifyByCustomer = new Map() // customerId -> timestamp + +/** + * Benachrichtigt die Admins per E-Mail ueber eine neue Portal-Chatnachricht. + * Fehler werden nur geloggt - der Chat darf am Mailversand nie scheitern. + */ +export async function sendChatNotification(customer, text) { + try { + const mailer = getTransporter() + if (!mailer || !config.chatNotify.emails.length) return { sent: false, reason: 'nicht konfiguriert' } + + const now = Date.now() + const last = lastNotifyByCustomer.get(customer.$id) || 0 + const throttleMs = config.chatNotify.throttleMin * 60 * 1000 + if (now - last < throttleMs) return { sent: false, reason: 'throttled' } + lastNotifyByCustomer.set(customer.$id, now) + + const name = customer.name || customer.email || 'Kunde' + await mailer.sendMail({ + from: `"Webklar Kundenportal" <${config.smtp.from}>`, + to: config.chatNotify.emails.join(', '), + subject: `Neue Portalnachricht von ${name}`, + text: [ + `${name} hat im Kundenportal geschrieben:`, + '', + text, + '', + 'Antworten: https://project.webklar.com/admin.html (Tab Chat)', + ].join('\n'), + }) + return { sent: true } + } catch (err) { + console.error('[mailer] Chat-Benachrichtigung fehlgeschlagen:', err.message) + return { sent: false, reason: err.message } + } +} diff --git a/server/services/projectInsights.js b/server/services/projectInsights.js new file mode 100644 index 0000000..9d1e415 --- /dev/null +++ b/server/services/projectInsights.js @@ -0,0 +1,47 @@ +import { getRepoInfo, listRepoCommits, getRepoTreeSummary } from './giteaAdmin.js' + +// In-Memory-Cache fuer Gitea-Abfragen (Commits/Groesse/Dateibaum) pro Repo. +// Wird vom Gitea-Webhook nach jedem Push invalidiert. +const cache = new Map() // key: `${repoFullName}:${kind}` -> { data, expiresAt } +const TTL_MS = 5 * 60 * 1000 + +async function cached(repoFullName, kind, loader) { + const key = `${repoFullName}:${kind}` + const hit = cache.get(key) + if (hit && hit.expiresAt > Date.now()) return hit.data + const data = await loader() + cache.set(key, { data, expiresAt: Date.now() + TTL_MS }) + return data +} + +export function invalidateProjectCache(repoFullName) { + for (const key of cache.keys()) { + if (key.startsWith(`${repoFullName}:`)) cache.delete(key) + } +} + +/** Repo-Info + letzte Commits fuer die Projekt-Detailansicht im Kundenportal. */ +export async function getProjectGitInsights(project) { + const repoFullName = project?.repoFullName + if (!repoFullName) return { repo: null, commits: [] } + + return cached(repoFullName, 'git', async () => { + const repo = await getRepoInfo(repoFullName) + const commits = await listRepoCommits(repoFullName, { + branch: repo?.defaultBranch || 'main', + limit: 20, + }) + return { repo, commits } + }) +} + +/** Aggregierte Datei-Uebersicht (Top-Level + Groessensummen). */ +export async function getProjectFileOverview(project) { + const repoFullName = project?.repoFullName + if (!repoFullName) return null + + return cached(repoFullName, 'files', async () => { + const repo = await getRepoInfo(repoFullName) + return getRepoTreeSummary(repoFullName, repo?.defaultBranch || 'main') + }) +} diff --git a/server/services/ticketAdmin.js b/server/services/ticketAdmin.js new file mode 100644 index 0000000..65b749d --- /dev/null +++ b/server/services/ticketAdmin.js @@ -0,0 +1,65 @@ +import { config } from '../config.js' +import { + createDocument, + getDocument, + listDocuments, + updateDocument, + Query, + ID, +} from './appwriteAdmin.js' + +const WOID_BASE = 9999 + +async function generateNextWoid() { + const docs = await listDocuments(config.collections.workorders, [ + Query.orderDesc('woid'), + Query.limit(1), + ]) + const highest = docs[0]?.woid ? parseInt(docs[0].woid, 10) : WOID_BASE + return String(Math.max(WOID_BASE, highest) + 1) +} + +/** + * Stellt sicher, dass ein Website-Projekt ein Ticket hat, damit Git-Pushes + * als Worksheets (WSIDs) getrackt werden koennen. Legt bei Bedarf automatisch + * ein 'Webpage'-Ticket fuer den Kunden des Projekts an und verknuepft es. + * Projekte ohne Kunde bekommen kein automatisches Ticket. + */ +export async function ensureProjectTicket(project, { repoFullName } = {}) { + if (!project) return null + if (project.ticketId) return project.ticketId + if (!project.customerId) return null + + const customer = await getDocument(config.collections.customers, project.customerId).catch(() => null) + if (!customer) return null + + const woid = await generateNextWoid() + const now = new Date().toISOString() + + const ticket = await createDocument( + config.collections.workorders, + { + topic: `Website ${project.projectName || repoFullName || project.subdomain || ''}`.trim(), + type: 'Webpage', + status: 'Open', + priority: 1, + serviceType: 'Remote', + systemType: 'n/a', + customerId: project.customerId, + customerName: customer.name || '', + customerLocation: customer.location || '', + woid, + details: `Automatisch angelegt fuer das Repository ${repoFullName || project.repoFullName || ''} (erster Git-Push).`, + createdAt: now, + }, + ID.unique() + ) + + await updateDocument(config.collections.websiteProjects, project.$id, { + ticketId: ticket.$id, + updatedAt: now, + }) + + console.log(`[ticketAdmin] Webpage-Ticket #${woid} fuer Projekt ${project.$id} angelegt`) + return ticket.$id +}