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.
+
+
+
+
+
+
+
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)' : ''}
+
+ | Ordner / Datei | Dateien | Größe |
+ ${rows}
+
`
+}
+
+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 = `
| Nr. | Datum | Betrag | Offen | Status | |
@@ -269,24 +384,29 @@ async function loadCustomerInvoices() {
${fmtDateShort(i.date)} |
${fmtEuro(i.amount)} |
${fmtEuro(i.balance)} |
- ${escapeHtml(i.status)} |
- ${i.portalUrl ? `Rechnung ansehen` : ''} |
+ ${escapeHtml(i.status)} |
+
+ ${i.portalUrl ? `Ansehen / Zahlen · ` : ''}
+ PDF
+ |
`).join('')}
`
- } 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…
-
- Noch keine Website-Projekte zugewiesen.
-
- Meine Rechnungen
-
+
+
+
+
+
+
+
+
+
+ Projekte werden geladen…
+
+ Noch keine Website-Projekte zugewiesen.
-
+
+
+
+
+
+
+
+
+ Chat mit dem Webklar-Team
+
+
+
+
+
+
+
Enter = Senden, Shift+Enter = Zeilenumbruch. Wir antworten in der Regel innerhalb eines Werktags.
+
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
+}