Portal: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen entfernt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a4fa581c0
commit
7c00633db0
43 files changed
+229
-4001
No files matched your search
@@ -1,159 +0,0 @@
|
||||
/* Termine der Rechnungsanalyse.
|
||||
Ein Termin ist kein eigener Datensatz, sondern haengt am Akquise-Ticket des
|
||||
Kunden (workorders): startDate + startTime + endTime + assignedTo. Das WOMS-
|
||||
Planboard liest genau diese Felder, damit Termin und Ticket nie auseinander
|
||||
laufen. Achtung: WOMS speichert Datum als Text "dd.mm.yyyy" und Uhrzeit als
|
||||
"hhmm" - beides wird hier erzeugt und beim Lesen tolerant geparst. */
|
||||
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
||||
|
||||
const TICKET_LIMIT = 500
|
||||
|
||||
/* ------------------------------------------------------------- Formate --- */
|
||||
// "2026-08-12" -> "12.08.2026"
|
||||
export function isoToGerman(iso) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''))
|
||||
if (!m) return ''
|
||||
return `${m[3]}.${m[2]}.${m[1]}`
|
||||
}
|
||||
|
||||
// "12.08.2026" und "12.8.2026" -> "2026-08-12"
|
||||
export function germanToIso(value) {
|
||||
const m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(String(value || '').trim())
|
||||
if (!m) return ''
|
||||
return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// "09:00" -> "0900"
|
||||
export function timeToWoms(value) {
|
||||
const m = /^(\d{1,2}):?(\d{2})$/.exec(String(value || '').trim())
|
||||
if (!m) return ''
|
||||
return `${m[1].padStart(2, '0')}${m[2]}`
|
||||
}
|
||||
|
||||
// "0900" -> "09:00"
|
||||
export function womsToTime(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
const m = /^(\d{1,2}):?(\d{2})$/.exec(raw)
|
||||
if (!m) return ''
|
||||
return `${m[1].padStart(2, '0')}:${m[2]}`
|
||||
}
|
||||
|
||||
function addHour(time) {
|
||||
const m = /^(\d{2}):(\d{2})$/.exec(womsToTime(time))
|
||||
if (!m) return ''
|
||||
const hour = (Number(m[1]) + 1) % 24
|
||||
return `${String(hour).padStart(2, '0')}${m[2]}`
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Mitarbeiter --- */
|
||||
export async function findEmployeeByEmail(email) {
|
||||
const wanted = String(email || '').trim().toLowerCase()
|
||||
if (!wanted) return null
|
||||
const employees = await listDocuments(config.collections.employees, [Query.limit(100)])
|
||||
return employees.find((e) => String(e.email || '').toLowerCase() === wanted) || null
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Tickets --- */
|
||||
function isClosed(ticket) {
|
||||
return String(ticket.status || '').toLowerCase() === 'closed'
|
||||
}
|
||||
|
||||
/* Das Ticket, an das der Termin gehoert: bevorzugt das offene Akquise-Ticket.
|
||||
Gibt es keins, kommt nur ein anderes offenes Ticket in Frage, das noch KEIN
|
||||
Datum traegt - bei einem Projekt- oder Webpage-Ticket bedeutet startDate den
|
||||
Projektstart, den wuerden wir sonst still ueberschreiben. */
|
||||
export async function findAcquisitionTicket(customerId) {
|
||||
const tickets = await listDocuments(config.collections.workorders, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.limit(100),
|
||||
])
|
||||
if (!tickets.length) return null
|
||||
|
||||
const newestFirst = [...tickets].sort((a, b) => String(b.$createdAt || '').localeCompare(String(a.$createdAt || '')))
|
||||
const akquise = newestFirst.filter((t) => String(t.type || '').toLowerCase() === 'akquise')
|
||||
if (akquise.length) return akquise.find((t) => !isClosed(t)) || akquise[0]
|
||||
|
||||
return newestFirst.find((t) => !isClosed(t) && !t.startDate) || null
|
||||
}
|
||||
|
||||
function toAppointment(ticket, employeesByUserId) {
|
||||
const dateIso = germanToIso(ticket.startDate)
|
||||
const time = womsToTime(ticket.startTime)
|
||||
if (!dateIso) return null
|
||||
const employee = employeesByUserId[ticket.assignedTo] || null
|
||||
return {
|
||||
ticketId: ticket.$id,
|
||||
woid: ticket.woid || '',
|
||||
topic: ticket.topic || ticket.title || '',
|
||||
type: ticket.type || '',
|
||||
status: ticket.status || '',
|
||||
customerId: ticket.customerId || '',
|
||||
customerName: ticket.customerName || '',
|
||||
customerLocation: ticket.customerLocation || '',
|
||||
date: dateIso,
|
||||
time,
|
||||
endTime: womsToTime(ticket.endTime),
|
||||
employeeUserId: ticket.assignedTo || '',
|
||||
employeeName: employee?.displayName || ticket.assignedName || '',
|
||||
employeeEmail: employee?.email || '',
|
||||
}
|
||||
}
|
||||
|
||||
/* Alle terminierten Tickets in einem Zeitraum (ISO-Datum, inklusive). */
|
||||
export async function listAppointments({ from = '', to = '' } = {}) {
|
||||
const [tickets, employees] = await Promise.all([
|
||||
listDocuments(config.collections.workorders, [Query.limit(TICKET_LIMIT)]),
|
||||
listDocuments(config.collections.employees, [Query.limit(100)]),
|
||||
])
|
||||
|
||||
const employeesByUserId = {}
|
||||
for (const e of employees) employeesByUserId[e.userId] = e
|
||||
|
||||
return tickets
|
||||
.map((t) => toAppointment(t, employeesByUserId))
|
||||
.filter((a) => a && (!from || a.date >= from) && (!to || a.date <= to))
|
||||
.sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time))
|
||||
}
|
||||
|
||||
/* Termin am Akquise-Ticket eintragen. */
|
||||
export async function saveAppointment({ customerId, employeeEmail, date, time }) {
|
||||
const germanDate = isoToGerman(date)
|
||||
const womsTime = timeToWoms(time)
|
||||
if (!germanDate) throw badRequest('Ungültiges Datum')
|
||||
if (!womsTime) throw badRequest('Ungültige Uhrzeit')
|
||||
|
||||
const employee = await findEmployeeByEmail(employeeEmail)
|
||||
if (!employee) throw badRequest(`Kein Mitarbeiter mit der E-Mail ${employeeEmail} in WOMS gefunden`)
|
||||
|
||||
const ticket = await findAcquisitionTicket(customerId)
|
||||
if (!ticket) {
|
||||
throw badRequest('Kein passendes Ticket gefunden. Bitte in WOMS ein Akquise-Ticket für diesen Kunden anlegen.')
|
||||
}
|
||||
|
||||
const updated = await updateDocument(config.collections.workorders, ticket.$id, {
|
||||
startDate: germanDate,
|
||||
startTime: womsTime,
|
||||
endTime: addHour(womsTime),
|
||||
assignedTo: employee.userId,
|
||||
assignedName: employee.displayName || '',
|
||||
})
|
||||
|
||||
return {
|
||||
ticketId: updated.$id,
|
||||
woid: updated.woid || '',
|
||||
topic: updated.topic || '',
|
||||
type: updated.type || '',
|
||||
date,
|
||||
time: womsToTime(womsTime),
|
||||
employeeName: employee.displayName || '',
|
||||
}
|
||||
}
|
||||
|
||||
function badRequest(message) {
|
||||
const error = new Error(message)
|
||||
error.status = 400
|
||||
return error
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
/**
|
||||
* Kundenansicht aus dem Ticketsystem (Spec 2026-10-01, Abschnitt 3): ein Mitarbeiter oeffnet per Einmal-Link das
|
||||
* Portal eines Kunden. Die Sitzung traegt eine zufaellige Kennung (viewId) gegen Verwechslung zweier Tabs und
|
||||
* endet nach 2 Stunden. Sitzungen ohne viewId stammen aus "Als Kunde ansehen" im alten Admin-Panel.
|
||||
* endet nach 2 Stunden. Sitzungen ohne viewId ("Als Kunde ansehen" im frueheren Admin-Panel) gelten als beendet
|
||||
* (kundenansichtGuard, /api/auth/me).
|
||||
*/
|
||||
export const KUNDENANSICHT_TTL_MS = 2 * 60 * 60 * 1000
|
||||
const COOKIE_GRACE_MS = 60 * 60 * 1000
|
||||
|
||||
@@ -83,7 +83,7 @@ const lastCustomerNoticeByCustomer = new Map() // customerId -> timestamp
|
||||
|
||||
/**
|
||||
* Benachrichtigt den Kunden per E-Mail ueber eine neue Chatnachricht vom Team (K17), ohne BCC.
|
||||
* Wirft nie; das Ergebnis zeigt das Admin-Panel unter der Nachricht an.
|
||||
* Wirft nie; das Ergebnis zeigt das Ticketsystem unter der Nachricht an.
|
||||
* @returns {Promise<{ sent: boolean, reason?: string, to?: string }>}
|
||||
*/
|
||||
export async function sendCustomerChatNotice(customer, { sender, text }) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { shortRef } from './takeoverCredits.js'
|
||||
* Individuelle Abo-Angebote.
|
||||
*
|
||||
* Der Admin vereinbart mit einem Kunden einen Sonderpreis fuer die ersten Monate (z. B. 2 Monate
|
||||
* je 25 € statt 50 €) und legt ihn vorab im Admin-Panel an. Jedes Angebot bekommt sofort einen
|
||||
* je 25 € statt 50 €) und legt ihn vorab im Ticketsystem an. Jedes Angebot bekommt sofort einen
|
||||
* eigenen, einmal einloesbaren Stripe-Coupon (amount_off, duration "repeating" ueber die
|
||||
* Angebotsmonate); danach gilt automatisch wieder der regulaere Preis.
|
||||
* - Kunde ohne Abo: Portal zeigt das Angebot, die Bestelluebersicht nennt den Angebotspreis und
|
||||
@@ -24,7 +24,7 @@ const COLLECTION = () => config.collections.aboOffers
|
||||
|
||||
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
|
||||
|
||||
// Anzeige im Team-Hinweis (wie im Admin-Bereich)
|
||||
// Anzeige im Team-Hinweis (wie im Ticketsystem)
|
||||
const OFFER_STATUS_LABEL = { offen: 'offen', aktiv: 'aktiv (schon eingelöst)', zurueckgezogen: 'zurückgezogen' }
|
||||
|
||||
function httpError(status, message) {
|
||||
|
||||
@@ -45,8 +45,8 @@ async function currentPasswordVersion(userId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Admin-Sessions
|
||||
* ("Als Kunde ansehen") und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
|
||||
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Sessions der
|
||||
* Kundenansicht (Mitarbeiter) und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
|
||||
* erreichbar, gilt die Session weiter (sonst waeren bei jeder Stoerung alle abgemeldet).
|
||||
*/
|
||||
export async function isSessionPasswordCurrent(session) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from './appwriteAdmin.js'
|
||||
import { isPreviewProjectReady } from './previewReadiness.js'
|
||||
import { createStaffPreviewToken } from './staffPreviewTokens.js'
|
||||
|
||||
function safePreviewUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(String(url || ''))
|
||||
if (
|
||||
parsed.hostname.endsWith('.project.webklar.com') ||
|
||||
parsed.hostname === 'project.webklar.com'
|
||||
) {
|
||||
return parsed.toString()
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function previewUrlForSubdomain(subdomain) {
|
||||
const safe = String(subdomain || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
if (!safe) return null
|
||||
return `https://${safe}.${config.preview.baseHost}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview), genutzt vom
|
||||
* Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
|
||||
*/
|
||||
export async function buildStaffPreviewLink({ userId, email, name, subdomain: rawSubdomain, previewUrl: rawPreviewUrl }) {
|
||||
const subdomain = String(rawSubdomain || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const previewUrl = safePreviewUrl(rawPreviewUrl) || previewUrlForSubdomain(subdomain)
|
||||
|
||||
if (!subdomain || !previewUrl) {
|
||||
return { status: 400, body: { error: 'subdomain oder gueltige previewUrl erforderlich' } }
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('subdomain', subdomain),
|
||||
Query.limit(1),
|
||||
])
|
||||
const project = projects[0]
|
||||
if (!project) {
|
||||
return { status: 404, body: { error: 'Projekt nicht gefunden' } }
|
||||
}
|
||||
if (!isPreviewProjectReady(project)) {
|
||||
return { status: 409, body: { error: 'Projekt-Vorschau ist noch nicht bereit' } }
|
||||
}
|
||||
|
||||
const token = createStaffPreviewToken({
|
||||
userId,
|
||||
email: email || '',
|
||||
name: name || email || 'Admin',
|
||||
previewUrl,
|
||||
customerId: project.customerId || '',
|
||||
})
|
||||
|
||||
const portalBase = `https://${config.preview.baseHost}`
|
||||
return { status: 200, body: { openUrl: `${portalBase}/api/auth/staff-preview?token=${encodeURIComponent(token)}` } }
|
||||
} catch (err) {
|
||||
return { status: err.status || 500, body: { error: err.message || 'Preview-Login fehlgeschlagen' } }
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,8 @@ export function createStaffPreviewToken(payload) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) vom Wechsel ins Projekt-Admin
|
||||
* ('switch') und von der Kundenansicht aus dem Ticketsystem ('kundenansicht'); ein Token fuer den falschen
|
||||
* Zweck ist trotzdem verbraucht.
|
||||
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) von der Kundenansicht aus dem
|
||||
* Ticketsystem ('kundenansicht'); ein Token fuer den falschen Zweck ist trotzdem verbraucht.
|
||||
*/
|
||||
export function consumeStaffPreviewToken(token, purpose = 'preview') {
|
||||
purgeExpired()
|
||||
|
||||
Reference in new issue
Block a user