Compare commits
17 Commits
cc57cf887a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b4974431 | ||
|
|
55991c9008 | ||
|
|
f3bde44b3d | ||
|
|
943c6307c8 | ||
|
|
b6f5ea11ec | ||
|
|
1b4262b09f | ||
|
|
07ebcc7e62 | ||
| a874e51773 | |||
|
|
bed738d8bd | ||
|
|
376a8ae34d | ||
|
|
617d463a5a | ||
|
|
b474c4aa48 | ||
| 8880b10b28 | |||
| 1c1826d25d | |||
| d4bd510dcd | |||
| 177ee6a13d | |||
| 0320042045 |
10
.webklar-preview.json
Normal file
10
.webklar-preview.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"type": "static",
|
||||
"branch": "main",
|
||||
"displayName": "Webklar-Kundenbereich",
|
||||
"templateName": "webklar-preview-template",
|
||||
"projectType": "project",
|
||||
"public": false,
|
||||
"template": false
|
||||
}
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -12,7 +12,8 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"jose": "^6.0.11",
|
||||
"node-appwrite": "^14.1.0"
|
||||
"node-appwrite": "^14.1.0",
|
||||
"nodemailer": "^9.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -581,6 +582,15 @@
|
||||
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"jose": "^6.0.11",
|
||||
"node-appwrite": "^14.1.0"
|
||||
"node-appwrite": "^14.1.0",
|
||||
"nodemailer": "^9.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
178
public/admin.html
Normal file
178
public/admin.html
Normal file
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin – Webklar Kundenbereich</title>
|
||||
<link rel="stylesheet" href="/login.css">
|
||||
<style>
|
||||
.admin-tabs { display: flex; gap: 8px; margin: 18px 0; flex-wrap: wrap; }
|
||||
.admin-tab {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14);
|
||||
color: inherit; padding: 8px 16px; border-radius: 999px; cursor: pointer; font: inherit;
|
||||
}
|
||||
.admin-tab.active { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
|
||||
.admin-section { display: none; }
|
||||
.admin-section.active { display: block; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||||
.stat-tile { padding: 14px 16px; border-radius: 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); }
|
||||
.stat-tile .num { font-size: 26px; font-weight: 700; display: block; }
|
||||
.stat-tile .lbl { font-size: 12px; opacity: 0.7; }
|
||||
.admin-search { width: 100%; max-width: 380px; padding: 9px 14px; border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; margin-bottom: 12px; }
|
||||
.admin-table-wrap { overflow-x: auto; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); }
|
||||
/* Kundentabelle: volle Breite, kein Links-/Rechts-Scroll */
|
||||
.admin-table-wrap.no-scroll { overflow-x: visible; }
|
||||
.admin-table-wrap.no-scroll table.admin-table { table-layout: fixed; }
|
||||
.admin-table-wrap.no-scroll .admin-table td { word-break: break-word; overflow-wrap: anywhere; }
|
||||
.cust-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
table.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.admin-table th, .admin-table td { text-align: left; padding: 9px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); vertical-align: middle; }
|
||||
.admin-table th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.65; }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; border: 1px solid transparent; }
|
||||
.pill-ok { background: rgba(74,222,128,0.15); border-color: rgba(74,222,128,0.4); }
|
||||
.pill-warn { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.45); }
|
||||
.pill-muted { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.16); opacity: 0.8; }
|
||||
.pill-red { background: rgba(248,113,113,0.15); border-color: rgba(248,113,113,0.45); }
|
||||
.mini-btn { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.18); color: inherit;
|
||||
padding: 5px 11px; border-radius: 8px; cursor: pointer; font-size: 13px; white-space: nowrap; }
|
||||
.mini-btn:hover { background: rgba(255,255,255,0.14); }
|
||||
.mini-btn.primary { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
|
||||
.code-input { width: 100%; min-width: 60px; max-width: 90px; padding: 4px 8px; border-radius: 7px; border: 1px solid rgba(255,255,255,0.2);
|
||||
background: rgba(255,255,255,0.06); color: inherit; font: inherit; font-size: 13px; }
|
||||
.detail-row td { background: rgba(255,255,255,0.03); }
|
||||
.detail-box { padding: 6px 4px; }
|
||||
.detail-box h4 { margin: 8px 0 4px; font-size: 13px; }
|
||||
.detail-box .muted { opacity: 0.65; }
|
||||
.ws-line { font-size: 13px; opacity: 0.85; margin: 2px 0 2px 14px; }
|
||||
a.plain { color: #7dd3fc; text-decoration: none; }
|
||||
a.plain:hover { text-decoration: underline; }
|
||||
.admin-note { font-size: 13px; opacity: 0.65; margin: 8px 0 0; }
|
||||
.flex-between { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.recent-list { list-style: none; margin: 0; padding: 0; }
|
||||
.recent-list li { padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,0.07); font-size: 14px; }
|
||||
.recent-list li:last-child { border-bottom: none; }
|
||||
.two-col { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
||||
|
||||
/* Chat */
|
||||
.badge-pill {
|
||||
display: inline-flex; min-width: 20px; height: 20px; padding: 0 6px;
|
||||
align-items: center; justify-content: center; border-radius: 999px;
|
||||
background: #f87171; color: #fff; font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 14px; min-height: 480px; }
|
||||
@media (max-width: 760px) { .chat-layout { grid-template-columns: 1fr; } }
|
||||
.chat-list { border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; overflow-y: auto; max-height: 560px; }
|
||||
.chat-list-item { padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.07); cursor: pointer; }
|
||||
.chat-list-item:hover { background: rgba(255,255,255,0.05); }
|
||||
.chat-list-item.active { background: rgba(94,234,212,0.1); }
|
||||
.chat-list-item .last { font-size: 12px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-thread { display: flex; flex-direction: column; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; }
|
||||
.chat-messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding: 4px; max-height: 440px; }
|
||||
.chat-bubble { max-width: 78%; padding: 10px 14px; border-radius: 16px; font-size: 14px; white-space: pre-wrap; word-break: break-word; }
|
||||
.chat-bubble .chat-meta { display: block; font-size: 11px; opacity: 0.6; margin-top: 4px; }
|
||||
.chat-bubble.mine { align-self: flex-end; background: rgba(94,234,212,0.18); border: 1px solid rgba(94,234,212,0.45); border-bottom-right-radius: 4px; }
|
||||
.chat-bubble.theirs { align-self: flex-start; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); border-bottom-left-radius: 4px; }
|
||||
.chat-input-row { display: flex; gap: 10px; margin-top: 12px; align-items: flex-end; }
|
||||
.chat-input-row textarea {
|
||||
flex: 1; resize: none; min-height: 44px; max-height: 120px; padding: 10px 12px;
|
||||
border-radius: 12px; border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(255,255,255,0.06); color: inherit; font: inherit;
|
||||
}
|
||||
.chat-empty { display: flex; align-items: center; justify-content: center; flex: 1; opacity: 0.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="portal-page dashboard-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
<div class="dot-pattern"></div>
|
||||
<div class="dot-fade"></div>
|
||||
</div>
|
||||
|
||||
<header class="portal-header">
|
||||
<a class="portal-logo" href="/admin.html">Webklar <span style="opacity:.6">Admin</span></a>
|
||||
<div class="portal-header-actions">
|
||||
<span class="portal-meta" id="admin-meta"></span>
|
||||
<button type="button" class="portal-btn-outline" id="logout-btn">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="portal-main dashboard-content-reveal">
|
||||
<h1 class="portal-title">Admin-Übersicht</h1>
|
||||
<p id="admin-error" class="error hidden"></p>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<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="customers">Kunden</button>
|
||||
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
|
||||
</div>
|
||||
|
||||
<!-- Übersicht -->
|
||||
<section class="admin-section active" id="tab-overview">
|
||||
<div class="stat-grid" id="stat-grid"><span class="portal-status">Lade…</span></div>
|
||||
<div class="two-col">
|
||||
<div class="portal-card">
|
||||
<h3>Letzte Portal-Logins</h3>
|
||||
<ul class="recent-list" id="recent-logins"><li class="muted">Lade…</li></ul>
|
||||
</div>
|
||||
<div class="portal-card">
|
||||
<h3>Zuletzt aktualisierte Projekte</h3>
|
||||
<ul class="recent-list" id="recent-projects"><li class="muted">Lade…</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Projekte -->
|
||||
<section class="admin-section" id="tab-projects">
|
||||
<input class="admin-search" id="project-search" type="search"
|
||||
placeholder="Projekte suchen (Name, Subdomain, Repo, Kunde)…">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Projekt</th><th>Subdomain</th><th>Kunde</th><th>Status</th><th>Typ</th><th>Links</th></tr>
|
||||
</thead>
|
||||
<tbody id="project-rows"><tr><td colspan="6" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="admin-note">Als Admin kannst du jede Projekt-Vorschau direkt öffnen – ohne Kunden-Login.</p>
|
||||
</section>
|
||||
|
||||
<!-- Kunden -->
|
||||
<section class="admin-section" id="tab-customers">
|
||||
<input class="admin-search" id="customer-search" type="search"
|
||||
placeholder="Kunden suchen (Kundennr., Name, E-Mail, Ort)…">
|
||||
<div class="admin-table-wrap no-scroll">
|
||||
<table class="admin-table">
|
||||
<colgroup>
|
||||
<col style="width:9%"><col style="width:23%"><col style="width:11%"><col style="width:11%">
|
||||
<col style="width:13%"><col style="width:8%"><col style="width:25%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>Kundennr.</th><th>Kunde</th><th>Status</th><th>Portal</th><th>Letzter Login</th><th>Projekte</th><th>Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody id="customer-rows"><tr><td colspan="7" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<script src="/admin.js"></script>
|
||||
<script>
|
||||
initAdminPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
451
public/admin.js
Normal file
451
public/admin.js
Normal file
@@ -0,0 +1,451 @@
|
||||
/* Admin-Dashboard des Kundenportals (Ticketsystem-Mitarbeiter). */
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
...options,
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error || `Fehler ${response.status}`)
|
||||
err.status = response.status
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function fmtDate(value) {
|
||||
if (!value) return '–'
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return escapeHtml(value)
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function 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 stagePill(status) {
|
||||
const s = String(status || '').toLowerCase()
|
||||
if (s === 'lead') return '<span class="pill pill-warn">Lead</span>'
|
||||
if (s === 'lost') return '<span class="pill pill-muted">Abgesagt</span>'
|
||||
return '<span class="pill pill-ok">Fester Kunde</span>'
|
||||
}
|
||||
|
||||
function statusPill(project) {
|
||||
const s = String(project.provisioningStatus || project.status || '').toLowerCase()
|
||||
if (['ready', 'deployed'].includes(s)) return '<span class="pill pill-ok">deployed</span>'
|
||||
if (s.includes('fail')) return '<span class="pill pill-red">Fehler</span>'
|
||||
if (!project.subdomain) return '<span class="pill pill-muted">ohne Website</span>'
|
||||
return `<span class="pill pill-warn">${escapeHtml(project.status || 'pending')}</span>`
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const el = document.getElementById('admin-error')
|
||||
el.textContent = message
|
||||
el.classList.remove('hidden')
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Übersicht --- */
|
||||
async function loadOverview() {
|
||||
const grid = document.getElementById('stat-grid')
|
||||
const logins = document.getElementById('recent-logins')
|
||||
const recent = document.getElementById('recent-projects')
|
||||
try {
|
||||
const o = await api('/api/portal-admin/overview')
|
||||
grid.innerHTML = [
|
||||
{ num: o.projects.total, lbl: 'Projekte gesamt' },
|
||||
{ num: o.projects.deployed, lbl: 'Websites online' },
|
||||
{ num: o.projects.withoutWebsite, lbl: 'Projekte ohne Website' },
|
||||
{ num: o.customers.total, lbl: 'Kunden gesamt' },
|
||||
{ num: o.customers.leads, lbl: 'Leads' },
|
||||
{ num: o.customers.portalEnabled, lbl: 'Portal-Zugänge' },
|
||||
].map((s) => `<div class="stat-tile"><span class="num">${s.num}</span><span class="lbl">${s.lbl}</span></div>`).join('')
|
||||
|
||||
logins.innerHTML = o.recentLogins.length
|
||||
? o.recentLogins.map((l) => `<li>${escapeHtml(l.customerName)} <span class="muted">– ${fmtDateTime(l.lastLoginAt)}</span></li>`).join('')
|
||||
: '<li class="muted">Noch keine Portal-Logins.</li>'
|
||||
|
||||
recent.innerHTML = o.recentProjects.length
|
||||
? o.recentProjects.map((p) => `<li>${escapeHtml(p.projectName)}${p.customerName ? ` <span class="muted">(${escapeHtml(p.customerName)})</span>` : ''} <span class="muted">– ${fmtDateTime(p.updatedAt)}</span></li>`).join('')
|
||||
: '<li class="muted">Keine Projekte.</li>'
|
||||
} catch (err) {
|
||||
grid.innerHTML = ''
|
||||
showError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Projekte --- */
|
||||
let projectSearchTimer = null
|
||||
|
||||
async function loadProjects(search = '') {
|
||||
const tbody = document.getElementById('project-rows')
|
||||
try {
|
||||
const { projects } = await api(`/api/portal-admin/projects?search=${encodeURIComponent(search)}`)
|
||||
if (!projects.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="muted">Keine Projekte gefunden.</td></tr>'
|
||||
return
|
||||
}
|
||||
tbody.innerHTML = projects.map((p) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(p.projectName || p.repoFullName || '–')}</td>
|
||||
<td>${p.subdomain ? escapeHtml(p.subdomain) : '<span class="muted">–</span>'}</td>
|
||||
<td>${p.customerName ? escapeHtml(p.customerName) : '<span class="muted">–</span>'}</td>
|
||||
<td>${statusPill(p)}</td>
|
||||
<td>${p.projectType === 'repo' ? 'Repo' : 'Website'}</td>
|
||||
<td>
|
||||
${p.previewUrl ? `<a class="plain" href="${escapeHtml(p.previewUrl)}" target="_blank" rel="noreferrer">Vorschau</a>` : ''}
|
||||
${p.giteaRepoUrl ? ` · <a class="plain" href="${escapeHtml(p.giteaRepoUrl)}" target="_blank" rel="noreferrer">Gitea</a>` : ''}
|
||||
</td>
|
||||
</tr>`).join('')
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="error">${escapeHtml(err.message)}</td></tr>`
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Kunden --- */
|
||||
let customerSearchTimer = null
|
||||
|
||||
async function saveCustomerCode(customerId, btn) {
|
||||
const input = document.querySelector(`input[data-code-for="${customerId}"]`)
|
||||
if (!input) return
|
||||
btn.disabled = true
|
||||
try {
|
||||
await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ code: input.value }),
|
||||
})
|
||||
btn.textContent = '✓ Gespeichert'
|
||||
setTimeout(() => { btn.textContent = '✓'; btn.disabled = false }, 1200)
|
||||
} catch (err) {
|
||||
btn.disabled = false
|
||||
alert('Kundennummer konnte nicht gespeichert werden: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function viewAsCustomer(customerId) {
|
||||
try {
|
||||
await api(`/api/portal-admin/view-as/${encodeURIComponent(customerId)}`, { method: 'POST' })
|
||||
window.location.href = '/dashboard.html'
|
||||
} catch (err) {
|
||||
alert('Ansicht konnte nicht gewechselt werden: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
function renderActivity(tickets) {
|
||||
if (!tickets.length) return '<p class="muted">Keine Tickets für diesen Kunden.</p>'
|
||||
return tickets.map((t) => `
|
||||
<div class="detail-box">
|
||||
<strong>${escapeHtml(t.woid ? `#${t.woid}` : '')} ${escapeHtml(t.topic || '–')}</strong>
|
||||
<span class="pill pill-muted">${escapeHtml(t.status || '–')}</span>
|
||||
<span class="muted">${escapeHtml(t.type || '')} · ${fmtDate(t.createdAt)}</span>
|
||||
${(t.worksheets || []).map((w) => `
|
||||
<div class="ws-line">→ ${fmtDate(w.date)} ${escapeHtml(w.employeeName)} (${escapeHtml(w.serviceType || '–')}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''}): ${escapeHtml((w.details || '').slice(0, 160))}</div>
|
||||
`).join('')}
|
||||
</div>`).join('')
|
||||
}
|
||||
|
||||
function renderInvoices(data) {
|
||||
if (!data.linked) return '<p class="muted">Kunde ist nicht mit InvoiceNinja verknüpft.</p>'
|
||||
if (!data.invoices.length) return '<p class="muted">Keine Rechnungen vorhanden.</p>'
|
||||
return `<table class="admin-table"><thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead><tbody>
|
||||
${data.invoices.map((i) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(i.number)}</td>
|
||||
<td>${fmtDate(i.date)}</td>
|
||||
<td>${fmtEuro(i.amount)}</td>
|
||||
<td>${fmtEuro(i.balance)}</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="plain" href="${escapeHtml(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen</a>` : ''}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`
|
||||
}
|
||||
|
||||
async function toggleCustomerDetail(customerId, kind, cell) {
|
||||
const existing = document.getElementById(`detail-${customerId}`)
|
||||
if (existing) {
|
||||
const wasKind = existing.dataset.kind
|
||||
existing.remove()
|
||||
if (wasKind === kind) return
|
||||
}
|
||||
|
||||
const row = document.querySelector(`tr[data-customer-row="${customerId}"]`)
|
||||
if (!row) return
|
||||
const tr = document.createElement('tr')
|
||||
tr.id = `detail-${customerId}`
|
||||
tr.dataset.kind = kind
|
||||
tr.className = 'detail-row'
|
||||
tr.innerHTML = `<td colspan="7"><div class="detail-box muted">Lade…</div></td>`
|
||||
row.after(tr)
|
||||
|
||||
try {
|
||||
if (kind === 'activity') {
|
||||
const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`)
|
||||
tr.innerHTML = `<td colspan="7"><h4>Tickets & Arbeiten</h4>${renderActivity(tickets)}</td>`
|
||||
} else {
|
||||
const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`)
|
||||
tr.innerHTML = `<td colspan="7"><h4>Rechnungen</h4>${renderInvoices(data)}</td>`
|
||||
}
|
||||
} catch (err) {
|
||||
tr.innerHTML = `<td colspan="7"><span class="error">${escapeHtml(err.message)}</span></td>`
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers(search = '') {
|
||||
const tbody = document.getElementById('customer-rows')
|
||||
try {
|
||||
const { customers } = await api(`/api/portal-admin/customers?search=${encodeURIComponent(search)}`)
|
||||
if (!customers.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="muted">Keine Kunden gefunden.</td></tr>'
|
||||
return
|
||||
}
|
||||
tbody.innerHTML = customers.map((c) => `
|
||||
<tr data-customer-row="${escapeHtml(c.id)}">
|
||||
<td>
|
||||
<div class="cust-actions">
|
||||
<input class="code-input" data-code-for="${escapeHtml(c.id)}" value="${escapeHtml(c.code)}" placeholder="–">
|
||||
<button class="mini-btn" data-save-code="${escapeHtml(c.id)}" title="Kundennummer speichern">✓</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>${escapeHtml(c.name)}</strong><br>
|
||||
<span class="muted">${escapeHtml(c.email || '–')}</span>
|
||||
</td>
|
||||
<td>${stagePill(c.customerStatus)}</td>
|
||||
<td>${c.portalAccessEnabled ? '<span class="pill pill-ok">aktiv</span>' : '<span class="pill pill-muted">kein Zugang</span>'}</td>
|
||||
<td>${fmtDateTime(c.lastLoginAt)}</td>
|
||||
<td>${c.projectCount}</td>
|
||||
<td>
|
||||
<div class="cust-actions">
|
||||
<button class="mini-btn primary" data-view-as="${escapeHtml(c.id)}">Als Kunde ansehen</button>
|
||||
<button class="mini-btn" data-activity="${escapeHtml(c.id)}">Arbeiten</button>
|
||||
<button class="mini-btn" data-invoices="${escapeHtml(c.id)}"${c.invoiceNinjaLinked ? '' : ' title="Nicht mit InvoiceNinja verknüpft"'}>Rechnungen</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('')
|
||||
|
||||
tbody.querySelectorAll('[data-save-code]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => saveCustomerCode(btn.dataset.saveCode, btn)))
|
||||
tbody.querySelectorAll('[data-view-as]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => viewAsCustomer(btn.dataset.viewAs)))
|
||||
tbody.querySelectorAll('[data-activity]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity')))
|
||||
tbody.querySelectorAll('[data-invoices]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices')))
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="error">${escapeHtml(err.message)}</td></tr>`
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- 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 --- */
|
||||
async function initAdminPage() {
|
||||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||
await api('/api/auth/logout', { method: 'POST' }).catch(() => {})
|
||||
window.location.href = '/login.html'
|
||||
})
|
||||
|
||||
try {
|
||||
const me = await api('/api/auth/me')
|
||||
if (!me.authenticated || me.role !== 'admin') {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
if (me.viewAs) {
|
||||
// Admin ist gerade in der Kundenansicht -> zurückholen
|
||||
await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
|
||||
}
|
||||
document.getElementById('admin-meta').textContent =
|
||||
`${me.admin?.name || 'Admin'} (${me.admin?.email || ''})`
|
||||
} catch {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
|
||||
let chatListLoaded = false
|
||||
document.querySelectorAll('.admin-tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active'))
|
||||
document.querySelectorAll('.admin-section').forEach((s) => s.classList.remove('active'))
|
||||
tab.classList.add('active')
|
||||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active')
|
||||
if (tab.dataset.tab === 'chat' && !chatListLoaded) {
|
||||
chatListLoaded = true
|
||||
loadChatList()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
document.getElementById('project-search')?.addEventListener('input', (e) => {
|
||||
clearTimeout(projectSearchTimer)
|
||||
projectSearchTimer = setTimeout(() => loadProjects(e.target.value.trim()), 250)
|
||||
})
|
||||
document.getElementById('customer-search')?.addEventListener('input', (e) => {
|
||||
clearTimeout(customerSearchTimer)
|
||||
customerSearchTimer = setTimeout(() => loadCustomers(e.target.value.trim()), 250)
|
||||
})
|
||||
|
||||
loadOverview()
|
||||
loadProjects()
|
||||
loadCustomers()
|
||||
|
||||
// Chat-Badge global pollen
|
||||
pollAdminChatBadge()
|
||||
setInterval(pollAdminChatBadge, 60000)
|
||||
}
|
||||
|
||||
window.initAdminPage = initAdminPage
|
||||
506
public/app.js
506
public/app.js
@@ -28,34 +28,8 @@ function hideError(el) {
|
||||
if (el) el.classList.add('hidden')
|
||||
}
|
||||
|
||||
function featuresForProject(features, projectId) {
|
||||
return features.filter((f) => !f.projectId || f.projectId === projectId)
|
||||
}
|
||||
|
||||
function renderProjectCard(project, features) {
|
||||
const li = document.createElement('li')
|
||||
li.className = 'project-card'
|
||||
const projectFeatures = featuresForProject(features, project.id)
|
||||
const featureHtml = projectFeatures.length
|
||||
? `<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) {
|
||||
return String(str)
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
@@ -70,6 +44,191 @@ function getQueryParam(name) {
|
||||
return new URLSearchParams(window.location.search).get(name) || ''
|
||||
}
|
||||
|
||||
function fmtDateShort(value) {
|
||||
if (!value) return '–'
|
||||
if (/^\d{2}\.\d{2}\.\d{4}$/.test(String(value))) return escapeHtml(value)
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return escapeHtml(value)
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function fmtDateTime(value) {
|
||||
if (!value) return '–'
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return escapeHtml(value)
|
||||
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtEuro(value) {
|
||||
return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
|
||||
}
|
||||
|
||||
function fmtBytes(bytes) {
|
||||
const n = Number(bytes || 0)
|
||||
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${n} B`
|
||||
}
|
||||
|
||||
function fmtMinutes(min) {
|
||||
const n = Number(min || 0)
|
||||
if (!n) return ''
|
||||
const h = Math.floor(n / 60)
|
||||
const m = n % 60
|
||||
return h ? `${h} h ${m} min` : `${m} min`
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Projekte --- */
|
||||
function featuresForProject(features, projectId) {
|
||||
return features.filter((f) => !f.projectId || f.projectId === projectId)
|
||||
}
|
||||
|
||||
const PREVIEW_BASE_WIDTH = 1280
|
||||
function setupPreviewFrame(frame) {
|
||||
const view = frame.querySelector('.preview-frame__view')
|
||||
if (!view) return
|
||||
const apply = () => {
|
||||
const w = frame.clientWidth
|
||||
if (w) view.style.transform = `scale(${w / PREVIEW_BASE_WIDTH})`
|
||||
}
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
new ResizeObserver(apply).observe(frame)
|
||||
} else {
|
||||
window.addEventListener('resize', apply)
|
||||
}
|
||||
apply()
|
||||
}
|
||||
|
||||
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>`
|
||||
: ''
|
||||
|
||||
const ready = isProjectPreviewReady(project)
|
||||
const url = project.previewUrl ? escapeAttr(project.previewUrl) : ''
|
||||
const previewHtml = ready && url
|
||||
? `
|
||||
<a class="preview-frame" href="${url}" target="_blank" rel="noreferrer" data-preview-frame aria-label="Projekt-Webseite in neuem Tab öffnen">
|
||||
<iframe class="preview-frame__view" src="${url}" loading="lazy" scrolling="no" tabindex="-1" aria-hidden="true"></iframe>
|
||||
<span class="preview-frame__hint">Webseite öffnen ↗</span>
|
||||
</a>`
|
||||
: `<div class="preview-frame preview-frame--empty"><span>${project.previewUrl ? 'Vorschau wird vorbereitet …' : 'Noch keine Webseite veröffentlicht'}</span></div>`
|
||||
|
||||
li.innerHTML = `
|
||||
${previewHtml}
|
||||
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
|
||||
<dl>
|
||||
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '–')}</dd>
|
||||
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}</dd>
|
||||
<dt>Status</dt><dd>${escapeHtml(project.status || '–')}</dd>
|
||||
</dl>
|
||||
${ready && url ? `<p class="preview-open"><a class="btn btn-green" href="${url}" target="_blank" rel="noreferrer">Projekt-Webseite öffnen ↗</a></p>` : ''}
|
||||
${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 frame = li.querySelector('[data-preview-frame]')
|
||||
if (frame) setupPreviewFrame(frame)
|
||||
|
||||
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) {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
@@ -116,17 +275,6 @@ async function resolvePostLoginTarget() {
|
||||
if (next && (await canAccessPreviewUrl(next))) {
|
||||
return next
|
||||
}
|
||||
|
||||
try {
|
||||
const { projects } = await api('/api/projects')
|
||||
const readyProjects = (projects || []).filter(isProjectPreviewReady)
|
||||
if (readyProjects.length === 1) {
|
||||
return readyProjects[0].previewUrl
|
||||
}
|
||||
} catch {
|
||||
/* dashboard fallback */
|
||||
}
|
||||
|
||||
return '/dashboard.html'
|
||||
}
|
||||
|
||||
@@ -134,6 +282,7 @@ async function postLoginRedirect() {
|
||||
window.location.href = await resolvePostLoginTarget()
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Login-Page --- */
|
||||
async function initLoginPage() {
|
||||
const form = document.getElementById('login-form')
|
||||
const errorEl = document.getElementById('login-error')
|
||||
@@ -178,6 +327,10 @@ async function initLoginPage() {
|
||||
|
||||
try {
|
||||
const me = await api('/api/auth/me')
|
||||
if (me.authenticated && me.role === 'admin' && !me.viewAs) {
|
||||
window.location.href = '/admin.html'
|
||||
return
|
||||
}
|
||||
if (me.authenticated && me.customer) {
|
||||
await postLoginRedirect()
|
||||
return
|
||||
@@ -195,13 +348,17 @@ async function initLoginPage() {
|
||||
btn.setAttribute('aria-busy', 'true')
|
||||
let cooldownActive = false
|
||||
try {
|
||||
await api('/api/auth/login', {
|
||||
const result = await api('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value,
|
||||
}),
|
||||
})
|
||||
if (result.role === 'admin') {
|
||||
window.location.href = '/admin.html'
|
||||
return
|
||||
}
|
||||
await postLoginRedirect()
|
||||
return
|
||||
} catch (err) {
|
||||
@@ -218,6 +375,204 @@ async function initLoginPage() {
|
||||
})
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- View-as --- */
|
||||
function renderViewAsBanner(me) {
|
||||
const banner = document.getElementById('viewas-banner')
|
||||
if (!banner || !me.viewAs) return
|
||||
banner.classList.remove('hidden')
|
||||
banner.innerHTML = `
|
||||
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
|
||||
<button type="button" class="portal-btn-outline" id="exit-viewas-btn">Zurück zur Admin-Übersicht</button>
|
||||
`
|
||||
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
|
||||
await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
|
||||
window.location.href = '/admin.html'
|
||||
})
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Rechnungen --- */
|
||||
async function loadCustomerInvoices() {
|
||||
const body = document.getElementById('invoices-body')
|
||||
if (!body) return
|
||||
try {
|
||||
const data = await api('/api/invoices')
|
||||
if (!data.linked) {
|
||||
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 = `
|
||||
<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>
|
||||
<tbody>
|
||||
${data.invoices.map((i) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(i.number)}</td>
|
||||
<td>${fmtDateShort(i.date)}</td>
|
||||
<td>${fmtEuro(i.amount)}</td>
|
||||
<td>${fmtEuro(i.balance)}</td>
|
||||
<td><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></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('')}
|
||||
</tbody>
|
||||
</table>`
|
||||
} catch (err) {
|
||||
body.innerHTML = `<p class="muted">Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}</p>`
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Aktivität --- */
|
||||
async function loadCustomerActivity() {
|
||||
const body = document.getElementById('activity-body')
|
||||
if (!body) return
|
||||
try {
|
||||
const { tickets } = await api('/api/activity')
|
||||
if (!tickets || !tickets.length) {
|
||||
body.innerHTML = '<p class="muted">Es wurden noch keine Arbeiten dokumentiert.</p>'
|
||||
return
|
||||
}
|
||||
body.innerHTML = tickets.map((t) => `
|
||||
<div class="activity-item">
|
||||
<div>
|
||||
<strong>${escapeHtml(t.woid ? `#${t.woid} ` : '')}${escapeHtml(t.topic || '–')}</strong>
|
||||
<span class="activity-status">${escapeHtml(t.status || '–')}</span>
|
||||
<span class="muted"> · ${fmtDateShort(t.createdAt)}</span>
|
||||
</div>
|
||||
${(t.worksheets || []).map((w) => `
|
||||
<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('')}
|
||||
</div>`).join('')
|
||||
} catch (err) {
|
||||
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() {
|
||||
const meta = document.getElementById('customer-meta')
|
||||
const list = document.getElementById('projects')
|
||||
@@ -231,31 +586,74 @@ async function initDashboardPage() {
|
||||
window.location.href = '/login.html'
|
||||
})
|
||||
|
||||
try {
|
||||
const [{ authenticated, customer }, { projects }, { features }] = await Promise.all([
|
||||
api('/api/auth/me'),
|
||||
api('/api/projects'),
|
||||
api('/api/features'),
|
||||
])
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!authenticated || !customer) {
|
||||
try {
|
||||
const me = await api('/api/auth/me')
|
||||
|
||||
if (!me.authenticated) {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
if (me.role === 'admin' && !me.customer) {
|
||||
window.location.href = '/admin.html'
|
||||
return
|
||||
}
|
||||
if (!me.customer) {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
|
||||
meta.textContent = customer.name ? `${customer.name} (${customer.email})` : customer.email
|
||||
const customer = me.customer
|
||||
chatState.viewAs = Boolean(me.viewAs)
|
||||
renderViewAsBanner(me)
|
||||
|
||||
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
|
||||
meta.textContent = (customer.name ? `${customer.name} (${customer.email})` : customer.email) + codeSuffix
|
||||
|
||||
const [{ projects }, { features }] = await Promise.all([
|
||||
api('/api/projects'),
|
||||
api('/api/features'),
|
||||
])
|
||||
|
||||
loading.classList.add('hidden')
|
||||
|
||||
if (!projects.length) {
|
||||
if (projects.length) {
|
||||
list.classList.remove('hidden')
|
||||
list.innerHTML = ''
|
||||
for (const project of projects) {
|
||||
list.appendChild(renderProjectCard(project, features))
|
||||
}
|
||||
} else {
|
||||
empty.classList.remove('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
list.classList.remove('hidden')
|
||||
list.innerHTML = ''
|
||||
for (const project of projects) {
|
||||
list.appendChild(renderProjectCard(project, features))
|
||||
}
|
||||
// Chat-Badge global pollen (auch wenn Chat-Tab nicht offen ist)
|
||||
pollChatBadge()
|
||||
chatState.badgeTimer = setInterval(pollChatBadge, 45000)
|
||||
} catch (err) {
|
||||
loading.classList.add('hidden')
|
||||
if (err.status === 401 || err.message.includes('Nicht angemeldet')) {
|
||||
|
||||
@@ -3,8 +3,109 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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">
|
||||
<style>
|
||||
.viewas-banner {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||
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);
|
||||
}
|
||||
|
||||
/* 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 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 tr:last-child td { border-bottom: none; }
|
||||
.btn-link { color: #7dd3fc; text-decoration: none; }
|
||||
.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:last-child { border-bottom: none; }
|
||||
.activity-status {
|
||||
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);
|
||||
}
|
||||
.activity-ws { font-size: 13px; opacity: 0.85; margin: 4px 0 4px 14px; white-space: pre-wrap; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* Projekt-Vorschaufenster */
|
||||
.preview-frame {
|
||||
position: relative; display: block; width: 100%; aspect-ratio: 16 / 10;
|
||||
overflow: hidden; border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(255,255,255,0.04); margin-bottom: 1rem; text-decoration: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.preview-frame:hover { border-color: rgba(157,61,254,0.7); box-shadow: 0 0 0 1px rgba(157,61,254,0.35); }
|
||||
.preview-frame__view {
|
||||
position: absolute; top: 0; left: 0; width: 1280px; height: 800px; border: 0;
|
||||
transform-origin: 0 0; pointer-events: none; background: #fff;
|
||||
}
|
||||
.preview-frame__hint {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 14px;
|
||||
font-size: 13px; font-weight: 600; color: #fff;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.6), rgba(0,0,0,0));
|
||||
opacity: 0; transition: opacity 0.2s ease; pointer-events: none;
|
||||
}
|
||||
.preview-frame:hover .preview-frame__hint { opacity: 1; }
|
||||
.preview-frame--empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: rgba(255,255,255,0.5); font-size: 14px; aspect-ratio: 16 / 10;
|
||||
}
|
||||
.preview-open { margin: 0.75rem 0 0; }
|
||||
|
||||
/* 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>
|
||||
</head>
|
||||
<body class="portal-page dashboard-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
@@ -21,12 +122,49 @@
|
||||
</header>
|
||||
|
||||
<main class="portal-main dashboard-content-reveal">
|
||||
<h1 class="portal-title">Meine Website-Projekte</h1>
|
||||
<div id="viewas-banner" class="viewas-banner hidden"></div>
|
||||
|
||||
<h1 class="portal-title">Kundenbereich</h1>
|
||||
<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>
|
||||
|
||||
<div class="portal-tabs">
|
||||
<button class="portal-tab active" data-tab="projects">Projekte</button>
|
||||
<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>
|
||||
|
||||
<!-- Was wurde gemacht -->
|
||||
<section class="portal-section" id="tab-activity">
|
||||
<h2 class="portal-section-title">Was wurde gemacht</h2>
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
|
||||
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',
|
||||
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
|
||||
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
|
||||
portalMessages: process.env.APPWRITE_COLLECTION_PORTAL_MESSAGES || 'portalMessages',
|
||||
},
|
||||
|
||||
smtp: {
|
||||
host: process.env.SMTP_HOST || '',
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: (process.env.SMTP_SECURE || 'false') === 'true',
|
||||
user: process.env.SMTP_USERNAME || '',
|
||||
pass: process.env.SMTP_PASSWORD || '',
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USERNAME || '',
|
||||
},
|
||||
|
||||
chatNotify: {
|
||||
emails: (process.env.CHAT_NOTIFY_EMAILS || 'kenso@webklar.com,andrej@webklar.com')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
throttleMin: Number(process.env.CHAT_NOTIFY_THROTTLE_MIN) || 15,
|
||||
},
|
||||
|
||||
gitea: {
|
||||
|
||||
@@ -8,6 +8,9 @@ import { sessionMiddleware } from './middleware/session.js'
|
||||
import authRoutes from './routes/auth.js'
|
||||
import projectsRoutes from './routes/projects.js'
|
||||
import featuresRoutes from './routes/features.js'
|
||||
import customerDataRoutes from './routes/customerData.js'
|
||||
import portalAdminRoutes from './routes/portalAdmin.js'
|
||||
import chatRoutes from './routes/chat.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
||||
@@ -24,7 +27,8 @@ assertServerConfig()
|
||||
const app = express()
|
||||
app.set('trust proxy', 1)
|
||||
app.use(sessionMiddleware())
|
||||
app.use(express.json({ limit: '2mb' }))
|
||||
// rawBody wird fuer die HMAC-Verifikation des Gitea-Webhooks benoetigt
|
||||
app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } }))
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.get('Origin')
|
||||
@@ -42,6 +46,9 @@ app.use((req, res, next) => {
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/projects', projectsRoutes)
|
||||
app.use('/api/features', featuresRoutes)
|
||||
app.use('/api', customerDataRoutes)
|
||||
app.use('/api/chat', chatRoutes)
|
||||
app.use('/api/portal-admin', portalAdminRoutes)
|
||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
||||
app.use('/api/admin/customers', adminCustomersRoutes)
|
||||
|
||||
@@ -26,22 +26,36 @@ export function clearPortalSession(res) {
|
||||
})
|
||||
}
|
||||
|
||||
export function requireSession(req, res, next) {
|
||||
export function parsePortalSession(req) {
|
||||
const raw = req.signedCookies?.[config.cookieName]
|
||||
if (!raw) {
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function requireSession(req, res, next) {
|
||||
const session = parsePortalSession(req)
|
||||
if (!session) {
|
||||
return res.status(401).json({ error: 'Nicht angemeldet' })
|
||||
}
|
||||
|
||||
try {
|
||||
const session = JSON.parse(raw)
|
||||
if (!session.customerId || !session.appwriteUserId) {
|
||||
return res.status(401).json({ error: 'Ungültige Session' })
|
||||
}
|
||||
req.session = session
|
||||
next()
|
||||
} catch {
|
||||
if (!session.customerId || !session.appwriteUserId) {
|
||||
return res.status(401).json({ error: 'Ungültige Session' })
|
||||
}
|
||||
req.session = session
|
||||
next()
|
||||
}
|
||||
|
||||
/** Admin-Session: Ticketsystem-Mitarbeiter (Appwrite-Label "admin") im Portal. */
|
||||
export function requireAdminSession(req, res, next) {
|
||||
const session = parsePortalSession(req)
|
||||
if (!session?.appwriteUserId || session.role !== 'admin') {
|
||||
return res.status(401).json({ error: 'Admin-Anmeldung erforderlich' })
|
||||
}
|
||||
req.session = session
|
||||
next()
|
||||
}
|
||||
|
||||
export function getSessionCustomerId(req) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
import { createProjectFromTemplate } from '../../services/giteaAdmin.js'
|
||||
import {
|
||||
createProjectFromTemplate,
|
||||
updateWebsiteProject,
|
||||
archiveRepo,
|
||||
} from '../../services/giteaAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -29,4 +33,38 @@ router.post('/create-from-template', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const { projectName, subdomain, projectType } = req.body || {}
|
||||
try {
|
||||
const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType })
|
||||
return res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message || 'Projekt konnte nicht aktualisiert werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// Repository archivieren
|
||||
router.post('/:id/archive', async (req, res) => {
|
||||
try {
|
||||
const result = await archiveRepo(req.params.id, true)
|
||||
return res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message || 'Projekt konnte nicht archiviert werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// Repository reaktivieren
|
||||
router.post('/:id/unarchive', async (req, res) => {
|
||||
try {
|
||||
const result = await archiveRepo(req.params.id, false)
|
||||
return res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message || 'Projekt konnte nicht reaktiviert werden' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getCustomerByEmail,
|
||||
getDocument,
|
||||
getPortalAccessByCustomerId,
|
||||
getUserById,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
Query,
|
||||
@@ -147,6 +148,31 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
try {
|
||||
const user = await loginWithAppwrite(email.trim(), password)
|
||||
|
||||
// Ticketsystem-Admins (Appwrite-Label "admin") bekommen die Admin-Ansicht
|
||||
const account = await getUserById(user.$id).catch(() => null)
|
||||
if (Array.isArray(account?.labels) && account.labels.includes('admin')) {
|
||||
const adminName = account.name || 'Admin'
|
||||
const adminEmail = account.email || email.trim()
|
||||
setPortalSession(res, {
|
||||
role: 'admin',
|
||||
appwriteUserId: user.$id,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
})
|
||||
const adminPreviewToken = await createPreviewSessionToken({
|
||||
userId: user.$id,
|
||||
customerId: '',
|
||||
email: adminEmail,
|
||||
name: adminName,
|
||||
role: 'admin',
|
||||
labels: ['admin'],
|
||||
authSource: 'kundenbereich-admin',
|
||||
})
|
||||
res.cookie(PREVIEW_COOKIE_NAME, adminPreviewToken, previewSessionCookieOptions())
|
||||
return res.json({ success: true, role: 'admin', admin: { name: adminName, email: adminEmail } })
|
||||
}
|
||||
|
||||
let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim())
|
||||
customer = await syncCustomerAppwriteUserId(customer, user.$id)
|
||||
|
||||
@@ -176,7 +202,7 @@ router.post('/login', async (req, res) => {
|
||||
console.warn('[auth] lastLoginAt update failed:', err.message)
|
||||
}
|
||||
|
||||
return res.json({ success: true, customer: sanitizeCustomer(customer) })
|
||||
return res.json({ success: true, role: 'customer', customer: sanitizeCustomer(customer) })
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
if (status === 429) {
|
||||
@@ -271,6 +297,22 @@ router.get('/staff-preview', async (req, res) => {
|
||||
return res.redirect(previewUrl)
|
||||
})
|
||||
|
||||
// Selbstheilung: Portal-Session lebt oft laenger als das preview_session-Cookie
|
||||
// (bzw. das Cookie fehlt nach WebView-/Browser-Datenverlust). Ohne gueltiges
|
||||
// Preview-Cookie laufen die *.project.webklar.com-Hosts in eine 302-Schleife.
|
||||
// Deshalb stellt /me das Preview-Cookie automatisch neu aus, solange die
|
||||
// Portal-Session gueltig ist.
|
||||
async function refreshPreviewCookie(req, res, payload) {
|
||||
try {
|
||||
const existing = req.cookies?.[PREVIEW_COOKIE_NAME]
|
||||
if (existing && (await verifyPreviewSessionToken(existing))) return
|
||||
const token = await createPreviewSessionToken(payload)
|
||||
res.cookie(PREVIEW_COOKIE_NAME, token, previewSessionCookieOptions())
|
||||
} catch (err) {
|
||||
console.warn('[auth] preview-cookie refresh failed:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/me', async (req, res) => {
|
||||
const raw = req.signedCookies?.[config.cookieName]
|
||||
if (!raw) {
|
||||
@@ -279,6 +321,35 @@ router.get('/me', async (req, res) => {
|
||||
|
||||
try {
|
||||
const session = JSON.parse(raw)
|
||||
|
||||
// Admin-Session (Ticketsystem-Mitarbeiter), optional im "Als Kunde ansehen"-Modus
|
||||
if (session.role === 'admin' && session.appwriteUserId) {
|
||||
let customer = null
|
||||
if (session.customerId) {
|
||||
const doc = await getDocument(config.collections.customers, session.customerId).catch(() => null)
|
||||
if (doc) customer = sanitizeCustomer(doc)
|
||||
}
|
||||
await refreshPreviewCookie(req, res, {
|
||||
userId: session.appwriteUserId,
|
||||
customerId: '',
|
||||
email: session.adminEmail || session.email || '',
|
||||
name: session.adminName || session.name || 'Admin',
|
||||
role: 'admin',
|
||||
labels: ['admin'],
|
||||
authSource: 'kundenbereich-admin',
|
||||
})
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
role: 'admin',
|
||||
viewAs: Boolean(session.viewAs && customer),
|
||||
admin: {
|
||||
name: session.adminName || session.name || 'Admin',
|
||||
email: session.adminEmail || session.email || '',
|
||||
},
|
||||
customer,
|
||||
})
|
||||
}
|
||||
|
||||
if (!session.customerId || !session.appwriteUserId) {
|
||||
return res.json({ authenticated: false })
|
||||
}
|
||||
@@ -302,8 +373,19 @@ router.get('/me', async (req, res) => {
|
||||
customer = await syncCustomerAppwriteUserId(customer, session.appwriteUserId)
|
||||
}
|
||||
|
||||
await refreshPreviewCookie(req, res, {
|
||||
userId: session.appwriteUserId,
|
||||
customerId: customer.$id,
|
||||
email: customer.email || session.email || '',
|
||||
name: customer.name || session.name || '',
|
||||
role: 'customer',
|
||||
labels: [],
|
||||
authSource: 'kundenbereich',
|
||||
})
|
||||
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
role: 'customer',
|
||||
customer: sanitizeCustomer(customer),
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
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
|
||||
78
server/routes/customerData.js
Normal file
78
server/routes/customerData.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { fetchInvoicePdf, getInvoiceRaw, listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** Rechnungen des eingeloggten Kunden (InvoiceNinja, sofern verknuepft). */
|
||||
router.get('/invoices', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customer = await getDocument(
|
||||
config.collections.customers,
|
||||
getSessionCustomerId(req)
|
||||
).catch(() => null)
|
||||
if (!customer) {
|
||||
return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
}
|
||||
if (!customer.invoiceNinjaClientId) {
|
||||
return res.json({ linked: false, invoices: [] })
|
||||
}
|
||||
const invoices = await listInvoicesForClient(customer.invoiceNinjaClientId)
|
||||
return res.json({ linked: true, invoices })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Rechnungen konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Rechnungs-PDF des eingeloggten Kunden (Download ueber Server-Proxy). */
|
||||
router.get('/invoices/:invoiceId/pdf', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customer = await getDocument(
|
||||
config.collections.customers,
|
||||
getSessionCustomerId(req)
|
||||
).catch(() => null)
|
||||
if (!customer?.invoiceNinjaClientId) {
|
||||
return res.status(404).json({ error: 'Rechnung nicht gefunden' })
|
||||
}
|
||||
|
||||
const invoice = await getInvoiceRaw(req.params.invoiceId).catch(() => null)
|
||||
// Ownership-Pruefung: Rechnung muss zum InvoiceNinja-Client des Kunden gehoeren
|
||||
if (!invoice || invoice.client_id !== customer.invoiceNinjaClientId) {
|
||||
return res.status(404).json({ error: 'Rechnung nicht gefunden' })
|
||||
}
|
||||
|
||||
const invitationKey = invoice.invitations?.[0]?.key
|
||||
if (!invitationKey) {
|
||||
return res.status(404).json({ error: 'Kein PDF verfuegbar' })
|
||||
}
|
||||
|
||||
const pdf = await fetchInvoicePdf(invitationKey)
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="Rechnung-${invoice.number || invoice.id}.pdf"`,
|
||||
'Cache-Control': 'no-store',
|
||||
})
|
||||
return res.end(pdf)
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.message || 'PDF konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Ticket-/Arbeitshistorie des eingeloggten Kunden ("was wurde gemacht"). */
|
||||
router.get('/activity', requireSession, async (req, res) => {
|
||||
try {
|
||||
const tickets = await getCustomerActivity(getSessionCustomerId(req), {
|
||||
ticketLimit: 25,
|
||||
// Interne Akquise-Tickets gehoeren nicht in die Kundenansicht
|
||||
excludeTypes: ['Akquise'],
|
||||
})
|
||||
return res.json({ tickets })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
382
server/routes/portalAdmin.js
Normal file
382
server/routes/portalAdmin.js
Normal file
@@ -0,0 +1,382 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import {
|
||||
parsePortalSession,
|
||||
requireAdminSession,
|
||||
setPortalSession,
|
||||
} from '../middleware/session.js'
|
||||
import {
|
||||
createDocument,
|
||||
getDocument,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
Query,
|
||||
ID,
|
||||
} from '../services/appwriteAdmin.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
import { sanitizeMessage } from './chat.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAdminSession)
|
||||
|
||||
function norm(value) {
|
||||
return String(value || '').toLowerCase()
|
||||
}
|
||||
|
||||
function isDeployed(project) {
|
||||
return ['ready', 'deployed'].includes(norm(project.provisioningStatus || project.status))
|
||||
}
|
||||
|
||||
function customerNameMap(customers) {
|
||||
const map = {}
|
||||
for (const c of customers) map[c.$id] = c.name || c.code || c.$id
|
||||
return map
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Übersicht --
|
||||
router.get('/overview', async (_req, res) => {
|
||||
try {
|
||||
const [projects, customers, access] = await Promise.all([
|
||||
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
|
||||
listDocuments(config.collections.customers, [Query.limit(500)]),
|
||||
listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]),
|
||||
])
|
||||
const nameById = customerNameMap(customers)
|
||||
|
||||
const stageOf = (c) => {
|
||||
const s = norm(c.customerStatus)
|
||||
if (s === 'lead') return 'lead'
|
||||
if (s === 'lost') return 'lost'
|
||||
return 'customer'
|
||||
}
|
||||
|
||||
const recentLogins = access
|
||||
.filter((a) => a.lastLoginAt)
|
||||
.sort((a, b) => String(b.lastLoginAt).localeCompare(String(a.lastLoginAt)))
|
||||
.slice(0, 5)
|
||||
.map((a) => ({
|
||||
customerName: nameById[a.customerId] || a.customerId,
|
||||
lastLoginAt: a.lastLoginAt,
|
||||
}))
|
||||
|
||||
const recentProjects = [...projects]
|
||||
.sort((a, b) =>
|
||||
String(b.updatedAt || b.$updatedAt || '').localeCompare(String(a.updatedAt || a.$updatedAt || ''))
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((p) => ({
|
||||
projectName: p.projectName || p.subdomain || p.repoFullName || '',
|
||||
subdomain: p.subdomain || '',
|
||||
status: p.status || '',
|
||||
customerName: p.customerId ? nameById[p.customerId] || '' : '',
|
||||
updatedAt: p.updatedAt || p.$updatedAt || '',
|
||||
}))
|
||||
|
||||
return res.json({
|
||||
projects: {
|
||||
total: projects.length,
|
||||
deployed: projects.filter(isDeployed).length,
|
||||
withoutWebsite: projects.filter((p) => !p.subdomain).length,
|
||||
assigned: projects.filter((p) => p.customerId).length,
|
||||
},
|
||||
customers: {
|
||||
total: customers.length,
|
||||
leads: customers.filter((c) => stageOf(c) === 'lead').length,
|
||||
fixed: customers.filter((c) => stageOf(c) === 'customer').length,
|
||||
portalEnabled: customers.filter((c) => c.portalAccessEnabled).length,
|
||||
},
|
||||
recentLogins,
|
||||
recentProjects,
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Übersicht konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------- Projektsuche --
|
||||
router.get('/projects', async (req, res) => {
|
||||
const search = norm(req.query.search)
|
||||
try {
|
||||
const [projects, customers] = await Promise.all([
|
||||
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
|
||||
listDocuments(config.collections.customers, [Query.limit(500)]),
|
||||
])
|
||||
const nameById = customerNameMap(customers)
|
||||
|
||||
let items = projects
|
||||
.filter((p) => !p.archived)
|
||||
.map((p) => ({
|
||||
id: p.$id,
|
||||
projectName: p.projectName || '',
|
||||
subdomain: p.subdomain || '',
|
||||
previewUrl: p.previewUrl || '',
|
||||
liveDomain: p.liveDomain || '',
|
||||
status: p.status || '',
|
||||
provisioningStatus: p.provisioningStatus || '',
|
||||
projectType: p.projectType || (p.subdomain ? 'website' : 'repo'),
|
||||
giteaRepoUrl: p.giteaRepoUrl || '',
|
||||
repoFullName: p.repoFullName || '',
|
||||
customerId: p.customerId || '',
|
||||
customerName: p.customerId ? nameById[p.customerId] || p.customerId : '',
|
||||
updatedAt: p.updatedAt || p.$updatedAt || '',
|
||||
}))
|
||||
|
||||
if (search) {
|
||||
items = items.filter((p) =>
|
||||
[p.projectName, p.subdomain, p.repoFullName, p.customerName, p.status].some((v) =>
|
||||
norm(v).includes(search)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
items.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
|
||||
return res.json({ projects: items })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Projekte konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------- Kundenliste --
|
||||
router.get('/customers', async (req, res) => {
|
||||
const search = norm(req.query.search)
|
||||
try {
|
||||
const [customers, access, projects] = await Promise.all([
|
||||
listDocuments(config.collections.customers, [Query.orderAsc('name'), Query.limit(500)]),
|
||||
listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]),
|
||||
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
|
||||
])
|
||||
|
||||
const lastLoginByCustomer = {}
|
||||
for (const a of access) {
|
||||
if (a.customerId && a.lastLoginAt) lastLoginByCustomer[a.customerId] = a.lastLoginAt
|
||||
}
|
||||
const projectCount = {}
|
||||
for (const p of projects) {
|
||||
if (p.customerId) projectCount[p.customerId] = (projectCount[p.customerId] || 0) + 1
|
||||
}
|
||||
|
||||
let items = customers
|
||||
.filter((c) => !c.archived)
|
||||
.map((c) => ({
|
||||
id: c.$id,
|
||||
code: c.code || '',
|
||||
name: c.name || '',
|
||||
email: c.email || '',
|
||||
phone: c.phone || '',
|
||||
location: c.location || '',
|
||||
customerStatus: c.customerStatus || '',
|
||||
portalAccessEnabled: Boolean(c.portalAccessEnabled),
|
||||
invoiceNinjaLinked: Boolean(c.invoiceNinjaClientId),
|
||||
lastLoginAt: lastLoginByCustomer[c.$id] || '',
|
||||
projectCount: projectCount[c.$id] || 0,
|
||||
}))
|
||||
|
||||
if (search) {
|
||||
items = items.filter((c) =>
|
||||
[c.code, c.name, c.email, c.location].some((v) => norm(v).includes(search))
|
||||
)
|
||||
}
|
||||
|
||||
return res.json({ customers: items })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Kunden konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------- Kundennummer/Stammdaten pflegen --
|
||||
router.patch('/customers/:customerId', async (req, res) => {
|
||||
const { code, location, phone } = req.body || {}
|
||||
const data = { updatedAt: new Date().toISOString() }
|
||||
if (code !== undefined) data.code = String(code).trim()
|
||||
if (location !== undefined) data.location = String(location).trim()
|
||||
if (phone !== undefined) data.phone = String(phone).trim()
|
||||
|
||||
try {
|
||||
const updated = await updateDocument(config.collections.customers, req.params.customerId, data)
|
||||
return res.json({
|
||||
success: true,
|
||||
customer: { id: updated.$id, code: updated.code || '', name: updated.name || '' },
|
||||
})
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message || 'Kunde konnte nicht aktualisiert werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------- Aktivität + Rechnungen eines Kunden --
|
||||
router.get('/customers/:customerId/activity', async (req, res) => {
|
||||
try {
|
||||
const tickets = await getCustomerActivity(req.params.customerId, { ticketLimit: 50 })
|
||||
return res.json({ tickets })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/customers/:customerId/invoices', async (req, res) => {
|
||||
try {
|
||||
const customer = await getDocument(config.collections.customers, req.params.customerId)
|
||||
if (!customer.invoiceNinjaClientId) {
|
||||
return res.json({ linked: false, invoices: [] })
|
||||
}
|
||||
const invoices = await listInvoicesForClient(customer.invoiceNinjaClientId, { includeDrafts: true })
|
||||
return res.json({ linked: true, invoices })
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message || 'Rechnungen konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------- Chat --
|
||||
router.get('/chats', async (_req, res) => {
|
||||
try {
|
||||
const [messages, customers] = await Promise.all([
|
||||
listDocuments(config.collections.portalMessages, [
|
||||
Query.orderDesc('createdAt'),
|
||||
Query.limit(500),
|
||||
]),
|
||||
listDocuments(config.collections.customers, [Query.limit(500)]),
|
||||
])
|
||||
const nameById = customerNameMap(customers)
|
||||
|
||||
const byCustomer = new Map()
|
||||
for (const msg of messages) {
|
||||
const entry = byCustomer.get(msg.customerId) || {
|
||||
customerId: msg.customerId,
|
||||
customerName: nameById[msg.customerId] || msg.customerId,
|
||||
lastText: msg.text || '',
|
||||
lastSender: msg.sender || '',
|
||||
lastAt: msg.createdAt || '',
|
||||
unreadFromCustomer: 0,
|
||||
}
|
||||
if (msg.sender === 'customer' && !msg.readByAdmin) entry.unreadFromCustomer += 1
|
||||
byCustomer.set(msg.customerId, entry)
|
||||
}
|
||||
|
||||
const chats = [...byCustomer.values()].sort((a, b) =>
|
||||
String(b.lastAt).localeCompare(String(a.lastAt))
|
||||
)
|
||||
return res.json({ chats })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Chats konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/chats/unread-count', async (_req, res) => {
|
||||
try {
|
||||
const docs = await listDocuments(config.collections.portalMessages, [
|
||||
Query.equal('readByAdmin', false),
|
||||
Query.equal('sender', 'customer'),
|
||||
Query.limit(100),
|
||||
])
|
||||
return res.json({ unread: docs.length })
|
||||
} catch {
|
||||
return res.json({ unread: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/chats/:customerId/messages', async (req, res) => {
|
||||
try {
|
||||
const queries = [
|
||||
Query.equal('customerId', req.params.customerId),
|
||||
Query.orderAsc('createdAt'),
|
||||
Query.limit(200),
|
||||
]
|
||||
const since = String(req.query.since || '').trim()
|
||||
if (since) queries.push(Query.greaterThan('createdAt', since))
|
||||
|
||||
const docs = await listDocuments(config.collections.portalMessages, queries)
|
||||
return res.json({ messages: docs.map(sanitizeMessage) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Nachrichten konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/chats/:customerId/messages', async (req, res) => {
|
||||
const text = String(req.body?.text || '').trim()
|
||||
if (!text || text.length > 2000) {
|
||||
return res.status(400).json({ error: 'Nachricht muss zwischen 1 und 2000 Zeichen lang sein.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
|
||||
if (!customer) {
|
||||
return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
}
|
||||
|
||||
const session = req.session
|
||||
const doc = await createDocument(
|
||||
config.collections.portalMessages,
|
||||
{
|
||||
customerId: customer.$id,
|
||||
sender: 'admin',
|
||||
senderName: session.adminName || session.name || 'Webklar-Team',
|
||||
text,
|
||||
readByAdmin: true,
|
||||
readByCustomer: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
ID.unique()
|
||||
)
|
||||
return res.status(201).json({ message: sanitizeMessage(doc) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/chats/:customerId/read', async (req, res) => {
|
||||
try {
|
||||
const docs = await listDocuments(config.collections.portalMessages, [
|
||||
Query.equal('customerId', req.params.customerId),
|
||||
Query.equal('readByAdmin', false),
|
||||
Query.equal('sender', 'customer'),
|
||||
Query.limit(100),
|
||||
])
|
||||
for (const doc of docs) {
|
||||
await updateDocument(config.collections.portalMessages, doc.$id, { readByAdmin: true })
|
||||
}
|
||||
return res.json({ updated: docs.length })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Fehler beim Markieren' })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------- Als Kunde ansehen --
|
||||
router.post('/view-as/:customerId', async (req, res) => {
|
||||
try {
|
||||
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
|
||||
if (!customer) {
|
||||
return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
}
|
||||
|
||||
const session = req.session
|
||||
setPortalSession(res, {
|
||||
role: 'admin',
|
||||
viewAs: true,
|
||||
appwriteUserId: session.appwriteUserId,
|
||||
adminName: session.adminName || session.name || 'Admin',
|
||||
adminEmail: session.adminEmail || session.email || '',
|
||||
customerId: customer.$id,
|
||||
name: customer.name || '',
|
||||
email: customer.email || '',
|
||||
})
|
||||
return res.json({ success: true, customer: { id: customer.$id, name: customer.name || '' } })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Ansicht konnte nicht gewechselt werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/exit-view-as', (req, res) => {
|
||||
const session = parsePortalSession(req) || req.session
|
||||
setPortalSession(res, {
|
||||
role: 'admin',
|
||||
appwriteUserId: session.appwriteUserId,
|
||||
name: session.adminName || session.name || 'Admin',
|
||||
email: session.adminEmail || session.email || '',
|
||||
})
|
||||
return res.json({ success: true })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,10 +1,25 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getProjectGitInsights, getProjectFileOverview } from '../services/projectInsights.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/**
|
||||
* Laedt das Projekt und stellt sicher, dass es dem eingeloggten Kunden gehoert.
|
||||
* Fremde/unbekannte IDs antworten einheitlich mit 404 (kein Enumeration-Leak).
|
||||
*/
|
||||
async function loadOwnProject(req, res, next) {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null)
|
||||
if (!project || !customerId || project.customerId !== customerId) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||
}
|
||||
req.project = project
|
||||
return next()
|
||||
}
|
||||
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
if (!customerId) {
|
||||
@@ -36,4 +51,64 @@ router.get('/', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Letzte Git-Pushes + Projektgroesse. */
|
||||
router.get('/:id/git', requireSession, loadOwnProject, async (req, res) => {
|
||||
try {
|
||||
const { repo, commits } = await getProjectGitInsights(req.project)
|
||||
return res.json({ repo, commits })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Git-Daten konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Datei-Uebersicht (Top-Level, Groessensummen). */
|
||||
router.get('/:id/files', requireSession, loadOwnProject, async (req, res) => {
|
||||
try {
|
||||
const summary = await getProjectFileOverview(req.project)
|
||||
if (!summary) return res.json({ totalFiles: 0, totalSizeBytes: 0, truncated: false, topLevel: [] })
|
||||
return res.json(summary)
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Datei-Uebersicht konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Ticket-Arbeiten am Projekt (inkl. Git-Push-Worksheets, "Zeit offen" markiert). */
|
||||
router.get('/:id/work', requireSession, loadOwnProject, async (req, res) => {
|
||||
try {
|
||||
const project = req.project
|
||||
if (!project.ticketId) {
|
||||
return res.json({ ticket: null, worksheets: [] })
|
||||
}
|
||||
|
||||
const ticket = await getDocument(config.collections.workorders, project.ticketId).catch(() => null)
|
||||
const worksheets = await listDocuments(config.collections.worksheets, [
|
||||
Query.equal('workorderId', project.ticketId),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(50),
|
||||
]).catch(() => [])
|
||||
|
||||
const sanitized = worksheets
|
||||
// GIT-Eintraege einschliessen, sonstige reine Kommentare ausblenden
|
||||
.filter((ws) => ws.serviceType === 'GIT' || !ws.isComment)
|
||||
.map((ws) => ({
|
||||
wsid: ws.wsid || '',
|
||||
date: ws.endDate || ws.startDate || '',
|
||||
time: ws.endTime || ws.startTime || '',
|
||||
employeeName: ws.employeeName || ws.employeeShort || '',
|
||||
serviceType: ws.serviceType || '',
|
||||
details: ws.details || '',
|
||||
totalTime: Number(ws.totalTime || 0),
|
||||
isGit: ws.serviceType === 'GIT',
|
||||
pending: ws.serviceType === 'GIT' && !ws.startTime,
|
||||
}))
|
||||
|
||||
return res.json({
|
||||
ticket: ticket ? { woid: ticket.woid || '', topic: ticket.topic || '', status: ticket.status || '' } : null,
|
||||
worksheets: sanitized,
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Arbeiten konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { upsertWebsiteProjectByRepo } from '../../services/appwriteAdmin.js'
|
||||
@@ -7,7 +8,10 @@ import {
|
||||
runDeploy,
|
||||
buildPreviewUrl,
|
||||
} from '../../services/previewDeploy.js'
|
||||
import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js'
|
||||
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
|
||||
import { ensureProjectTicket } from '../../services/ticketAdmin.js'
|
||||
import { invalidateProjectCache } from '../../services/projectInsights.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -15,8 +19,21 @@ function verifyWebhookToken(req) {
|
||||
const token = config.gitea.webhookToken
|
||||
if (!token) return false
|
||||
const queryToken = req.query.token
|
||||
const headerToken = req.get('X-Gitea-Token') || req.get('X-Gogs-Signature')
|
||||
return queryToken === token || headerToken === token
|
||||
const headerToken = req.get('X-Gitea-Token')
|
||||
if (queryToken === token || headerToken === token) return true
|
||||
|
||||
// Gitea sendet das Webhook-Secret nicht im Klartext, sondern als
|
||||
// HMAC-SHA256-Signatur des Request-Bodys (X-Gitea-Signature).
|
||||
const signature = req.get('X-Gitea-Signature') || req.get('X-Gogs-Signature')
|
||||
if (signature && req.rawBody) {
|
||||
const expected = crypto.createHmac('sha256', token).update(req.rawBody).digest('hex')
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
router.post('/gitea', async (req, res) => {
|
||||
@@ -39,10 +56,24 @@ router.post('/gitea', async (req, res) => {
|
||||
const branch = ref.replace('refs/heads/', '') || 'main'
|
||||
|
||||
try {
|
||||
const previewConfigFile = await fetchPreviewConfig(repoFullName, branch)
|
||||
let previewConfigFile = await fetchPreviewConfig(repoFullName, branch)
|
||||
// Selbstheilung: Wurde .webklar-preview.json im Repo geloescht (z.B. beim
|
||||
// Leerraeumen/Ueberarbeiten der Seite), aber das Projekt ist in Appwrite als
|
||||
// gehostet bekannt, dann Config aus dem gespeicherten Stand wiederherstellen
|
||||
// (Datei ins Repo zurueckschreiben) und normal weiter-deployen.
|
||||
if (!previewConfigFile) {
|
||||
try {
|
||||
const restored = await ensureRepoPreviewConfig(repoFullName, branch)
|
||||
if (restored) previewConfigFile = restored
|
||||
} catch (err) {
|
||||
console.error('[webhook] preview-config-Wiederherstellung fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
const previewConfig = previewConfigFile || defaultPreviewConfig(repoFullName)
|
||||
const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
|
||||
const subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
|
||||
// Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen -
|
||||
// Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website.
|
||||
const subdomain = hasPreview ? previewConfig.subdomain || repoFullName.split('/').pop() : ''
|
||||
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
|
||||
|
||||
let deployResult = { deployed: false, skipped: true }
|
||||
@@ -79,17 +110,29 @@ router.post('/gitea', async (req, res) => {
|
||||
}
|
||||
|
||||
const project = await upsertWebsiteProjectByRepo(repoFullName, projectPayload)
|
||||
invalidateProjectCache(repoFullName)
|
||||
|
||||
let gitWorksheet = null
|
||||
if (project.ticketId && commitSha && commitSha !== '0000000000000000000000000000000000000000') {
|
||||
gitWorksheet = await createGitPushWorksheet({
|
||||
ticketId: project.ticketId,
|
||||
repoFullName,
|
||||
branch,
|
||||
commits,
|
||||
pusher,
|
||||
commitSha,
|
||||
})
|
||||
if (commitSha && commitSha !== '0000000000000000000000000000000000000000') {
|
||||
// Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde)
|
||||
let ticketId = project.ticketId
|
||||
if (!ticketId) {
|
||||
try {
|
||||
ticketId = await ensureProjectTicket(project, { repoFullName })
|
||||
} catch (err) {
|
||||
console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
if (ticketId) {
|
||||
gitWorksheet = await createGitPushWorksheet({
|
||||
ticketId,
|
||||
repoFullName,
|
||||
branch,
|
||||
commits,
|
||||
pusher,
|
||||
commitSha,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
|
||||
@@ -75,6 +75,7 @@ export const Query = {
|
||||
values: Array.isArray(value) ? value : [value],
|
||||
}),
|
||||
limit: (n) => ({ method: 'limit', values: [n] }),
|
||||
greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }),
|
||||
orderDesc: (attribute) => ({ method: 'orderDesc', attribute }),
|
||||
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
|
||||
}
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
import { config } from '../config.js'
|
||||
|
||||
const DEBUG_LOG = (location, message, data, hypothesisId) => {
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7281/ingest/30e8e71c-b377-4e72-84f9-593826c6d234', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '80bbfc' },
|
||||
body: JSON.stringify({
|
||||
sessionId: '80bbfc',
|
||||
location,
|
||||
message,
|
||||
data,
|
||||
hypothesisId,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {})
|
||||
// #endregion
|
||||
}
|
||||
|
||||
function appwriteHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -78,7 +61,6 @@ export async function loginWithAppwrite(email, password) {
|
||||
const now = Date.now()
|
||||
if (now < appwriteLoginBlockedUntil) {
|
||||
const waitSec = Math.ceil((appwriteLoginBlockedUntil - now) / 1000)
|
||||
DEBUG_LOG('appwriteClient.js:cooldown', 'login blocked locally', { waitSec }, 'H8')
|
||||
const error = new Error(
|
||||
`Zu viele Anmeldeversuche. Bitte warte noch ${waitSec} Sekunden.`
|
||||
)
|
||||
@@ -92,15 +74,7 @@ export async function loginWithAppwrite(email, password) {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
})
|
||||
DEBUG_LOG('appwriteClient.js:session', 'createEmailPasswordSession ok', {
|
||||
hasUserId: Boolean(session?.userId),
|
||||
sessionId: session?.$id || null,
|
||||
}, 'H6')
|
||||
} catch (err) {
|
||||
DEBUG_LOG('appwriteClient.js:session', 'createEmailPasswordSession fail', {
|
||||
message: err?.message?.slice(0, 120),
|
||||
code: err?.code,
|
||||
}, 'H1')
|
||||
if (err.status === 429) {
|
||||
appwriteLoginBlockedUntil = Date.now() + APPWRITE_RATE_LIMIT_COOLDOWN_MS
|
||||
}
|
||||
@@ -116,7 +90,6 @@ export async function loginWithAppwrite(email, password) {
|
||||
}
|
||||
|
||||
const user = { $id: session.userId, email, name: '' }
|
||||
DEBUG_LOG('appwriteClient.js:user', 'using session userId', { userId: user.$id }, 'H7')
|
||||
|
||||
clearLoginCooldown()
|
||||
return user
|
||||
|
||||
60
server/services/customerActivity.js
Normal file
60
server/services/customerActivity.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Tickets + zugehoerige Worksheets eines Kunden ("was wurde gemacht").
|
||||
* excludeTypes: Ticket-Typen, die der Kunde nicht sehen soll (z. B. Akquise).
|
||||
*/
|
||||
export async function getCustomerActivity(customerId, { ticketLimit = 25, excludeTypes = [] } = {}) {
|
||||
if (!customerId) return []
|
||||
|
||||
let workorders = await listDocuments(config.collections.workorders, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(ticketLimit),
|
||||
])
|
||||
|
||||
if (excludeTypes.length) {
|
||||
const blocked = new Set(excludeTypes.map((t) => t.toLowerCase()))
|
||||
workorders = workorders.filter((w) => !blocked.has(String(w.type || '').toLowerCase()))
|
||||
}
|
||||
|
||||
const ids = workorders.map((w) => w.$id)
|
||||
let worksheets = []
|
||||
if (ids.length) {
|
||||
worksheets = await listDocuments(config.collections.worksheets, [
|
||||
Query.equal('workorderId', ids),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(300),
|
||||
]).catch(() => [])
|
||||
}
|
||||
|
||||
const sheetsByWorkorder = {}
|
||||
for (const ws of worksheets) {
|
||||
// Git-Push-Eintraege dem Kunden sofort zeigen, sonstige reine Kommentare nicht
|
||||
if (ws.isComment && ws.serviceType !== 'GIT') continue
|
||||
const list = (sheetsByWorkorder[ws.workorderId] ||= [])
|
||||
list.push({
|
||||
wsid: ws.wsid || '',
|
||||
employeeName: ws.employeeName || ws.employeeShort || '',
|
||||
serviceType: ws.serviceType || '',
|
||||
newStatus: ws.newStatus || '',
|
||||
totalTime: ws.totalTime || '',
|
||||
date: ws.endDate || ws.startDate || '',
|
||||
details: ws.details || '',
|
||||
isGit: ws.serviceType === 'GIT',
|
||||
pending: ws.serviceType === 'GIT' && !ws.startTime,
|
||||
createdAt: ws.createdAt || ws.$createdAt || '',
|
||||
})
|
||||
}
|
||||
|
||||
return workorders.map((w) => ({
|
||||
id: w.$id,
|
||||
woid: w.woid || '',
|
||||
topic: w.topic || w.title || '',
|
||||
status: w.status || '',
|
||||
type: w.type || '',
|
||||
createdAt: w.createdAt || w.$createdAt || '',
|
||||
worksheets: (sheetsByWorkorder[w.$id] || []).slice(0, 20),
|
||||
}))
|
||||
}
|
||||
@@ -39,6 +39,7 @@ function sanitizeCustomer(customer, { portalPassword } = {}) {
|
||||
portalAccessEnabled: Boolean(customer.portalAccessEnabled),
|
||||
appwriteUserId: customer.appwriteUserId || '',
|
||||
customerStatus: customer.customerStatus || '',
|
||||
archived: Boolean(customer.archived),
|
||||
portalPassword: storedPassword || '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ import {
|
||||
} from './appwriteAdmin.js'
|
||||
|
||||
const WSID_START = 100000
|
||||
// worksheets.details ist auf 4000 Zeichen begrenzt
|
||||
const DETAILS_MAX_LENGTH = 3900
|
||||
|
||||
function formatToday() {
|
||||
// dd.mm.yyyy - gleiches Format wie CreateWorksheetModal der Ticketsystem-SPA
|
||||
const d = new Date()
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}${m}${day}`
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}.${m}.${d.getFullYear()}`
|
||||
}
|
||||
|
||||
function formatTimeNow() {
|
||||
@@ -49,10 +51,16 @@ function formatCommitLines(commits, repoFullName) {
|
||||
return commits
|
||||
.map((commit) => {
|
||||
const sha = (commit.id || commit.sha || '').slice(0, 7)
|
||||
const message = (commit.message || '').split('\n')[0]
|
||||
// Commit-Titel + Beschreibung (Body) uebernehmen
|
||||
const [title, ...bodyLines] = String(commit.message || '').trim().split('\n')
|
||||
const author = commit.author?.name || commit.committer?.name || 'Unbekannt'
|
||||
const url = commit.url || `${config.gitea.baseUrl}/${repoFullName}/commit/${commit.id || commit.sha}`
|
||||
return `- [${sha}](${url}): ${message} (${author})`
|
||||
const body = bodyLines
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')
|
||||
return `- [${sha}](${url}): ${title} (${author})${body ? `\n${body}` : ''}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
@@ -85,13 +93,18 @@ export async function createGitPushWorksheet({
|
||||
const wsid = await generateNextWsid()
|
||||
const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea'
|
||||
const latestCommit = commits[commits.length - 1] || commits[0]
|
||||
const details = [
|
||||
let details = [
|
||||
`Git Push auf \`${repoFullName}\` (Branch: ${branch})`,
|
||||
`Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`,
|
||||
'',
|
||||
formatCommitLines(commits, repoFullName),
|
||||
].join('\n')
|
||||
if (details.length > DETAILS_MAX_LENGTH) {
|
||||
details = `${details.slice(0, DETAILS_MAX_LENGTH)}\n… (gekuerzt)`
|
||||
}
|
||||
|
||||
// Endzeit = Push-Zeitpunkt. Startzeit bleibt leer und wird im Ticketsystem
|
||||
// nachgetragen ("Zeit nachtragen") - erst dann zaehlt die Arbeitszeit.
|
||||
const today = formatToday()
|
||||
const worksheet = await createDocument(
|
||||
config.collections.worksheets,
|
||||
@@ -109,7 +122,7 @@ export async function createGitPushWorksheet({
|
||||
newResponseLevel: workorder.response || '',
|
||||
totalTime: 0,
|
||||
startDate: today,
|
||||
startTime: formatTimeNow(),
|
||||
startTime: '',
|
||||
endDate: today,
|
||||
endTime: formatTimeNow(),
|
||||
details,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { config } from '../config.js'
|
||||
import { upsertWebsiteProjectByRepo, listDocuments, Query } from './appwriteAdmin.js'
|
||||
import {
|
||||
upsertWebsiteProjectByRepo,
|
||||
listDocuments,
|
||||
Query,
|
||||
getDocument,
|
||||
updateDocument,
|
||||
} from './appwriteAdmin.js'
|
||||
import {
|
||||
runDeploy,
|
||||
buildPreviewUrl,
|
||||
removeDeploy,
|
||||
syncPreviewTls,
|
||||
fetchPreviewConfig,
|
||||
} from './previewDeploy.js'
|
||||
|
||||
function giteaHeaders() {
|
||||
@@ -122,6 +131,8 @@ export async function syncReposToAppwrite() {
|
||||
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
||||
giteaRepoName: repo.name || repoFullName.split('/').pop(),
|
||||
repoFullName,
|
||||
// In Gitea archivierte Repos werden im Ticketsystem ausgeblendet.
|
||||
archived: Boolean(repo.archived),
|
||||
}
|
||||
|
||||
if (subdomain) payload.subdomain = subdomain
|
||||
@@ -220,6 +231,47 @@ async function updatePreviewConfigFile(repoFullName, previewConfig, branch = 'ma
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Selbstheilung fuer eine geloeschte/fehlende .webklar-preview.json:
|
||||
* Rekonstruiert die Hosting-Config aus dem in Appwrite gespeicherten
|
||||
* Projektstand (Subdomain, Typ, Name), schreibt sie ins Repo zurueck und liefert
|
||||
* sie fuer einen sofortigen Deploy zurueck. Gibt null zurueck, wenn zu dem Repo
|
||||
* kein gehostetes, aktives Projekt bekannt ist (dann bleibt es Repo-only).
|
||||
*/
|
||||
export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') {
|
||||
if (!repoFullName) return null
|
||||
const docs = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('repoFullName', repoFullName),
|
||||
Query.limit(1),
|
||||
])
|
||||
const doc = docs[0]
|
||||
if (!doc || doc.archived) return null
|
||||
|
||||
const type = deriveProjectType(doc)
|
||||
const hasWebsite = type === 'preview' || type === 'website' || type === 'template'
|
||||
const subdomain = doc.subdomain || ''
|
||||
if (!hasWebsite || !subdomain) return null
|
||||
|
||||
const previewConfig = {
|
||||
enabled: true,
|
||||
type: 'static',
|
||||
branch,
|
||||
displayName: doc.projectName || repoFullName.split('/').pop(),
|
||||
templateName: doc.templateName || config.gitea.templateRepo,
|
||||
projectType: type,
|
||||
public: type === 'website',
|
||||
template: type === 'template',
|
||||
subdomain,
|
||||
}
|
||||
|
||||
try {
|
||||
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
|
||||
} catch (err) {
|
||||
console.error('[ensureRepoPreviewConfig] .webklar-preview.json zurueckschreiben fehlgeschlagen:', err.message)
|
||||
}
|
||||
return previewConfig
|
||||
}
|
||||
|
||||
export async function createProjectFromTemplate({
|
||||
repoName,
|
||||
displayName,
|
||||
@@ -300,6 +352,325 @@ export async function createProjectFromTemplate({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet den Projekt-Typ aus einem bestehenden Dokument ab, wenn im Request
|
||||
* kein expliziter Typ mitgegeben wurde.
|
||||
* preview -> Website mit Login-geschuetzter Preview (Standard)
|
||||
* website -> oeffentliche Website ohne Login
|
||||
* template -> Website-Vorlage (login-geschuetzt, nur Admin, in Vorlagen-Ansicht)
|
||||
* project -> internes Projekt ohne Hosting/Subdomain
|
||||
*/
|
||||
function deriveProjectType(doc) {
|
||||
if (doc?.isTemplate) return 'template'
|
||||
if (doc?.isPublic) return 'website'
|
||||
if (doc?.subdomain) return 'preview'
|
||||
return 'project'
|
||||
}
|
||||
|
||||
async function patchGiteaRepo(repoFullName, body) {
|
||||
const [owner, repo] = String(repoFullName || '').split('/')
|
||||
if (!owner || !repo) return null
|
||||
return giteaFetch(`/repos/${owner}/${repo}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearbeitet ein bestehendes Website-Projekt (Name / Subdomain / Typ) und
|
||||
* synchronisiert die Aenderung ueber ALLE Systeme:
|
||||
* - Appwrite-Dokument (projectName, subdomain, previewUrl, projectType, isPublic, status)
|
||||
* - Gitea: .webklar-preview.json (enabled/subdomain/displayName/public) + Repo-Beschreibung
|
||||
* - Preview-Hosting: Redeploy in neues Verzeichnis, altes entfernen, Traefik-TLS neu
|
||||
* Der Gitea-Repo-Slug/die Klon-URL bleiben bewusst stabil (kein Repo-Rename).
|
||||
*/
|
||||
export async function updateWebsiteProject(projectId, { projectName, subdomain, projectType } = {}) {
|
||||
const doc = await getDocument(config.collections.websiteProjects, projectId)
|
||||
if (!doc) {
|
||||
const error = new Error('Projekt nicht gefunden')
|
||||
error.status = 404
|
||||
throw error
|
||||
}
|
||||
|
||||
const repoFullName = doc.repoFullName || ''
|
||||
const [owner, repoSlug] = repoFullName.split('/')
|
||||
|
||||
const nextName = String(projectName ?? doc.projectName ?? '').trim() || doc.projectName || repoSlug || ''
|
||||
const nextType = projectType || deriveProjectType(doc)
|
||||
// template = login-geschuetzte Vorlage: braucht Preview (wie 'preview'), aber
|
||||
// nicht oeffentlich; wird als isTemplate markiert.
|
||||
const hasWebsite = nextType === 'preview' || nextType === 'website' || nextType === 'template'
|
||||
const isPublic = nextType === 'website'
|
||||
const isTemplate = nextType === 'template'
|
||||
|
||||
const oldSubdomain = doc.subdomain || ''
|
||||
let nextSubdomain = ''
|
||||
if (hasWebsite) {
|
||||
const requested = slugify(subdomain !== undefined ? subdomain : oldSubdomain)
|
||||
nextSubdomain = requested || slugify(repoSlug)
|
||||
}
|
||||
const subdomainChanged = nextSubdomain !== oldSubdomain
|
||||
|
||||
// Subdomain-Eindeutigkeit pruefen (eigenes Dokument ignorieren)
|
||||
if (hasWebsite && subdomainChanged && nextSubdomain) {
|
||||
const conflicts = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('subdomain', nextSubdomain),
|
||||
Query.limit(5),
|
||||
])
|
||||
if (conflicts.some((c) => c.$id !== projectId)) {
|
||||
const error = new Error(`Subdomain "${nextSubdomain}" ist bereits vergeben`)
|
||||
error.status = 409
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let branch = 'main'
|
||||
try {
|
||||
const info = await getRepoInfo(repoFullName)
|
||||
branch = info?.defaultBranch || 'main'
|
||||
} catch {
|
||||
/* Default main */
|
||||
}
|
||||
|
||||
// Deploy-Typ (z.B. node_build) und index-Unterordner aus der vorhandenen
|
||||
// Repo-Config uebernehmen - sonst wuerde jedes Bearbeiten im Portal
|
||||
// App-Projekte (Expo/Vite/React) wieder auf "static" zuruecksetzen.
|
||||
let existingCfg = null
|
||||
try {
|
||||
existingCfg = await fetchPreviewConfig(repoFullName, branch)
|
||||
} catch {
|
||||
/* keine bestehende Config -> Defaults */
|
||||
}
|
||||
|
||||
const previewConfig = {
|
||||
enabled: hasWebsite,
|
||||
type: existingCfg?.type || 'static',
|
||||
...(existingCfg?.index ? { index: existingCfg.index } : {}),
|
||||
branch,
|
||||
displayName: nextName,
|
||||
templateName: doc.templateName || config.gitea.templateRepo,
|
||||
projectType: nextType,
|
||||
public: isPublic,
|
||||
template: isTemplate,
|
||||
...(hasWebsite && nextSubdomain ? { subdomain: nextSubdomain } : {}),
|
||||
}
|
||||
|
||||
if (owner && repoSlug) {
|
||||
try {
|
||||
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
|
||||
} catch (err) {
|
||||
console.error('[updateWebsiteProject] preview-config schreiben fehlgeschlagen:', err.message)
|
||||
}
|
||||
try {
|
||||
await patchGiteaRepo(repoFullName, { description: nextName })
|
||||
} catch (err) {
|
||||
console.error('[updateWebsiteProject] Gitea-Beschreibung fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy / Teardown
|
||||
let deployResult = { deployed: false, skipped: true }
|
||||
if (hasWebsite && nextSubdomain) {
|
||||
if (subdomainChanged && oldSubdomain) {
|
||||
try { await removeDeploy(oldSubdomain) } catch (err) {
|
||||
console.error('[updateWebsiteProject] alten Deploy entfernen fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
try {
|
||||
deployResult = await runDeploy(repoFullName, branch, previewConfig)
|
||||
} catch (err) {
|
||||
console.error('[updateWebsiteProject] deploy fehlgeschlagen:', err.message)
|
||||
deployResult = { deployed: false, error: err.message }
|
||||
}
|
||||
} else if (oldSubdomain) {
|
||||
try { await removeDeploy(oldSubdomain) } catch (err) {
|
||||
console.error('[updateWebsiteProject] Deploy entfernen fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : ''
|
||||
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
||||
projectName: nextName,
|
||||
subdomain: nextSubdomain,
|
||||
previewUrl,
|
||||
projectType: nextType,
|
||||
isPublic,
|
||||
isTemplate,
|
||||
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
|
||||
provisioningStatus: hasWebsite
|
||||
? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending'))
|
||||
: 'ready',
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// TLS-Router NACH dem Appwrite-Update synchronisieren, damit der public/auth-
|
||||
// Zustand (isPublic) korrekt gelesen wird - auch wenn kein Redeploy stattfand
|
||||
// (z.B. nur Typ preview <-> website getauscht).
|
||||
try { await syncPreviewTls() } catch (err) {
|
||||
console.error('[updateWebsiteProject] TLS-Sync fehlgeschlagen:', err.message)
|
||||
}
|
||||
|
||||
return {
|
||||
project: updated,
|
||||
deploy: deployResult,
|
||||
previewUrl,
|
||||
subdomain: nextSubdomain,
|
||||
projectType: nextType,
|
||||
isPublic,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archiviert (oder reaktiviert) ein Projekt: setzt das Gitea-Repo auf archived
|
||||
* und pflegt das Appwrite-Flag. Beim Archivieren wird zusaetzlich der Preview-
|
||||
* Deploy entfernt; beim Reaktivieren mit Subdomain neu deployt.
|
||||
*/
|
||||
export async function archiveRepo(projectId, archived = true) {
|
||||
const doc = await getDocument(config.collections.websiteProjects, projectId)
|
||||
if (!doc) {
|
||||
const error = new Error('Projekt nicht gefunden')
|
||||
error.status = 404
|
||||
throw error
|
||||
}
|
||||
|
||||
const repoFullName = doc.repoFullName || ''
|
||||
let repoMissing = false
|
||||
if (repoFullName.includes('/')) {
|
||||
try {
|
||||
await patchGiteaRepo(repoFullName, { archived: Boolean(archived) })
|
||||
} catch (err) {
|
||||
// Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem
|
||||
// nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet.
|
||||
if (err.status === 404) {
|
||||
repoMissing = true
|
||||
console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`)
|
||||
} else {
|
||||
const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message)
|
||||
error.status = err.status || 500
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (archived && doc.subdomain) {
|
||||
try { await removeDeploy(doc.subdomain) } catch (err) {
|
||||
console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!archived && !repoMissing && doc.subdomain) {
|
||||
try {
|
||||
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
|
||||
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
|
||||
enabled: true,
|
||||
type: 'static',
|
||||
branch,
|
||||
subdomain: doc.subdomain,
|
||||
displayName: doc.projectName,
|
||||
}
|
||||
await runDeploy(repoFullName, branch, cfg)
|
||||
} catch (err) {
|
||||
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
||||
archived: Boolean(archived),
|
||||
...(archived ? { status: 'archived' } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return { project: updated, archived: Boolean(archived) }
|
||||
}
|
||||
|
||||
/** Repo-Basisdaten (Groesse in KB, Default-Branch). */
|
||||
export async function getRepoInfo(repoFullName) {
|
||||
const [owner, repo] = String(repoFullName || '').split('/')
|
||||
if (!owner || !repo) return null
|
||||
const data = await giteaFetch(`/repos/${owner}/${repo}`)
|
||||
return {
|
||||
sizeKb: Number(data.size || 0),
|
||||
defaultBranch: data.default_branch || 'main',
|
||||
updatedAt: data.updated_at || '',
|
||||
htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Letzte Commits eines Repos (volle Message inkl. Body). */
|
||||
export async function listRepoCommits(repoFullName, { branch = 'main', limit = 20 } = {}) {
|
||||
const [owner, repo] = String(repoFullName || '').split('/')
|
||||
if (!owner || !repo) return []
|
||||
// stat/verification/files abschalten - sonst berechnet Gitea Diff-Stats pro Commit
|
||||
const data = await giteaFetch(
|
||||
`/repos/${owner}/${repo}/commits?sha=${encodeURIComponent(branch)}&limit=${limit}&stat=false&verification=false&files=false`
|
||||
)
|
||||
return (Array.isArray(data) ? data : []).map((entry) => ({
|
||||
sha: entry.sha || '',
|
||||
shortSha: (entry.sha || '').slice(0, 7),
|
||||
message: entry.commit?.message || '',
|
||||
authorName:
|
||||
entry.commit?.author?.name || entry.author?.login || entry.committer?.login || 'Unbekannt',
|
||||
date: entry.commit?.author?.date || entry.created || '',
|
||||
url: entry.html_url || `${config.gitea.baseUrl}/${repoFullName}/commit/${entry.sha}`,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei-Uebersicht: aggregiert den Git-Baum serverseitig nach Top-Level-Eintraegen
|
||||
* (nie den rohen Baum ans Frontend geben - kann tausende Eintraege haben).
|
||||
*/
|
||||
export async function getRepoTreeSummary(repoFullName, ref = 'main') {
|
||||
const [owner, repo] = String(repoFullName || '').split('/')
|
||||
if (!owner || !repo) return null
|
||||
const data = await giteaFetch(
|
||||
`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=1000`
|
||||
)
|
||||
|
||||
const entries = Array.isArray(data?.tree) ? data.tree : []
|
||||
const topLevelMap = new Map()
|
||||
let totalFiles = 0
|
||||
let totalSizeBytes = 0
|
||||
|
||||
for (const entry of entries) {
|
||||
const isBlob = entry.type === 'blob'
|
||||
const size = isBlob ? Number(entry.size || 0) : 0
|
||||
const path = String(entry.path || '')
|
||||
const slash = path.indexOf('/')
|
||||
const topSegment = slash === -1 ? path : path.slice(0, slash)
|
||||
const isDir = slash !== -1 || entry.type === 'tree'
|
||||
|
||||
if (isBlob) {
|
||||
totalFiles += 1
|
||||
totalSizeBytes += size
|
||||
}
|
||||
|
||||
if (!topSegment) continue
|
||||
const existing = topLevelMap.get(topSegment) || {
|
||||
path: topSegment,
|
||||
type: isDir ? 'dir' : 'file',
|
||||
fileCount: 0,
|
||||
sizeBytes: 0,
|
||||
}
|
||||
if (isDir) existing.type = 'dir'
|
||||
if (isBlob) {
|
||||
existing.fileCount += 1
|
||||
existing.sizeBytes += size
|
||||
}
|
||||
topLevelMap.set(topSegment, existing)
|
||||
}
|
||||
|
||||
const topLevel = [...topLevelMap.values()]
|
||||
.sort((a, b) => b.sizeBytes - a.sizeBytes)
|
||||
.slice(0, 50)
|
||||
|
||||
return {
|
||||
totalFiles,
|
||||
totalSizeBytes,
|
||||
truncated: Boolean(data?.truncated),
|
||||
topLevel,
|
||||
}
|
||||
}
|
||||
|
||||
const README_CANDIDATES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README']
|
||||
|
||||
export async function fetchRepoReadme(repoFullName) {
|
||||
|
||||
@@ -35,6 +35,122 @@ export function sanitizeInvoiceClient(client) {
|
||||
}
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_LABELS = {
|
||||
1: 'Entwurf',
|
||||
2: 'Offen',
|
||||
3: 'Teilzahlung',
|
||||
4: 'Bezahlt',
|
||||
5: 'Storniert',
|
||||
6: 'Storniert',
|
||||
}
|
||||
|
||||
export function sanitizeInvoice(inv) {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const balance = Number(inv.balance ?? 0)
|
||||
const statusId = Number(inv.status_id ?? 0)
|
||||
const overdue = balance > 0 && Boolean(inv.due_date) && inv.due_date < today && statusId !== 5
|
||||
const invitationKey = inv.invitations?.[0]?.key || ''
|
||||
|
||||
return {
|
||||
id: inv.id,
|
||||
number: inv.number || '',
|
||||
date: inv.date || '',
|
||||
dueDate: inv.due_date || '',
|
||||
amount: Number(inv.amount ?? 0),
|
||||
balance,
|
||||
statusId,
|
||||
status: overdue ? 'Überfällig' : INVOICE_STATUS_LABELS[statusId] || 'Offen',
|
||||
overdue,
|
||||
poNumber: inv.po_number || '',
|
||||
publicNotes: inv.public_notes || '',
|
||||
// Link ins InvoiceNinja-Kundenportal (Rechnung ansehen/zahlen)
|
||||
portalUrl: invitationKey ? `${config.invoiceNinja.url}/client/invoice/${invitationKey}` : '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Rohe Rechnung inkl. Invitations (fuer PDF-Download + Ownership-Pruefung). */
|
||||
export async function getInvoiceRaw(invoiceId) {
|
||||
const token = config.invoiceNinja.apiToken
|
||||
if (!token) {
|
||||
const error = new Error('INVOICE_NINJA_API_TOKEN ist nicht konfiguriert')
|
||||
error.status = 503
|
||||
throw error
|
||||
}
|
||||
const response = await fetch(
|
||||
`${config.invoiceNinja.url}/api/v1/invoices/${encodeURIComponent(invoiceId)}?include=invitations`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-TOKEN': token,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.message || 'Rechnung nicht gefunden')
|
||||
error.status = response.status === 401 ? 502 : response.status
|
||||
throw error
|
||||
}
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** PDF einer Rechnung ueber den Client-Portal-Invitation-Link streamen. */
|
||||
export async function fetchInvoicePdf(invitationKey) {
|
||||
const token = config.invoiceNinja.apiToken
|
||||
const response = await fetch(
|
||||
`${config.invoiceNinja.url}/client/invoice/${encodeURIComponent(invitationKey)}/download_pdf`,
|
||||
{
|
||||
headers: {
|
||||
'X-Api-Token': token,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
const error = new Error('PDF-Download fehlgeschlagen')
|
||||
error.status = response.status
|
||||
throw error
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
/** Rechnungen eines InvoiceNinja-Clients (fuer Kundenportal + Admin-Ansicht). */
|
||||
export async function listInvoicesForClient(clientId, { includeDrafts = false } = {}) {
|
||||
const token = config.invoiceNinja.apiToken
|
||||
if (!token) {
|
||||
const error = new Error('INVOICE_NINJA_API_TOKEN ist nicht konfiguriert')
|
||||
error.status = 503
|
||||
throw error
|
||||
}
|
||||
if (!clientId) return []
|
||||
|
||||
const url = new URL(`${config.invoiceNinja.url}/api/v1/invoices`)
|
||||
url.searchParams.set('client_id', String(clientId))
|
||||
url.searchParams.set('per_page', '100')
|
||||
url.searchParams.set('sort', 'date|desc')
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-API-TOKEN': token,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.message || 'Invoice Ninja API Fehler')
|
||||
error.status = response.status === 401 ? 502 : response.status
|
||||
throw error
|
||||
}
|
||||
|
||||
return (data.data || [])
|
||||
.filter((inv) => !inv.is_deleted)
|
||||
.map(sanitizeInvoice)
|
||||
.filter((inv) => includeDrafts || inv.statusId !== 1)
|
||||
}
|
||||
|
||||
export async function listInvoiceNinjaClients() {
|
||||
const token = config.invoiceNinja.apiToken
|
||||
if (!token) {
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,29 @@ import { promisify } from 'node:util'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from './appwriteAdmin.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
/**
|
||||
* Subdomains, die als "oeffentliche Website" (ohne Login) laufen sollen.
|
||||
* Diese bekommen in syncPreviewTls einen Router OHNE preview-auth-ForwardAuth.
|
||||
* Faellt bei Appwrite-Fehlern bewusst auf ein leeres Set zurueck -> alle Seiten
|
||||
* bleiben dann login-geschuetzt (sicherer Default).
|
||||
*/
|
||||
async function getPublicSubdomains() {
|
||||
try {
|
||||
const docs = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('isPublic', true),
|
||||
Query.limit(500),
|
||||
])
|
||||
return new Set(docs.map((d) => d.subdomain).filter(Boolean))
|
||||
} catch (err) {
|
||||
console.error('[previewDeploy] getPublicSubdomains fehlgeschlagen:', err.message)
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPreviewConfig(repoFullName, branch) {
|
||||
const [owner, repo] = repoFullName.split('/')
|
||||
if (!owner || !repo || !config.gitea.apiToken) {
|
||||
@@ -35,32 +55,220 @@ export function defaultPreviewConfig(repoFullName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneRepo(cloneUrl, targetDir, branch) {
|
||||
/**
|
||||
* Baut aus einer Gitea-Clone-URL eine authentifizierte URL, damit auch PRIVATE
|
||||
* Repos geklont werden koennen. Ohne Token bleibt die URL unveraendert (anonymer
|
||||
* Clone fuer oeffentliche Repos).
|
||||
*/
|
||||
function authedCloneUrl(cloneUrl, token) {
|
||||
if (!token) return cloneUrl
|
||||
try {
|
||||
const u = new URL(cloneUrl)
|
||||
u.username = 'oauth2'
|
||||
u.password = token
|
||||
return u.toString()
|
||||
} catch {
|
||||
return cloneUrl
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneRepo(cloneUrl, targetDir, branch, token = '') {
|
||||
await fs.rm(targetDir, { recursive: true, force: true })
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
await execFileAsync('git', [
|
||||
'clone',
|
||||
'--depth',
|
||||
'1',
|
||||
'--branch',
|
||||
branch,
|
||||
cloneUrl,
|
||||
targetDir,
|
||||
])
|
||||
try {
|
||||
await execFileAsync('git', [
|
||||
'clone',
|
||||
'--depth',
|
||||
'1',
|
||||
'--branch',
|
||||
branch,
|
||||
authedCloneUrl(cloneUrl, token),
|
||||
targetDir,
|
||||
])
|
||||
} catch (err) {
|
||||
// Token niemals in Logs/Fehlermeldungen durchreichen.
|
||||
if (token && err && typeof err.message === 'string') {
|
||||
err.message = err.message.split(token).join('***')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deployStatic(sourceDir, targetDir, indexSubdir = '') {
|
||||
const webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir
|
||||
let webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir
|
||||
// Falls der in .webklar-preview.json angegebene index-Unterordner nicht (mehr)
|
||||
// existiert, auf die Repo-Wurzel zurueckfallen statt den cp scheitern zu lassen.
|
||||
try {
|
||||
await fs.access(webroot)
|
||||
} catch {
|
||||
webroot = sourceDir
|
||||
}
|
||||
await fs.rm(targetDir, { recursive: true, force: true })
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
await execFileAsync('cp', ['-R', `${webroot}/.`, targetDir])
|
||||
// Git-Metadaten nie mit ausliefern (verhindert Zugriff auf /.git/ im Preview).
|
||||
await fs.rm(path.join(targetDir, '.git'), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter-Seite, die statt eines nackten nginx-403 ausgeliefert wird, wenn
|
||||
* ein Deploy-Verzeichnis keine index.html enthaelt (z.B. leergeraeumtes Repo,
|
||||
* fehlerhafter Build oder falscher index-Unterordner). So bleibt die Subdomain
|
||||
* immer erreichbar und zeigt eine verstaendliche Meldung.
|
||||
*/
|
||||
function placeholderHtml(displayName) {
|
||||
const name = String(displayName || 'Diese Website')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>${name} – wird vorbereitet</title>
|
||||
<style>
|
||||
html,body{height:100%;margin:0}
|
||||
body{display:flex;align-items:center;justify-content:center;
|
||||
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||||
background:#0f172a;color:#e2e8f0}
|
||||
.box{max-width:32rem;padding:2rem;text-align:center}
|
||||
h1{font-size:1.4rem;margin:0 0 .75rem}
|
||||
p{margin:0;color:#94a3b8;line-height:1.5}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>${name}</h1>
|
||||
<p>Diese Seite wird gerade vorbereitet oder aktualisiert.<br>Bitte in Kürze erneut laden.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht im Deploy-Verzeichnis die beste vorhandene HTML-Datei als Startseiten-
|
||||
* Ersatz: bevorzugte Namen (index.htm, home, main, start, app), sonst die
|
||||
* einzige bzw. groesste HTML-Datei. Nur Dateien im Wurzelverzeichnis.
|
||||
*/
|
||||
async function findIndexCandidate(targetDir) {
|
||||
let entries = []
|
||||
try {
|
||||
entries = await fs.readdir(targetDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const htmlFiles = entries
|
||||
.filter((e) => e.isFile() && /\.html?$/i.test(e.name) && !e.name.startsWith('.'))
|
||||
.map((e) => e.name)
|
||||
.filter((name) => name.toLowerCase() !== 'index.html')
|
||||
if (!htmlFiles.length) return null
|
||||
|
||||
const preferred = ['index.htm', 'home.html', 'homepage.html', 'main.html', 'start.html', 'app.html']
|
||||
for (const want of preferred) {
|
||||
const hit = htmlFiles.find((name) => name.toLowerCase() === want)
|
||||
if (hit) return hit
|
||||
}
|
||||
if (htmlFiles.length === 1) return htmlFiles[0]
|
||||
|
||||
// Mehrere Kandidaten ohne sprechenden Namen: die groesste Datei ist mit
|
||||
// Abstand am wahrscheinlichsten die eigentliche Projektseite.
|
||||
const sized = await Promise.all(
|
||||
htmlFiles.map(async (name) => {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(targetDir, name))
|
||||
return { name, size: stat.size }
|
||||
} catch {
|
||||
return { name, size: 0 }
|
||||
}
|
||||
})
|
||||
)
|
||||
sized.sort((a, b) => b.size - a.size)
|
||||
return sized[0].name
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass ein Deploy-Verzeichnis eine index.html besitzt. Fehlt sie,
|
||||
* wird zuerst eine vorhandene HTML-Datei zur Startseite befoerdert (Kopie),
|
||||
* damit die Preview das echte Projekt zeigt statt "wird vorbereitet". Erst wenn
|
||||
* gar keine HTML-Datei existiert, kommt der Platzhalter (403-Schutz, nginx
|
||||
* liefert mit autoindex off sonst "403 Forbidden").
|
||||
*/
|
||||
async function ensureIndexHtml(targetDir, displayName) {
|
||||
const indexPath = path.join(targetDir, 'index.html')
|
||||
try {
|
||||
await fs.access(indexPath)
|
||||
return { placeholder: false }
|
||||
} catch {
|
||||
/* keine index.html vorhanden -> Kandidat befoerdern oder Platzhalter */
|
||||
}
|
||||
const candidate = await findIndexCandidate(targetDir)
|
||||
if (candidate) {
|
||||
await fs.copyFile(path.join(targetDir, candidate), indexPath)
|
||||
console.log(`[previewDeploy] keine index.html – "${candidate}" als Startseite uebernommen (${targetDir})`)
|
||||
return { placeholder: false, promotedFrom: candidate }
|
||||
}
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
await fs.writeFile(indexPath, placeholderHtml(displayName), 'utf8')
|
||||
return { placeholder: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt App-Repos, die einen Build-Schritt brauchen (Expo, Vite, React ...):
|
||||
* package.json mit "build"-Script vorhanden, aber keine index.html im Webroot.
|
||||
* Solche Repos wuerden als "static" nur den Platzhalter zeigen - deshalb wird
|
||||
* der Deploy-Typ automatisch auf node_build angehoben (Langzeit-Fix, damit
|
||||
* App-Projekte ohne manuelle Config-Pflege korrekt angezeigt werden).
|
||||
*/
|
||||
async function shouldAutoBuild(sourceDir, indexSubdir = '') {
|
||||
try {
|
||||
const pkg = JSON.parse(await fs.readFile(path.join(sourceDir, 'package.json'), 'utf8'))
|
||||
if (!pkg?.scripts?.build) return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir
|
||||
try {
|
||||
await fs.access(path.join(webroot, 'index.html'))
|
||||
return false // echte statische Startseite vorhanden -> kein Build noetig
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* npm ci + npm run build, dann das Build-Ausgabeverzeichnis deployen.
|
||||
* Ausgabeverzeichnis wird erkannt (dist > build > out > web-build), weil
|
||||
* Toolchains unterschiedliche Defaults haben (Expo/Vite: dist, CRA: build).
|
||||
*/
|
||||
async function deployNodeBuild(sourceDir, targetDir) {
|
||||
await execFileAsync('npm', ['ci'], { cwd: sourceDir })
|
||||
await execFileAsync('npm', ['run', 'build'], { cwd: sourceDir })
|
||||
const distDir = path.join(sourceDir, 'dist')
|
||||
await deployStatic(distDir, targetDir)
|
||||
const execOpts = {
|
||||
cwd: sourceDir,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
timeout: 15 * 60 * 1000,
|
||||
env: { ...process.env, CI: '1' },
|
||||
}
|
||||
try {
|
||||
await execFileAsync('npm', ['ci'], execOpts)
|
||||
} catch {
|
||||
// Ohne (passendes) package-lock.json schlaegt npm ci fehl -> npm install.
|
||||
await execFileAsync('npm', ['install', '--no-audit', '--no-fund'], execOpts)
|
||||
}
|
||||
await execFileAsync('npm', ['run', 'build'], execOpts)
|
||||
|
||||
for (const dirName of ['dist', 'build', 'out', 'web-build']) {
|
||||
const candidate = path.join(sourceDir, dirName)
|
||||
try {
|
||||
await fs.access(path.join(candidate, 'index.html'))
|
||||
await deployStatic(candidate, targetDir)
|
||||
return
|
||||
} catch {
|
||||
/* naechsten Kandidaten pruefen */
|
||||
}
|
||||
}
|
||||
throw new Error('Build erzeugte kein Ausgabeverzeichnis mit index.html (dist/build/out/web-build)')
|
||||
}
|
||||
|
||||
export function buildPreviewUrl(subdomain) {
|
||||
@@ -93,17 +301,24 @@ export async function syncPreviewTls() {
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
|
||||
const publicSet = await getPublicSubdomains()
|
||||
|
||||
const blocks = slugs.map((name) => {
|
||||
const host = `${name}.${baseHost}`
|
||||
const id = `preview-${name}`
|
||||
const isPublic = publicSet.has(name)
|
||||
// Oeffentliche Website: HTTPS-Router OHNE preview-auth -> frei zugaenglich.
|
||||
// Preview (Standard): mit preview-chain (ForwardAuth) -> login-geschuetzt.
|
||||
const httpsMiddlewareLines = isPublic
|
||||
? []
|
||||
: [' middlewares:', ' - preview-chain@docker']
|
||||
return [
|
||||
` ${id}-https:`,
|
||||
` rule: "Host(\`${host}\`)"`,
|
||||
' entryPoints:',
|
||||
' - websecure',
|
||||
' service: preview-nginx@docker',
|
||||
' middlewares:',
|
||||
' - preview-chain@docker',
|
||||
...httpsMiddlewareLines,
|
||||
' priority: 100',
|
||||
' tls:',
|
||||
' certResolver: letsencrypt',
|
||||
@@ -142,14 +357,31 @@ export async function runDeploy(repoFullName, branch, previewConfig) {
|
||||
const workDir = path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))
|
||||
const targetDir = path.join(config.preview.deployRoot, subdomain)
|
||||
|
||||
await cloneRepo(cloneUrl, workDir, branch)
|
||||
await cloneRepo(cloneUrl, workDir, branch, config.gitea.apiToken)
|
||||
|
||||
if (previewConfig.type === 'node_build') {
|
||||
await deployNodeBuild(workDir, targetDir)
|
||||
let deployType = previewConfig.type
|
||||
if (deployType !== 'node_build' && (await shouldAutoBuild(workDir, previewConfig.index || ''))) {
|
||||
console.log(`[previewDeploy] ${repoFullName}: package.json mit build-Script, keine index.html -> node_build (auto)`)
|
||||
deployType = 'node_build'
|
||||
}
|
||||
|
||||
if (deployType === 'node_build') {
|
||||
try {
|
||||
await deployNodeBuild(workDir, targetDir)
|
||||
} catch (err) {
|
||||
// Build-Fehler duerfen die Subdomain nicht toeten: statisch deployen,
|
||||
// ensureIndexHtml haelt sie mit Kandidat/Platzhalter erreichbar.
|
||||
console.error(`[previewDeploy] node_build fuer ${repoFullName} fehlgeschlagen, Fallback auf static:`, err.message)
|
||||
await deployStatic(workDir, targetDir, previewConfig.index || '')
|
||||
}
|
||||
} else {
|
||||
await deployStatic(workDir, targetDir, previewConfig.index || '')
|
||||
}
|
||||
|
||||
// Universeller 403-Schutz: ohne index.html liefert nginx (autoindex off) sonst
|
||||
// "403 Forbidden". Ein Platzhalter haelt die Subdomain immer erreichbar.
|
||||
const index = await ensureIndexHtml(targetDir, previewConfig.displayName || subdomain)
|
||||
|
||||
let tls = { synced: false }
|
||||
try {
|
||||
tls = await syncPreviewTls()
|
||||
@@ -158,5 +390,25 @@ export async function runDeploy(repoFullName, branch, previewConfig) {
|
||||
tls = { synced: false, error: err.message }
|
||||
}
|
||||
|
||||
return { deployed: true, subdomain, targetDir, tls }
|
||||
return { deployed: true, subdomain, targetDir, tls, placeholder: index.placeholder }
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt das Deploy-Verzeichnis einer Subdomain und synchronisiert die
|
||||
* Traefik-TLS-Router neu (z.B. beim Aendern der Subdomain oder Archivieren).
|
||||
*/
|
||||
export async function removeDeploy(subdomain) {
|
||||
if (!subdomain || !config.preview.deployRoot) return { removed: false }
|
||||
if (!/^[a-z0-9-]+$/.test(subdomain)) return { removed: false, reason: 'ungueltige Subdomain' }
|
||||
const targetDir = path.join(config.preview.deployRoot, subdomain)
|
||||
await fs.rm(targetDir, { recursive: true, force: true })
|
||||
|
||||
let tls = { synced: false }
|
||||
try {
|
||||
tls = await syncPreviewTls()
|
||||
} catch (err) {
|
||||
console.error('[previewDeploy] TLS-Sync nach removeDeploy fehlgeschlagen:', err.message)
|
||||
tls = { synced: false, error: err.message }
|
||||
}
|
||||
return { removed: true, subdomain, tls }
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
}
|
||||
67
server/services/ticketAdmin.js
Normal file
67
server/services/ticketAdmin.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { config } from '../config.js'
|
||||
import {
|
||||
createDocument,
|
||||
getDocument,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
Query,
|
||||
ID,
|
||||
} from './appwriteAdmin.js'
|
||||
|
||||
const WOID_BASE = 9999
|
||||
|
||||
async function generateNextWoid() {
|
||||
// Numerisches Maximum ueber alle Tickets - orderDesc('woid') sortiert Strings
|
||||
// und liefert bei gemischt 5-/6-stelligen WOIDs falsche Ergebnisse.
|
||||
const docs = await listDocuments(config.collections.workorders, [Query.limit(1000)])
|
||||
const highest = docs.reduce((max, doc) => {
|
||||
const n = parseInt(doc.woid, 10)
|
||||
return Number.isNaN(n) ? max : Math.max(max, n)
|
||||
}, WOID_BASE)
|
||||
return String(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