Files
Webklar-Kundenbereich/server/services/customerAdmin.js
T
ClaudeandClaude Opus 5.5 78170567b4 Analyse: alle Beendigungswege schalten den Messdienst ab (Kunde/Projekt weg, Domain, Löschfrist, Widerruf Wechsel)
Messdienst: POST /a/admin/sites/:key/off setzt active=0 und offSince einmalig; Portal-Client stopSite.
analyse.js: endAnalyse als gemeinsamer Kern, endAnalyseForCustomer, analyseOnProjectChanged,
analyseDisabled (Sperre/Löschfrist) und täglicher Abgleich runAnalyseReconcile (Projekt/Kunde weg,
Domains nachziehen, K18-Wiederholung, Messdienst für off/disabled, fehlende Entfernen-Tickets nachholen).
Entfernen-Ticket je Abschaltung gekennzeichnet (kein Doppelticket, auch wenn das Speichern der ID scheiterte).
Hooks: Kunde löschen, updateWebsiteProject, withdrawOrder (Wechsel), siteFeatures.disable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00

298 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../config.js'
import {
createAppwriteUser,
createDocument,
deleteDocument,
getCustomerByEmail,
getDocument,
getPortalAccessByCustomerId,
listDocuments,
updateAppwriteUserEmail,
updateAppwriteUserPassword,
deleteAppwriteUser,
updateDocument,
Query,
} from './appwriteAdmin.js'
import { forgetPasswordVersion } from './passwordVersion.js'
function readPortalPassword(customer) {
if (customer?.portalPassword) return customer.portalPassword
// Fallback: Altbestand, bei dem das Passwort noch in den Notizen lag
const notes = customer?.notes || ''
const match = notes.match(/^__portalPassword__:([^\n]+)/)
return match ? match[1] : ''
}
function notesWithoutPortalPassword(notes) {
return (notes || '').replace(/^__portalPassword__:[^\n]*\n?/, '').trim()
}
function sanitizeCustomer(customer, { portalPassword } = {}) {
const storedPassword = portalPassword || readPortalPassword(customer)
return {
$id: customer.$id,
code: customer.code || '',
name: customer.name || '',
location: customer.location || '',
email: customer.email || '',
phone: customer.phone || '',
companyName: customer.companyName || '',
portalAccessEnabled: Boolean(customer.portalAccessEnabled),
appwriteUserId: customer.appwriteUserId || '',
customerStatus: customer.customerStatus || '',
archived: Boolean(customer.archived),
portalPassword: storedPassword || '',
}
}
async function ensurePortalAccess(customerId, appwriteUserId) {
const now = new Date().toISOString()
let portalAccess = await getPortalAccessByCustomerId(customerId)
if (portalAccess) {
portalAccess = await updateDocument(config.collections.customerPortalAccess, portalAccess.$id, {
enabled: true,
appwriteUserId,
passwordSet: true,
})
} else {
portalAccess = await createDocument(config.collections.customerPortalAccess, {
customerId,
enabled: true,
appwriteUserId,
passwordSet: true,
})
}
return portalAccess
}
export async function createCustomerWithPortalAccess(payload) {
const {
code = '',
name,
location = '',
email,
phone = '',
password,
} = payload || {}
if (!name?.trim()) {
const error = new Error('Name ist erforderlich')
error.status = 400
throw error
}
if (!email?.trim()) {
const error = new Error('E-Mail ist erforderlich')
error.status = 400
throw error
}
if (!password || password.length < 8) {
const error = new Error('Passwort muss mindestens 8 Zeichen haben')
error.status = 400
throw error
}
const existing = await getCustomerByEmail(email.trim())
if (existing) {
const error = new Error('Ein Kunde mit dieser E-Mail existiert bereits')
error.status = 409
throw error
}
const appwriteUser = await createAppwriteUser({
email: email.trim(),
password,
name: name.trim(),
})
const now = new Date().toISOString()
let customer
try {
customer = await createDocument(config.collections.customers, {
code: code.trim(),
name: name.trim(),
location: location.trim(),
email: email.trim(),
phone: phone.trim(),
portalPassword: password,
portalAccessEnabled: true,
appwriteUserId: appwriteUser.$id,
customerStatus: 'active',
createdAt: now,
updatedAt: now,
})
await ensurePortalAccess(customer.$id, appwriteUser.$id)
} catch (err) {
try {
await deleteAppwriteUser(appwriteUser.$id)
} catch {
/* ignore rollback failure */
}
throw err
}
return {
customer: sanitizeCustomer(customer, { portalPassword: password }),
}
}
export async function updateCustomerWithPortalAccess(customerId, payload) {
const customer = await getDocument(config.collections.customers, customerId).catch(() => null)
if (!customer) {
const error = new Error('Kunde nicht gefunden')
error.status = 404
throw error
}
const {
code,
name,
location,
email,
phone,
password,
} = payload || {}
const nextEmail = (email ?? customer.email ?? '').trim()
if (!nextEmail) {
const error = new Error('E-Mail ist erforderlich')
error.status = 400
throw error
}
if (password && password.length < 8) {
const error = new Error('Passwort muss mindestens 8 Zeichen haben')
error.status = 400
throw error
}
const normalizedPassword =
typeof password === 'string' && password.trim().length > 0 ? password : undefined
if (nextEmail.toLowerCase() !== (customer.email || '').trim().toLowerCase()) {
const existing = await getCustomerByEmail(nextEmail)
if (existing && existing.$id !== customerId) {
const error = new Error('Ein anderer Kunde nutzt diese E-Mail bereits')
error.status = 409
throw error
}
}
let appwriteUserId = customer.appwriteUserId || ''
let portalPassword
if (!appwriteUserId && nextEmail && normalizedPassword) {
const appwriteUser = await createAppwriteUser({
email: nextEmail,
password: normalizedPassword,
name: (name ?? customer.name ?? nextEmail).trim(),
})
appwriteUserId = appwriteUser.$id
portalPassword = normalizedPassword
} else if (appwriteUserId) {
if (nextEmail && nextEmail !== customer.email) {
await updateAppwriteUserEmail(appwriteUserId, nextEmail)
}
if (normalizedPassword) {
await updateAppwriteUserPassword(appwriteUserId, normalizedPassword)
// Neues Passwort von uns: der Kunde ist auf allen Geraeten abgemeldet (passwordVersion.js)
forgetPasswordVersion(appwriteUserId)
portalPassword = normalizedPassword
}
} else if (normalizedPassword) {
const error = new Error('Portal-Login fehlt � bitte E-Mail setzen und Passwort erneut speichern')
error.status = 400
throw error
}
const now = new Date().toISOString()
const nextPortalPassword =
normalizedPassword || readPortalPassword(customer) || undefined
// Legacy-Prefix aus den Notizen entfernen, Passwort liegt jetzt im eigenen Feld
const cleanedNotes = notesWithoutPortalPassword(customer.notes)
const notesChanged = cleanedNotes !== (customer.notes || '')
const updated = await updateDocument(config.collections.customers, customerId, {
...(code !== undefined ? { code: String(code).trim() } : {}),
...(name !== undefined ? { name: String(name).trim() } : {}),
...(location !== undefined ? { location: String(location).trim() } : {}),
email: nextEmail,
...(phone !== undefined ? { phone: String(phone).trim() } : {}),
...(nextPortalPassword ? { portalPassword: nextPortalPassword } : {}),
...(notesChanged ? { notes: cleanedNotes } : {}),
// CRM-Stufe (lead/customer/lost) nicht ueberschreiben - nur setzen, wenn leer
...(appwriteUserId
? {
appwriteUserId,
portalAccessEnabled: true,
...(customer.customerStatus ? {} : { customerStatus: 'active' }),
}
: {}),
updatedAt: now,
})
if (appwriteUserId) {
await ensurePortalAccess(customerId, appwriteUserId)
}
return {
customer: sanitizeCustomer(updated, portalPassword ? { portalPassword } : {}),
}
}
/**
* Der Kunde hat sein Passwort im Kundenbereich selbst geaendert: die Klartext-Kopie, die das
* Ticketsystem zum Weitergeben anzeigt, stimmt nicht mehr. Sie wird geleert statt ueberschrieben,
* denn ein selbst gewaehltes Passwort gehoert nirgends im Klartext hin. Das Ticketsystem zeigt dann "–".
* @returns {Promise<boolean>} true, wenn eine Kopie entfernt wurde
*/
export async function forgetStoredPortalPassword(customerId) {
const customer = await getDocument(config.collections.customers, customerId)
const legacyInNotes = /^__portalPassword__:/.test(customer.notes || '')
if (!customer.portalPassword && !legacyInNotes) return false
await updateDocument(config.collections.customers, customerId, {
portalPassword: '',
...(legacyInNotes ? { notes: notesWithoutPortalPassword(customer.notes) } : {}),
updatedAt: new Date().toISOString(),
})
return true
}
export async function listCustomersForAdmin() {
// Ohne Limit liefert Appwrite nur 25 Dokumente - neue Kunden fielen dann aus der Liste.
const customers = await listDocuments(config.collections.customers, [
Query.orderAsc('name'),
Query.limit(5000),
])
return customers.map((customer) => sanitizeCustomer(customer))
}
export async function deleteCustomerWithPortalAccess(customerId) {
const customer = await getDocument(config.collections.customers, customerId)
.catch(() => null)
if (!customer) {
const error = new Error('Kunde nicht gefunden')
error.status = 404
throw error
}
// Website-Analyse zuerst abschalten (kein Ticket, keine Mail); Fehler halten das Loeschen nicht auf, der taegliche Abgleich holt nach
try {
const { endAnalyseForCustomer } = await import('./analyse.js')
await endAnalyseForCustomer(customerId, { reason: 'kunde_geloescht', mail: false })
} catch (err) {
console.error(`[customerAdmin] Analyse von Kunde ${customerId} nicht beendet:`, err.message)
}
const portalAccess = await getPortalAccessByCustomerId(customerId)
if (portalAccess) {
await deleteDocument(
config.collections.customerPortalAccess,
portalAccess.$id
)
}
await deleteDocument(config.collections.customers, customerId)
if (customer.appwriteUserId) {
await deleteAppwriteUser(customer.appwriteUserId).catch(() => {})
}
return { success: true }
}