Files
Webklar-Kundenbereich/server/services/customerAdmin.js
T

386 lines
14 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'
import { KUNDENARTEN, kundenartGespeichert } from './kundenart.js'
import { neueAnsichtModus } from './kundenansicht/schalter.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()
}
/** Fehler fuer eine fehlende oder ungueltige Kundenart (400 kundenart_fehlt); die Route reicht `code` durch. */
function kundenartFehler() {
const error = new Error('Kundenart ist erforderlich (unternehmen oder privat).')
error.status = 400
error.code = 'kundenart_fehlt'
return error
}
/** Als "nicht mitgeschickt" gelten undefined, null und ''. */
const kundenartFehlt = (wert) => wert === undefined || wert === null || wert === ''
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),
kundenart: kundenartGespeichert(customer),
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,
kundenart,
} = 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
}
// Kundenart (V4): Pflicht nur ab Schalter pilot (bei off legt das WOMS Kunden wie heute ohne Kundenart an);
// ein mitgeschickter, ungueltiger Wert ist in jedem Modus ein Fehler. Vor jedem Schreibzugriff.
if (kundenartFehlt(kundenart) ? neueAnsichtModus() !== 'off' : !KUNDENARTEN.includes(kundenart)) throw kundenartFehler()
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',
...(kundenartFehlt(kundenart) ? {} : { kundenart }),
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,
kundenart,
} = 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
}
// Kundenart optional: nur wenn mitgeschickt, dann gueltig (V4)
if (kundenart !== undefined && !KUNDENARTEN.includes(kundenart)) throw kundenartFehler()
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() } : {}),
...(kundenart !== undefined ? { kundenart } : {}),
...(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)
}
// Terminplaner abschalten: Gaeste koennen ihre Termine noch absagen, die Daten loescht der Tageslauf 30 Tage nach disabledAt.
// Fehler halten das Loeschen nicht auf (wie bei der Analyse).
try {
const { disableCustomerFeatures } = await import('./siteFeatures.js')
await disableCustomerFeatures(customerId, 'termine', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' })
} catch (err) {
console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
// Webshop abschalten: Kaeufer koennen Bestellungen nicht mehr aufgeben, den Widerruf aber weiter erklaeren; die Shopdaten loescht der
// Tagesjob 90 Tage nach disabledAt. Den Shop selbst schliessen wir sofort (geoeffnet=false) und leeren den Speicher der oeffentlichen
// Schnittstelle. Jeder Teilschritt ist abgesichert: Fehler halten das Loeschen nicht auf (wie bei Analyse und Terminplaner).
try {
const { disableCustomerFeatures } = await import('./siteFeatures.js')
await disableCustomerFeatures(customerId, 'shop', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' })
} catch (err) {
console.error(`[customerAdmin] Webshop von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
try {
const { schliesseShopsVonKunde } = await import('./shop/kundenLoeschung.js')
await schliesseShopsVonKunde(customerId)
} catch (err) {
console.error(`[customerAdmin] Webshop von Kunde ${customerId} nicht geschlossen:`, err.message)
}
// Buchungskalender abschalten: Gaeste koennen keine Anfragen mehr stellen, die Daten loescht der Tagesjob 90 Tage nach disabledAt. Neue
// Anfragen schliessen wir sofort (Unterkuenfte inaktiv, Speicher der oeffentlichen Schnittstelle leeren). Jeder Teilschritt fuer sich abgesichert.
try {
const { disableCustomerFeatures } = await import('./siteFeatures.js')
await disableCustomerFeatures(customerId, 'unterkunft', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' })
} catch (err) {
console.error(`[customerAdmin] Buchungskalender von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
try {
const { schliesseUkVonKunde } = await import('./unterkunft/aufbewahrung.js')
await schliesseUkVonKunde(customerId)
} catch (err) {
console.error(`[customerAdmin] Buchungskalender von Kunde ${customerId} nicht geschlossen:`, err.message)
}
// Oeffnungszeiten live: wie der Terminplaner abschalten (die Antwort der Website ist sofort aktiv false: Kunde und Funktion fehlen/sind
// disabled), danach Zeiten und Erinnerungsmerker sofort loeschen (Spec 5) und den Speicher der Antworten leeren. Beides fuer sich in
// try/catch: ein Fehler haelt weder das Loeschen des Kunden noch den jeweils anderen Schritt auf. Geloggt wird nur die Kunden-ID.
try {
const { disableCustomerFeatures } = await import('./siteFeatures.js')
await disableCustomerFeatures(customerId, 'zeiten', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' })
} catch (err) {
console.error(`[customerAdmin] Öffnungszeiten von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
try {
const { loescheZeitenVonKunde } = await import('./zeiten/speicher.js')
await loescheZeitenVonKunde(customerId)
} catch (err) {
console.error(`[customerAdmin] Öffnungszeiten von Kunde ${customerId} nicht gelöscht:`, err.message)
}
// Entwuerfe (Terminplaner im Zustand off: nie bestellt oder nach dem Abo-Ende zurueckgesetzt) gehen sofort mit dem Kunden, Funktionsdokument
// und Einstellung. Fehler halten das Loeschen nicht auf (wie oben); ein uebrig gebliebener Entwurf faellt nach 90 Tagen im Tageslauf.
try {
const { loescheEntwuerfeVonKunde } = await import('./termine/entwuerfe.js')
await loescheEntwuerfeVonKunde(customerId)
} catch (err) {
console.error(`[customerAdmin] Entwürfe von Kunde ${customerId} nicht gelöscht:`, 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 }
}