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 <noreply@anthropic.com>
This commit is contained in:
12
package-lock.json
generated
12
package-lock.json
generated
@@ -12,7 +12,8 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jose": "^6.0.11",
|
"jose": "^6.0.11",
|
||||||
"node-appwrite": "^14.1.0"
|
"node-appwrite": "^14.1.0",
|
||||||
|
"nodemailer": "^9.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -581,6 +582,15 @@
|
|||||||
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==",
|
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jose": "^6.0.11",
|
"jose": "^6.0.11",
|
||||||
"node-appwrite": "^14.1.0"
|
"node-appwrite": "^14.1.0",
|
||||||
|
"nodemailer": "^9.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,0.07); font-size: 14px; }
|
||||||
.recent-list li:last-child { border-bottom: none; }
|
.recent-list li:last-child { border-bottom: none; }
|
||||||
.two-col { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="portal-page dashboard-page">
|
<body class="portal-page dashboard-page">
|
||||||
@@ -73,6 +100,7 @@
|
|||||||
<button class="admin-tab active" data-tab="overview">Übersicht</button>
|
<button class="admin-tab active" data-tab="overview">Übersicht</button>
|
||||||
<button class="admin-tab" data-tab="projects">Projekte</button>
|
<button class="admin-tab" data-tab="projects">Projekte</button>
|
||||||
<button class="admin-tab" data-tab="customers">Kunden</button>
|
<button class="admin-tab" data-tab="customers">Kunden</button>
|
||||||
|
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Übersicht -->
|
<!-- Übersicht -->
|
||||||
@@ -119,6 +147,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="admin-note">Kundennummer direkt im Feld ändern und mit ✓ speichern. „Als Kunde ansehen“ zeigt dir das Portal genau so, wie der Kunde es sieht.</p>
|
<p class="admin-note">Kundennummer direkt im Feld ändern und mit ✓ speichern. „Als Kunde ansehen“ zeigt dir das Portal genau so, wie der Kunde es sieht.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Chat -->
|
||||||
|
<section class="admin-section" id="tab-chat">
|
||||||
|
<div class="chat-layout">
|
||||||
|
<div class="chat-list" id="admin-chat-list">
|
||||||
|
<div class="chat-list-item"><span class="muted">Lade Unterhaltungen…</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-thread" id="admin-chat-thread">
|
||||||
|
<div class="chat-empty">Unterhaltung links auswählen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/admin.js"></script>
|
<script src="/admin.js"></script>
|
||||||
|
|||||||
148
public/admin.js
148
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 = '<div class="chat-list-item"><span class="muted">Noch keine Unterhaltungen.</span></div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list.innerHTML = chats.map((c) => `
|
||||||
|
<div class="chat-list-item${c.customerId === adminChat.activeCustomerId ? ' active' : ''}" data-chat-customer="${escapeHtml(c.customerId)}" data-chat-name="${escapeHtml(c.customerName)}">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
||||||
|
<strong>${escapeHtml(c.customerName)}</strong>
|
||||||
|
${c.unreadFromCustomer ? `<span class="badge-pill">${c.unreadFromCustomer}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="last">${c.lastSender === 'admin' ? 'Du: ' : ''}${escapeHtml(c.lastText)}</div>
|
||||||
|
<div class="last">${fmtDateTime(c.lastAt)}</div>
|
||||||
|
</div>`).join('')
|
||||||
|
list.querySelectorAll('[data-chat-customer]').forEach((el) =>
|
||||||
|
el.addEventListener('click', () => openChatThread(el.dataset.chatCustomer, el.dataset.chatName)))
|
||||||
|
} catch (err) {
|
||||||
|
list.innerHTML = `<div class="chat-list-item"><span class="error">${escapeHtml(err.message)}</span></div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}<span class="chat-meta">${msg.sender === 'admin' ? escapeHtml(msg.senderName || 'Admin') : escapeHtml(adminChat.activeCustomerName)} · ${fmtDateTime(msg.createdAt)}</span>`
|
||||||
|
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 = `
|
||||||
|
<div style="margin-bottom:8px"><strong>${escapeHtml(adminChat.activeCustomerName)}</strong></div>
|
||||||
|
<div class="chat-messages" id="admin-chat-messages"></div>
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<textarea id="admin-chat-input" maxlength="2000" placeholder="Antwort an ${escapeHtml(adminChat.activeCustomerName)}…"></textarea>
|
||||||
|
<button type="button" class="mini-btn primary" id="admin-chat-send" style="padding:12px 18px">Senden</button>
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
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 = `<span class="error">${escapeHtml(err.message)}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(adminChat.threadTimer)
|
||||||
|
adminChat.threadTimer = setInterval(pollAdminThread, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------- Init --- */
|
/* ----------------------------------------------------------------- Init --- */
|
||||||
async function initAdminPage() {
|
async function initAdminPage() {
|
||||||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||||
@@ -273,12 +412,17 @@ async function initAdminPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let chatListLoaded = false
|
||||||
document.querySelectorAll('.admin-tab').forEach((tab) => {
|
document.querySelectorAll('.admin-tab').forEach((tab) => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active'))
|
document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active'))
|
||||||
document.querySelectorAll('.admin-section').forEach((s) => s.classList.remove('active'))
|
document.querySelectorAll('.admin-section').forEach((s) => s.classList.remove('active'))
|
||||||
tab.classList.add('active')
|
tab.classList.add('active')
|
||||||
document.getElementById(`tab-${tab.dataset.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()
|
loadOverview()
|
||||||
loadProjects()
|
loadProjects()
|
||||||
loadCustomers()
|
loadCustomers()
|
||||||
|
|
||||||
|
// Chat-Badge global pollen
|
||||||
|
pollAdminChatBadge()
|
||||||
|
setInterval(pollAdminChatBadge, 60000)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.initAdminPage = initAdminPage
|
window.initAdminPage = initAdminPage
|
||||||
|
|||||||
399
public/app.js
399
public/app.js
@@ -28,34 +28,8 @@ function hideError(el) {
|
|||||||
if (el) el.classList.add('hidden')
|
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
|
|
||||||
? `<div class="feature-tags">${projectFeatures.map((f) => `<span class="feature-tag">${escapeHtml(f.featureKey)}</span>`).join('')}</div>`
|
|
||||||
: '<p class="muted">Keine zusätzlichen Features freigeschaltet.</p>'
|
|
||||||
|
|
||||||
li.innerHTML = `
|
|
||||||
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
|
|
||||||
<dl>
|
|
||||||
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '–')}</dd>
|
|
||||||
<dt>Vorschau</dt><dd>${project.previewUrl ? `<a class="btn btn-green" href="${escapeAttr(project.previewUrl)}">Projekt-Webseite öffnen</a>` : '–'}</dd>
|
|
||||||
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}</dd>
|
|
||||||
<dt>Status</dt><dd>${escapeHtml(project.status || '–')}</dd>
|
|
||||||
<dt>Bereitstellung</dt><dd>${escapeHtml(project.provisioningStatus || '–')}</dd>
|
|
||||||
</dl>
|
|
||||||
${featureHtml}
|
|
||||||
`
|
|
||||||
return li
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
return String(str)
|
return String(str ?? '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
@@ -70,6 +44,161 @@ function getQueryParam(name) {
|
|||||||
return new URLSearchParams(window.location.search).get(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
|
||||||
|
? `<div class="feature-tags">${projectFeatures.map((f) => `<span class="feature-tag">${escapeHtml(f.featureKey)}</span>`).join('')}</div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
li.innerHTML = `
|
||||||
|
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '–')}</dd>
|
||||||
|
<dt>Vorschau</dt><dd>${project.previewUrl ? `<a class="btn btn-green" href="${escapeAttr(project.previewUrl)}">Projekt-Webseite öffnen</a>` : '–'}</dd>
|
||||||
|
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}</dd>
|
||||||
|
<dt>Status</dt><dd>${escapeHtml(project.status || '–')}</dd>
|
||||||
|
</dl>
|
||||||
|
${featureHtml}
|
||||||
|
<div class="project-details-toggle">
|
||||||
|
<button type="button" class="portal-btn-outline" data-details-btn>Details anzeigen</button>
|
||||||
|
</div>
|
||||||
|
<div class="project-details hidden" data-details-box></div>
|
||||||
|
`
|
||||||
|
|
||||||
|
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
|
||||||
|
? `<p class="muted">Projektgröße: ${fmtBytes((git.repo.sizeKb || 0) * 1024)} · Branch: ${escapeHtml(git.repo.defaultBranch || 'main')}</p>`
|
||||||
|
: ''
|
||||||
|
if (!git.commits || !git.commits.length) {
|
||||||
|
return `${sizeInfo}<p class="muted">Noch keine Änderungen vorhanden.</p>`
|
||||||
|
}
|
||||||
|
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 `
|
||||||
|
<div class="commit-item">
|
||||||
|
<strong>${escapeHtml(title)}</strong>
|
||||||
|
${bodyText ? `<div class="commit-body">${escapeHtml(bodyText)}</div>` : ''}
|
||||||
|
<div class="commit-meta">${escapeHtml(c.shortSha)} · ${escapeHtml(c.authorName)} · ${fmtDateTime(c.date)}</div>
|
||||||
|
</div>`
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileOverview(files) {
|
||||||
|
if (!files || !files.totalFiles) {
|
||||||
|
return '<p class="muted">Keine Datei-Informationen verfügbar.</p>'
|
||||||
|
}
|
||||||
|
const rows = (files.topLevel || []).map((entry) => `
|
||||||
|
<tr>
|
||||||
|
<td>${entry.type === 'dir' ? '📁' : '📄'} ${escapeHtml(entry.path)}</td>
|
||||||
|
<td>${entry.fileCount}</td>
|
||||||
|
<td>${fmtBytes(entry.sizeBytes)}</td>
|
||||||
|
</tr>`).join('')
|
||||||
|
return `
|
||||||
|
<p class="muted">${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}</p>
|
||||||
|
<table class="simple-table">
|
||||||
|
<thead><tr><th>Ordner / Datei</th><th>Dateien</th><th>Größe</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkEntries(work) {
|
||||||
|
if (!work.ticket) {
|
||||||
|
return '<p class="muted">Für dieses Projekt gibt es noch kein Ticket.</p>'
|
||||||
|
}
|
||||||
|
const head = `<p class="muted">Ticket #${escapeHtml(work.ticket.woid)} „${escapeHtml(work.ticket.topic)}" · Status: ${escapeHtml(work.ticket.status)}</p>`
|
||||||
|
if (!work.worksheets.length) {
|
||||||
|
return `${head}<p class="muted">Noch keine dokumentierten Arbeiten.</p>`
|
||||||
|
}
|
||||||
|
return head + work.worksheets.map((ws) => `
|
||||||
|
<div class="activity-item">
|
||||||
|
<div>
|
||||||
|
${ws.isGit ? '<span class="pill pill-git">Git-Push</span>' : `<span class="pill pill-muted">${escapeHtml(ws.serviceType || 'Arbeit')}</span>`}
|
||||||
|
<strong> ${escapeHtml(ws.employeeName)}</strong>
|
||||||
|
<span class="muted"> · ${fmtDateShort(ws.date)}</span>
|
||||||
|
${ws.totalTime ? `<span class="pill pill-ok">${fmtMinutes(ws.totalTime)}</span>` : ''}
|
||||||
|
${ws.pending ? '<span class="pill pill-muted">Zeiterfassung folgt</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="activity-ws">${escapeHtml(ws.details)}</div>
|
||||||
|
</div>`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjectDetails(project, box) {
|
||||||
|
box.innerHTML = '<p class="muted">Lade Details…</p>'
|
||||||
|
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 = `
|
||||||
|
<h3>Letzte Änderungen</h3>
|
||||||
|
${git ? renderCommits(git) : '<p class="muted">Git-Daten derzeit nicht verfügbar.</p>'}
|
||||||
|
<h3>Datei-Übersicht</h3>
|
||||||
|
${files ? renderFileOverview(files) : '<p class="muted">Datei-Übersicht derzeit nicht verfügbar.</p>'}
|
||||||
|
<h3>Arbeiten am Projekt</h3>
|
||||||
|
${work ? renderWorkEntries(work) : '<p class="muted">Keine Ticket-Daten verfügbar.</p>'}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------- Login-Redirects --- */
|
||||||
function safeRedirectUrl(url) {
|
function safeRedirectUrl(url) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
@@ -116,17 +245,6 @@ async function resolvePostLoginTarget() {
|
|||||||
if (next && (await canAccessPreviewUrl(next))) {
|
if (next && (await canAccessPreviewUrl(next))) {
|
||||||
return 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'
|
return '/dashboard.html'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +252,7 @@ async function postLoginRedirect() {
|
|||||||
window.location.href = await resolvePostLoginTarget()
|
window.location.href = await resolvePostLoginTarget()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- Login-Page --- */
|
||||||
async function initLoginPage() {
|
async function initLoginPage() {
|
||||||
const form = document.getElementById('login-form')
|
const form = document.getElementById('login-form')
|
||||||
const errorEl = document.getElementById('login-error')
|
const errorEl = document.getElementById('login-error')
|
||||||
@@ -226,17 +345,7 @@ async function initLoginPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDateShort(value) {
|
/* --------------------------------------------------------------- View-as --- */
|
||||||
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' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderViewAsBanner(me) {
|
function renderViewAsBanner(me) {
|
||||||
const banner = document.getElementById('viewas-banner')
|
const banner = document.getElementById('viewas-banner')
|
||||||
if (!banner || !me.viewAs) return
|
if (!banner || !me.viewAs) return
|
||||||
@@ -251,14 +360,20 @@ function renderViewAsBanner(me) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Rechnungen --- */
|
||||||
async function loadCustomerInvoices() {
|
async function loadCustomerInvoices() {
|
||||||
const section = document.getElementById('invoices-section')
|
|
||||||
const body = document.getElementById('invoices-body')
|
const body = document.getElementById('invoices-body')
|
||||||
if (!section || !body) return
|
if (!body) return
|
||||||
try {
|
try {
|
||||||
const data = await api('/api/invoices')
|
const data = await api('/api/invoices')
|
||||||
if (!data.linked || !data.invoices.length) return
|
if (!data.linked) {
|
||||||
section.classList.remove('hidden')
|
body.innerHTML = '<p class="muted">Für dein Konto sind noch keine Rechnungen hinterlegt.</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!data.invoices.length) {
|
||||||
|
body.innerHTML = '<p class="muted">Es liegen noch keine Rechnungen vor.</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<table class="simple-table">
|
<table class="simple-table">
|
||||||
<thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead>
|
<thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead>
|
||||||
@@ -269,24 +384,29 @@ async function loadCustomerInvoices() {
|
|||||||
<td>${fmtDateShort(i.date)}</td>
|
<td>${fmtDateShort(i.date)}</td>
|
||||||
<td>${fmtEuro(i.amount)}</td>
|
<td>${fmtEuro(i.amount)}</td>
|
||||||
<td>${fmtEuro(i.balance)}</td>
|
<td>${fmtEuro(i.balance)}</td>
|
||||||
<td>${escapeHtml(i.status)}</td>
|
<td><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
|
||||||
<td>${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Rechnung ansehen</a>` : ''}</td>
|
<td style="white-space:nowrap">
|
||||||
|
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen / Zahlen</a> · ` : ''}
|
||||||
|
<a class="btn-link" href="/api/invoices/${escapeAttr(i.id)}/pdf">PDF</a>
|
||||||
|
</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>`
|
</table>`
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* Rechnungen sind optional */
|
body.innerHTML = `<p class="muted">Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}</p>`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- Aktivität --- */
|
||||||
async function loadCustomerActivity() {
|
async function loadCustomerActivity() {
|
||||||
const section = document.getElementById('activity-section')
|
|
||||||
const body = document.getElementById('activity-body')
|
const body = document.getElementById('activity-body')
|
||||||
if (!section || !body) return
|
if (!body) return
|
||||||
try {
|
try {
|
||||||
const { tickets } = await api('/api/activity')
|
const { tickets } = await api('/api/activity')
|
||||||
if (!tickets || !tickets.length) return
|
if (!tickets || !tickets.length) {
|
||||||
section.classList.remove('hidden')
|
body.innerHTML = '<p class="muted">Es wurden noch keine Arbeiten dokumentiert.</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
body.innerHTML = tickets.map((t) => `
|
body.innerHTML = tickets.map((t) => `
|
||||||
<div class="activity-item">
|
<div class="activity-item">
|
||||||
<div>
|
<div>
|
||||||
@@ -295,14 +415,134 @@ async function loadCustomerActivity() {
|
|||||||
<span class="muted"> · ${fmtDateShort(t.createdAt)}</span>
|
<span class="muted"> · ${fmtDateShort(t.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
${(t.worksheets || []).map((w) => `
|
${(t.worksheets || []).map((w) => `
|
||||||
<div class="activity-ws">→ ${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))}` : ''}</div>
|
<div style="margin: 6px 0 0 14px;">
|
||||||
|
${w.isGit ? '<span class="pill pill-git">Git-Push</span>' : `<span class="pill pill-muted">${escapeHtml(w.serviceType || 'Arbeit')}</span>`}
|
||||||
|
${w.employeeName ? ` <strong>${escapeHtml(w.employeeName)}</strong>` : ''}
|
||||||
|
<span class="muted"> · ${fmtDateShort(w.date)}</span>
|
||||||
|
${Number(w.totalTime) ? `<span class="pill pill-ok">${fmtMinutes(w.totalTime)}</span>` : ''}
|
||||||
|
${w.pending ? '<span class="pill pill-muted">Zeiterfassung folgt</span>' : ''}
|
||||||
|
${w.details ? `<div class="activity-ws">${escapeHtml(w.details)}</div>` : ''}
|
||||||
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>`).join('')
|
</div>`).join('')
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* Aktivität ist optional */
|
body.innerHTML = `<p class="muted">Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}</p>`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ 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)}<span class="chat-meta">${msg.sender === 'customer' ? 'Du' : escapeHtml(msg.senderName || 'Webklar-Team')} · ${fmtDateTime(msg.createdAt)}</span>`
|
||||||
|
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() {
|
async function initDashboardPage() {
|
||||||
const meta = document.getElementById('customer-meta')
|
const meta = document.getElementById('customer-meta')
|
||||||
const list = document.getElementById('projects')
|
const list = document.getElementById('projects')
|
||||||
@@ -316,6 +556,31 @@ async function initDashboardPage() {
|
|||||||
window.location.href = '/login.html'
|
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 {
|
try {
|
||||||
const me = await api('/api/auth/me')
|
const me = await api('/api/auth/me')
|
||||||
|
|
||||||
@@ -333,6 +598,7 @@ async function initDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customer = me.customer
|
const customer = me.customer
|
||||||
|
chatState.viewAs = Boolean(me.viewAs)
|
||||||
renderViewAsBanner(me)
|
renderViewAsBanner(me)
|
||||||
|
|
||||||
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
|
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
|
||||||
@@ -355,8 +621,9 @@ async function initDashboardPage() {
|
|||||||
empty.classList.remove('hidden')
|
empty.classList.remove('hidden')
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCustomerInvoices()
|
// Chat-Badge global pollen (auch wenn Chat-Tab nicht offen ist)
|
||||||
loadCustomerActivity()
|
pollChatBadge()
|
||||||
|
chatState.badgeTimer = setInterval(pollChatBadge, 45000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loading.classList.add('hidden')
|
loading.classList.add('hidden')
|
||||||
if (err.status === 401 || err.message.includes('Nicht angemeldet')) {
|
if (err.status === 401 || err.message.includes('Nicht angemeldet')) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Meine Projekte – Webklar Kundenbereich</title>
|
<title>Kundenbereich – Webklar</title>
|
||||||
<link rel="stylesheet" href="/login.css">
|
<link rel="stylesheet" href="/login.css">
|
||||||
<style>
|
<style>
|
||||||
.viewas-banner {
|
.viewas-banner {
|
||||||
@@ -11,20 +11,75 @@
|
|||||||
padding: 10px 16px; margin-bottom: 16px; border-radius: 12px;
|
padding: 10px 16px; margin-bottom: 16px; border-radius: 12px;
|
||||||
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.45);
|
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tabs (Muster aus Admin, violett) */
|
||||||
|
.portal-tabs { display: flex; gap: 8px; margin: 14px 0 22px; flex-wrap: wrap; }
|
||||||
|
.portal-tab {
|
||||||
|
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
color: inherit; padding: 9px 18px; border-radius: 999px; cursor: pointer; font: inherit;
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.portal-tab.active { background: rgba(157,61,254,0.22); border-color: rgba(157,61,254,0.65); }
|
||||||
|
.portal-section { display: none; }
|
||||||
|
.portal-section.active { display: block; }
|
||||||
|
.portal-section-title { margin: 0 0 14px; font-size: 20px; }
|
||||||
|
|
||||||
|
.badge-pill {
|
||||||
|
display: inline-flex; min-width: 20px; height: 20px; padding: 0 6px;
|
||||||
|
align-items: center; justify-content: center; border-radius: 999px;
|
||||||
|
background: #9d3dfe; color: #fff; font-size: 12px; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.simple-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
.simple-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
.simple-table th, .simple-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
.simple-table th, .simple-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||||
.simple-table th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.65; }
|
.simple-table th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.65; }
|
||||||
.simple-table tr:last-child td { border-bottom: none; }
|
.simple-table tr:last-child td { border-bottom: none; }
|
||||||
.btn-link { color: #7dd3fc; text-decoration: none; }
|
.btn-link { color: #7dd3fc; text-decoration: none; }
|
||||||
.btn-link:hover { text-decoration: underline; }
|
.btn-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; border: 1px solid transparent; white-space: nowrap; }
|
||||||
|
.pill-ok { background: rgba(74,222,128,0.15); border-color: rgba(74,222,128,0.4); }
|
||||||
|
.pill-warn { background: rgba(157,61,254,0.18); border-color: rgba(157,61,254,0.5); }
|
||||||
|
.pill-red { background: rgba(248,113,113,0.15); border-color: rgba(248,113,113,0.45); }
|
||||||
|
.pill-muted { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.16); opacity: 0.85; }
|
||||||
|
.pill-git { background: rgba(251,146,60,0.16); border-color: rgba(251,146,60,0.5); }
|
||||||
|
|
||||||
.activity-item { padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
.activity-item { padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||||
.activity-item:last-child { border-bottom: none; }
|
.activity-item:last-child { border-bottom: none; }
|
||||||
.activity-status {
|
.activity-status {
|
||||||
display: inline-block; margin-left: 6px; padding: 1px 9px; border-radius: 999px; font-size: 12px;
|
display: inline-block; margin-left: 6px; padding: 1px 9px; border-radius: 999px; font-size: 12px;
|
||||||
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16);
|
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16);
|
||||||
}
|
}
|
||||||
.activity-ws { font-size: 13px; opacity: 0.8; margin: 3px 0 3px 14px; }
|
.activity-ws { font-size: 13px; opacity: 0.85; margin: 4px 0 4px 14px; white-space: pre-wrap; }
|
||||||
.portal-section-title { margin: 28px 0 12px; font-size: 20px; }
|
|
||||||
|
/* Projekt-Details */
|
||||||
|
.project-details-toggle { margin-top: 10px; }
|
||||||
|
.project-details { margin-top: 14px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; }
|
||||||
|
.project-details h3 { margin: 12px 0 8px; font-size: 15px; }
|
||||||
|
.commit-item { padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 14px; }
|
||||||
|
.commit-item:last-child { border-bottom: none; }
|
||||||
|
.commit-meta { font-size: 12px; opacity: 0.6; }
|
||||||
|
.commit-body { font-size: 13px; opacity: 0.8; white-space: pre-wrap; margin: 2px 0 0 8px; }
|
||||||
|
|
||||||
|
/* Chat */
|
||||||
|
.chat-box { display: flex; flex-direction: column; height: 480px; }
|
||||||
|
.chat-messages { flex: 1; overflow-y: auto; padding: 6px 4px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.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(157,61,254,0.3); border: 1px solid rgba(157,61,254,0.5); 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: 46px; max-height: 130px; padding: 12px 14px;
|
||||||
|
border-radius: 14px; border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
background: rgba(255,255,255,0.06); color: inherit; font: inherit;
|
||||||
|
}
|
||||||
|
.chat-send {
|
||||||
|
background: #9d3dfe; border: none; color: #fff; font: inherit; font-weight: 600;
|
||||||
|
padding: 12px 20px; border-radius: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-send:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.chat-hint { font-size: 12px; opacity: 0.55; margin-top: 6px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="portal-page dashboard-page">
|
<body class="portal-page dashboard-page">
|
||||||
@@ -44,21 +99,46 @@
|
|||||||
<main class="portal-main dashboard-content-reveal">
|
<main class="portal-main dashboard-content-reveal">
|
||||||
<div id="viewas-banner" class="viewas-banner hidden"></div>
|
<div id="viewas-banner" class="viewas-banner hidden"></div>
|
||||||
|
|
||||||
<h1 class="portal-title">Meine Website-Projekte</h1>
|
<h1 class="portal-title">Kundenbereich</h1>
|
||||||
|
|
||||||
<p id="load-error" class="error hidden"></p>
|
<p id="load-error" class="error hidden"></p>
|
||||||
<p id="loading" class="muted portal-status">Projekte werden geladen…</p>
|
|
||||||
<ul id="projects" class="projects hidden"></ul>
|
|
||||||
<p id="empty" class="portal-card hidden portal-status">Noch keine Website-Projekte zugewiesen.</p>
|
|
||||||
|
|
||||||
<section id="invoices-section" class="hidden">
|
<div class="portal-tabs">
|
||||||
<h2 class="portal-section-title">Meine Rechnungen</h2>
|
<button class="portal-tab active" data-tab="projects">Projekte</button>
|
||||||
<div class="portal-card" id="invoices-body"></div>
|
<button class="portal-tab" data-tab="activity">Was wurde gemacht</button>
|
||||||
|
<button class="portal-tab" data-tab="invoices">Rechnungen</button>
|
||||||
|
<button class="portal-tab" data-tab="chat">Chat <span id="chat-badge" class="badge-pill hidden"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projekte -->
|
||||||
|
<section class="portal-section active" id="tab-projects">
|
||||||
|
<p id="loading" class="muted portal-status">Projekte werden geladen…</p>
|
||||||
|
<ul id="projects" class="projects hidden"></ul>
|
||||||
|
<p id="empty" class="portal-card hidden portal-status">Noch keine Website-Projekte zugewiesen.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="activity-section" class="hidden">
|
<!-- Was wurde gemacht -->
|
||||||
|
<section class="portal-section" id="tab-activity">
|
||||||
<h2 class="portal-section-title">Was wurde gemacht</h2>
|
<h2 class="portal-section-title">Was wurde gemacht</h2>
|
||||||
<div class="portal-card" id="activity-body"></div>
|
<div class="portal-card" id="activity-body"><p class="muted">Lade…</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Rechnungen -->
|
||||||
|
<section class="portal-section" id="tab-invoices">
|
||||||
|
<h2 class="portal-section-title">Meine Rechnungen</h2>
|
||||||
|
<div class="portal-card" id="invoices-body"><p class="muted">Lade…</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Chat -->
|
||||||
|
<section class="portal-section" id="tab-chat">
|
||||||
|
<h2 class="portal-section-title">Chat mit dem Webklar-Team</h2>
|
||||||
|
<div class="portal-card chat-box">
|
||||||
|
<div class="chat-messages" id="chat-messages"></div>
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<textarea id="chat-input" maxlength="2000" placeholder="Nachricht an das Webklar-Team…"></textarea>
|
||||||
|
<button type="button" class="chat-send" id="chat-send">Senden</button>
|
||||||
|
</div>
|
||||||
|
<p class="chat-hint">Enter = Senden, Shift+Enter = Zeilenumbruch. Wir antworten in der Regel innerhalb eines Werktags.</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
50
scripts/migrate-git-worksheet-dates.mjs
Normal file
50
scripts/migrate-git-worksheet-dates.mjs
Normal file
@@ -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.`)
|
||||||
111
scripts/setup-chat-collection.mjs
Normal file
111
scripts/setup-chat-collection.mjs
Normal file
@@ -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.')
|
||||||
@@ -46,6 +46,24 @@ export const config = {
|
|||||||
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
|
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
|
||||||
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
|
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
|
||||||
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
|
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: {
|
gitea: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import projectsRoutes from './routes/projects.js'
|
|||||||
import featuresRoutes from './routes/features.js'
|
import featuresRoutes from './routes/features.js'
|
||||||
import customerDataRoutes from './routes/customerData.js'
|
import customerDataRoutes from './routes/customerData.js'
|
||||||
import portalAdminRoutes from './routes/portalAdmin.js'
|
import portalAdminRoutes from './routes/portalAdmin.js'
|
||||||
|
import chatRoutes from './routes/chat.js'
|
||||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.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/projects', projectsRoutes)
|
||||||
app.use('/api/features', featuresRoutes)
|
app.use('/api/features', featuresRoutes)
|
||||||
app.use('/api', customerDataRoutes)
|
app.use('/api', customerDataRoutes)
|
||||||
|
app.use('/api/chat', chatRoutes)
|
||||||
app.use('/api/portal-admin', portalAdminRoutes)
|
app.use('/api/portal-admin', portalAdminRoutes)
|
||||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||||
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
||||||
|
|||||||
123
server/routes/chat.js
Normal file
123
server/routes/chat.js
Normal file
@@ -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
|
||||||
@@ -3,7 +3,7 @@ import { config } from '../config.js'
|
|||||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||||
import { getDocument } from '../services/appwriteAdmin.js'
|
import { getDocument } from '../services/appwriteAdmin.js'
|
||||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
import { fetchInvoicePdf, getInvoiceRaw, listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||||
|
|
||||||
const router = Router()
|
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"). */
|
/** Ticket-/Arbeitshistorie des eingeloggten Kunden ("was wurde gemacht"). */
|
||||||
router.get('/activity', requireSession, async (req, res) => {
|
router.get('/activity', requireSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import {
|
|||||||
setPortalSession,
|
setPortalSession,
|
||||||
} from '../middleware/session.js'
|
} from '../middleware/session.js'
|
||||||
import {
|
import {
|
||||||
|
createDocument,
|
||||||
getDocument,
|
getDocument,
|
||||||
listDocuments,
|
listDocuments,
|
||||||
updateDocument,
|
updateDocument,
|
||||||
Query,
|
Query,
|
||||||
|
ID,
|
||||||
} from '../services/appwriteAdmin.js'
|
} from '../services/appwriteAdmin.js'
|
||||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||||
|
import { sanitizeMessage } from './chat.js'
|
||||||
|
|
||||||
const router = Router()
|
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 --
|
// ------------------------------------------------------- Als Kunde ansehen --
|
||||||
router.post('/view-as/:customerId', async (req, res) => {
|
router.post('/view-as/:customerId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { config } from '../config.js'
|
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 { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||||
|
import { getProjectGitInsights, getProjectFileOverview } from '../services/projectInsights.js'
|
||||||
|
|
||||||
const router = Router()
|
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) => {
|
router.get('/', requireSession, async (req, res) => {
|
||||||
const customerId = getSessionCustomerId(req)
|
const customerId = getSessionCustomerId(req)
|
||||||
if (!customerId) {
|
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
|
export default router
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
buildPreviewUrl,
|
buildPreviewUrl,
|
||||||
} from '../../services/previewDeploy.js'
|
} from '../../services/previewDeploy.js'
|
||||||
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
|
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
|
||||||
|
import { ensureProjectTicket } from '../../services/ticketAdmin.js'
|
||||||
|
import { invalidateProjectCache } from '../../services/projectInsights.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -93,17 +95,29 @@ router.post('/gitea', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = await upsertWebsiteProjectByRepo(repoFullName, projectPayload)
|
const project = await upsertWebsiteProjectByRepo(repoFullName, projectPayload)
|
||||||
|
invalidateProjectCache(repoFullName)
|
||||||
|
|
||||||
let gitWorksheet = null
|
let gitWorksheet = null
|
||||||
if (project.ticketId && commitSha && commitSha !== '0000000000000000000000000000000000000000') {
|
if (commitSha && commitSha !== '0000000000000000000000000000000000000000') {
|
||||||
gitWorksheet = await createGitPushWorksheet({
|
// Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde)
|
||||||
ticketId: project.ticketId,
|
let ticketId = project.ticketId
|
||||||
repoFullName,
|
if (!ticketId) {
|
||||||
branch,
|
try {
|
||||||
commits,
|
ticketId = await ensureProjectTicket(project, { repoFullName })
|
||||||
pusher,
|
} catch (err) {
|
||||||
commitSha,
|
console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message)
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
if (ticketId) {
|
||||||
|
gitWorksheet = await createGitPushWorksheet({
|
||||||
|
ticketId,
|
||||||
|
repoFullName,
|
||||||
|
branch,
|
||||||
|
commits,
|
||||||
|
pusher,
|
||||||
|
commitSha,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const Query = {
|
|||||||
values: Array.isArray(value) ? value : [value],
|
values: Array.isArray(value) ? value : [value],
|
||||||
}),
|
}),
|
||||||
limit: (n) => ({ method: 'limit', values: [n] }),
|
limit: (n) => ({ method: 'limit', values: [n] }),
|
||||||
|
greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }),
|
||||||
orderDesc: (attribute) => ({ method: 'orderDesc', attribute }),
|
orderDesc: (attribute) => ({ method: 'orderDesc', attribute }),
|
||||||
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
|
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export async function getCustomerActivity(customerId, { ticketLimit = 25, exclud
|
|||||||
|
|
||||||
const sheetsByWorkorder = {}
|
const sheetsByWorkorder = {}
|
||||||
for (const ws of worksheets) {
|
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] ||= [])
|
const list = (sheetsByWorkorder[ws.workorderId] ||= [])
|
||||||
list.push({
|
list.push({
|
||||||
wsid: ws.wsid || '',
|
wsid: ws.wsid || '',
|
||||||
@@ -41,6 +42,8 @@ export async function getCustomerActivity(customerId, { ticketLimit = 25, exclud
|
|||||||
totalTime: ws.totalTime || '',
|
totalTime: ws.totalTime || '',
|
||||||
date: ws.endDate || ws.startDate || '',
|
date: ws.endDate || ws.startDate || '',
|
||||||
details: ws.details || '',
|
details: ws.details || '',
|
||||||
|
isGit: ws.serviceType === 'GIT',
|
||||||
|
pending: ws.serviceType === 'GIT' && !ws.startTime,
|
||||||
createdAt: ws.createdAt || ws.$createdAt || '',
|
createdAt: ws.createdAt || ws.$createdAt || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import {
|
|||||||
} from './appwriteAdmin.js'
|
} from './appwriteAdmin.js'
|
||||||
|
|
||||||
const WSID_START = 100000
|
const WSID_START = 100000
|
||||||
|
// worksheets.details ist auf 4000 Zeichen begrenzt
|
||||||
|
const DETAILS_MAX_LENGTH = 3900
|
||||||
|
|
||||||
function formatToday() {
|
function formatToday() {
|
||||||
|
// dd.mm.yyyy - gleiches Format wie CreateWorksheetModal der Ticketsystem-SPA
|
||||||
const d = new Date()
|
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')
|
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() {
|
function formatTimeNow() {
|
||||||
@@ -49,10 +51,16 @@ function formatCommitLines(commits, repoFullName) {
|
|||||||
return commits
|
return commits
|
||||||
.map((commit) => {
|
.map((commit) => {
|
||||||
const sha = (commit.id || commit.sha || '').slice(0, 7)
|
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 author = commit.author?.name || commit.committer?.name || 'Unbekannt'
|
||||||
const url = commit.url || `${config.gitea.baseUrl}/${repoFullName}/commit/${commit.id || commit.sha}`
|
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')
|
.join('\n')
|
||||||
}
|
}
|
||||||
@@ -85,13 +93,18 @@ export async function createGitPushWorksheet({
|
|||||||
const wsid = await generateNextWsid()
|
const wsid = await generateNextWsid()
|
||||||
const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea'
|
const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea'
|
||||||
const latestCommit = commits[commits.length - 1] || commits[0]
|
const latestCommit = commits[commits.length - 1] || commits[0]
|
||||||
const details = [
|
let details = [
|
||||||
`Git Push auf \`${repoFullName}\` (Branch: ${branch})`,
|
`Git Push auf \`${repoFullName}\` (Branch: ${branch})`,
|
||||||
`Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`,
|
`Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`,
|
||||||
'',
|
'',
|
||||||
formatCommitLines(commits, repoFullName),
|
formatCommitLines(commits, repoFullName),
|
||||||
].join('\n')
|
].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 today = formatToday()
|
||||||
const worksheet = await createDocument(
|
const worksheet = await createDocument(
|
||||||
config.collections.worksheets,
|
config.collections.worksheets,
|
||||||
@@ -109,7 +122,7 @@ export async function createGitPushWorksheet({
|
|||||||
newResponseLevel: workorder.response || '',
|
newResponseLevel: workorder.response || '',
|
||||||
totalTime: 0,
|
totalTime: 0,
|
||||||
startDate: today,
|
startDate: today,
|
||||||
startTime: formatTimeNow(),
|
startTime: '',
|
||||||
endDate: today,
|
endDate: today,
|
||||||
endTime: formatTimeNow(),
|
endTime: formatTimeNow(),
|
||||||
details,
|
details,
|
||||||
|
|||||||
@@ -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']
|
const README_CANDIDATES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README']
|
||||||
|
|
||||||
export async function fetchRepoReadme(repoFullName) {
|
export async function fetchRepoReadme(repoFullName) {
|
||||||
|
|||||||
@@ -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). */
|
/** Rechnungen eines InvoiceNinja-Clients (fuer Kundenportal + Admin-Ansicht). */
|
||||||
export async function listInvoicesForClient(clientId, { includeDrafts = false } = {}) {
|
export async function listInvoicesForClient(clientId, { includeDrafts = false } = {}) {
|
||||||
const token = config.invoiceNinja.apiToken
|
const token = config.invoiceNinja.apiToken
|
||||||
|
|||||||
56
server/services/mailer.js
Normal file
56
server/services/mailer.js
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
47
server/services/projectInsights.js
Normal file
47
server/services/projectInsights.js
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
}
|
||||||
65
server/services/ticketAdmin.js
Normal file
65
server/services/ticketAdmin.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user