import { createHash, randomBytes } from 'node:crypto' import { config } from '../config.js' import { createDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js' import { withLocks } from './keyedLock.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' import { createFeatureTicket } from './ticketAdmin.js' export const FEATURE_KEYS = ['news', 'termine'] /** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion. */ export const FEATURE_LABELS = { news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' }, } export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled'] export const DISABLED_MESSAGE = 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.' const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567' function httpError(status, message) { const err = new Error(message) err.status = status return err } /** 16 Zeichen base32 (a-z, 2-7) aus 80 zufaelligen Bits. */ export function newSiteKey() { const bytes = randomBytes(10) let bits = 0n for (const b of bytes) bits = (bits << 8n) | BigInt(b) let out = '' for (let i = 15; i >= 0; i -= 1) out += BASE32[Number((bits >> BigInt(i * 5)) & 31n)] return out } /** * Feste Dokument-ID je (Projekt, Funktion): macht (Kunde, Projekt, key) eindeutig. Passt sie nicht in * Appwrites 36 Zeichen, wird sie per sha1 gekuerzt (wie eventIdFor). */ function featureDocId(projectId, key) { const plain = `f_${projectId}_${key}` if (plain.length <= 36 && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(plain)) return plain return `f_${createHash('sha1').update(`${projectId}_${key}`).digest('hex').slice(0, 34)}` } function assertKey(key) { if (!FEATURE_KEYS.includes(key)) throw httpError(400, 'Unbekannte Funktion') } /** state und enabled werden immer zusammen geschrieben. */ export function stateFields(state) { return { state, enabled: state === 'active' } } export async function getFeatureDoc(customerId, projectId, key) { try { const doc = await getDocument(config.collections.portalFeatures, featureDocId(projectId, key)) return doc.customerId === customerId ? doc : null } catch (err) { if (err.status === 404) return null throw err } } /** forNewOrder: Neu bestellen/einschalten geht auf archivierten Projekten nicht mehr (Kunde sieht sie nicht). */ export async function assertOwnProject(customerId, projectId, { forNewOrder = false } = {}) { let project = null try { project = await getDocument(config.collections.websiteProjects, projectId) } catch (err) { if (err.status !== 404) throw err } if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt') if (forNewOrder && project.archived) { throw httpError(409, 'Dieses Projekt ist archiviert - dafür kann nichts mehr bestellt oder eingeschaltet werden.') } return project } /** Dokument holen oder mit state 'off' und dauerhaftem siteKey anlegen. */ export async function ensureFeatureDoc(customerId, projectId, key) { const existing = await getFeatureDoc(customerId, projectId, key) if (existing) return existing try { return await createDocument( config.collections.portalFeatures, { customerId, projectId, featureKey: key, ...stateFields('off'), unlockedByPurchase: false, purchaseStatus: '', siteKey: newSiteKey(), }, featureDocId(projectId, key) ) } catch (err) { if (err.status === 409) { const again = await getFeatureDoc(customerId, projectId, key) if (again) return again } throw err } } async function ticketWoid(ticketId) { if (!ticketId) return null try { const ticket = await getDocument(config.collections.workorders, ticketId) return ticket.woid || null } catch (err) { if (err.status === 404) return null throw err } } async function featureView(doc) { return { state: doc?.state || 'off', siteKey: doc?.siteKey || null, ticketWoid: doc?.ticketId ? await ticketWoid(doc.ticketId) : null, orderedAt: doc?.orderedAt || null, activatedAt: doc?.activatedAt || null, invoiceUrl: doc?.invoiceUrl || null, } } /** Alle Funktionsdokumente eines Kunden (alle Projekte, alle Funktionen). */ export function listCustomerFeatureDocs(customerId) { return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)]) } /** * docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. * includeArchived: nur fuer die Mitarbeiter-Ansicht. Der Kunde sieht archivierte Projekte nur noch, solange dort * eine Funktion bestellt/eingerichtet/aktiv ist - sonst kaeme er z. B. nicht mehr an seine Terminbuchungen. */ export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) { const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) const laufend = new Set(docs.filter((d) => ['ordered', 'setup', 'active'].includes(d.state)).map((d) => d.projectId)) const projects = (await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000), ])).filter((p) => includeArchived || !p.archived || laufend.has(p.$id)) const result = [] for (const p of projects) { const features = {} for (const key of FEATURE_KEYS) { const doc = docs.find((d) => d.projectId === p.$id && d.featureKey === key) features[key] = await featureView(doc) } result.push({ projectId: p.$id, projectName: p.projectName || '', liveUrl: p.liveDomain ? `https://${p.liveDomain}` : '', features, }) } return result } /** * Rechnungslink nachtragen: Stripe legt die Rechnung eines Zahlungs-Checkouts erst kurz nach der Zahlung an, * der Webhook sieht sie oft noch nicht. Best effort - Fehler werden geloggt und nie weitergereicht. */ export async function backfillInvoiceLinks(customerId) { try { const docs = await listDocuments(config.collections.portalFeatures, [ Query.equal('customerId', customerId), Query.limit(1000), ]) for (const doc of docs) { if ((doc.state !== 'setup' && doc.state !== 'active') || doc.invoiceUrl || !doc.stripeSessionId) continue try { const session = await retrieveCheckoutSession(doc.stripeSessionId, { expandInvoice: true }) const invoice = session.invoice && typeof session.invoice === 'object' ? session.invoice : null if (!invoice?.hosted_invoice_url) continue await updateDocument(config.collections.portalFeatures, doc.$id, { stripeInvoiceId: invoice.id || doc.stripeInvoiceId || '', invoiceUrl: invoice.hosted_invoice_url, }) } catch (err) { console.warn(`[funktionen] Rechnungslink fuer Sitzung ${doc.stripeSessionId} nicht nachgetragen:`, err.message) } } } catch (err) { console.warn('[funktionen] Rechnungslinks nicht nachgetragen:', err.message) } } /** * Bezahlte Funktionen eines Kunden mit Projektname und Ticketnummer (Widerruf der Funktion, Team-Hinweis T09). * Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht. */ export async function paidFeatureOrders(customerId) { const docs = await listDocuments(config.collections.portalFeatures, [ Query.equal('customerId', customerId), Query.limit(1000), ]) const paid = docs.filter((d) => ['setup', 'active', 'disabled'].includes(d.state) && d.unlockedByPurchase) const result = [] for (const doc of paid) { result.push({ projekt: await projectName(doc.projectId).catch(() => ''), state: doc.state, orderedAt: doc.orderedAt || '', ticketWoid: doc.ticketId ? await ticketWoid(doc.ticketId).catch(() => null) : null, }) } return result } export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) { assertKey(key) const doc = await ensureFeatureDoc(customerId, projectId, key) if (doc.state === 'setup' || doc.state === 'active') { throw httpError(409, 'Diese Funktion ist bereits bestellt oder freigeschaltet') } if (doc.state === 'disabled') throw httpError(409, DISABLED_MESSAGE) return updateDocument(config.collections.portalFeatures, doc.$id, { featureKey: key, ...stateFields('ordered'), purchaseStatus: 'ordered', stripeSessionId: sessionId, orderedAt: new Date().toISOString(), consentText: String(consent.text || '').slice(0, 1000), consentAt: consent.at || null, consentIp: String(consent.ip || '').slice(0, 64), consentUserAgent: String(consent.userAgent || '').slice(0, 300), }) } async function findBySession(sessionId) { if (!sessionId) return null const [doc] = await listDocuments(config.collections.portalFeatures, [ Query.equal('stripeSessionId', sessionId), Query.limit(1), ]) return doc || null } /** * Idempotent und je Session serialisiert: nur der Uebergang aus 'ordered' legt das Ticket an. Erst * wird auf 'setup' gestellt, dann das Ticket erstellt; scheitert das, bleibt 'setup' ohne ticketId * (das Portal zeigt "Ticket fehlt") und der Fehler wird weitergereicht. Stripe wiederholt den Webhook: * 'setup' ohne ticketId gilt dann als fortsetzbar (Ticket anlegen, created:true -> K15/T16 gehen raus). */ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { if (!sessionId) return { doc: null, created: false } return withLocks([`feature-paid:${sessionId}`], async () => { const doc = await findBySession(sessionId) if (!doc) return { doc: null, created: false } const resumable = doc.state === 'setup' && !doc.ticketId if (doc.state !== 'ordered' && !resumable) return { doc, created: false } const setup = resumable ? doc : await updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('setup'), purchaseStatus: 'paid', unlockedByPurchase: true, stripeInvoiceId: invoiceId || '', invoiceUrl: invoiceUrl || '', }) const project = await getDocument(config.collections.websiteProjects, doc.projectId) const { ticketId } = await createFeatureTicket({ customerId: doc.customerId, project, featureKey: doc.featureKey, siteKey: doc.siteKey, }) const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId }) return { doc: updated, created: true } }) } /** * Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck. * Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte * Bestellung ('setup') oder eine aktive Funktion anfasst. */ export async function markCheckoutFailed(sessionId) { if (!sessionId) return null return withLocks([`feature-paid:${sessionId}`], async () => { const doc = await findBySession(sessionId) if (!doc || doc.state !== 'ordered') return doc return updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('off'), purchaseStatus: 'failed', }) }) } function byNote(by, verb) { return by ? `${verb} von ${by}`.slice(0, 300) : undefined } export async function activate(docId, { by, adminNote } = {}) { const note = adminNote ?? byNote(by, 'Freigeschaltet') return updateDocument(config.collections.portalFeatures, docId, { ...stateFields('active'), activatedAt: new Date().toISOString(), // Eine wieder eingeschaltete Funktion darf nie in den 30-Tage-Loeschlauf des Terminplaners fallen (disabledAt-Marker) disabledAt: null, ...(note ? { adminNote: note } : {}), }) } export async function disable(docId, { by, adminNote } = {}) { const note = adminNote ?? byNote(by, 'Abgeschaltet') const doc = await updateDocument(config.collections.portalFeatures, docId, { ...stateFields('disabled'), disabledAt: new Date().toISOString(), ...(note ? { adminNote: note } : {}), }) if (doc?.featureKey === 'analyse' && doc.siteKey) await stopAnalyseCollecting(doc) return doc } /** * Alle noch nicht abgeschalteten Dokumente einer Funktion eines Kunden abschalten (Kunde wird geloescht). Schon abgeschaltete * behalten ihr disabledAt (Beginn der Loeschfrist), 'off' hat nichts zu sperren. Scheitert eines, werden die uebrigen trotzdem * abgeschaltet und danach der erste Fehler weitergereicht. * @returns {Promise} die abgeschalteten Dokumente */ export async function disableCustomerFeatures(customerId, key, { by, adminNote } = {}) { assertKey(key) const docs = (await listCustomerFeatureDocs(customerId)).filter( (d) => d.featureKey === key && d.state !== 'off' && d.state !== 'disabled' ) const disabled = [] let failure = null for (const doc of docs) { try { disabled.push(await disable(doc.$id, { by, adminNote })) } catch (err) { failure ||= err } } if (failure) throw failure return disabled } // Analyse gesperrt (auch Loeschfrist): Sammeldienst abschalten, Neubau und Entfernen-Ticket. Best effort, Zustand ist schon gespeichert. // Dynamischer Import: analyse.js importiert dieses Modul. async function stopAnalyseCollecting(doc) { try { const { analyseDisabled } = await import('./analyse.js') await analyseDisabled(doc) } catch (err) { console.warn(`[siteFeatures] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message) } } /** Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). */ export async function adminActivate(customerId, projectId, key, { by, adminNote } = {}) { assertKey(key) await assertOwnProject(customerId, projectId) const doc = await ensureFeatureDoc(customerId, projectId, key) return activate(doc.$id, { by, adminNote }) } /** Alle 'setup'-Funktionen (optional nur eines Kunden), deren Ticket 'Closed' ist, werden 'active'. */ export async function activateClosedTickets({ now = new Date(), customerId = null } = {}) { const queries = [Query.equal('featureKey', 'news'), Query.equal('state', 'setup'), Query.limit(1000)] if (customerId) queries.push(Query.equal('customerId', customerId)) const pending = await listDocuments(config.collections.portalFeatures, queries) const activated = [] for (const doc of pending) { if (!doc.ticketId) continue let ticket = null try { ticket = await getDocument(config.collections.workorders, doc.ticketId) } catch (err) { if (err.status === 404) continue throw err } if (ticket.status !== 'Closed') continue // Je Dokument gesperrt und neu gelesen: GET /api/funktionen, ein zweiter Tab und der 5-Minuten-Job // duerfen dieselbe Funktion nicht beide freischalten (und beide benachrichtigen) const updated = await withLocks([`feature-activate:${doc.$id}`], async () => { const fresh = await getDocument(config.collections.portalFeatures, doc.$id) if (fresh.state !== 'setup') return null return updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('active'), activatedAt: now.toISOString(), }) }) if (updated) activated.push(updated) } return activated } /** Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null. */ export async function findActiveBySiteKey(siteKey, featureKey = 'news') { if (!/^[a-z2-7]{16}$/.test(String(siteKey || '')) || !FEATURE_KEYS.includes(featureKey)) return null const [doc] = await listDocuments(config.collections.portalFeatures, [ Query.equal('siteKey', siteKey), Query.equal('featureKey', featureKey), Query.equal('state', 'active'), Query.limit(1), ]) return doc || null } // --------------------------------------------------------------------------- // Stripe-Webhook, Mails und Freischalt-Job // --------------------------------------------------------------------------- const eur = (cents) => `${(cents / 100).toFixed(2).replace('.', ',')} €` const portalUrl = () => `${config.publicBaseUrl}/dashboard.html#news` /** Ereignisse, die handleFeatureCheckoutEvent versteht (Funktions-Checkout: mode 'payment' mit featureKey). */ export const FEATURE_EVENT_TYPES = [ 'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired', ] export function isFeatureCheckoutEvent(event) { const obj = event?.data?.object return obj?.mode === 'payment' && Boolean(obj.metadata?.featureKey) && FEATURE_EVENT_TYPES.includes(event.type) } async function projectName(projectId) { try { return (await getDocument(config.collections.websiteProjects, projectId)).projectName || '' } catch (err) { if (err.status === 404) return '' throw err } } async function customerOf(customerId) { try { return await getDocument(config.collections.customers, customerId) } catch (err) { if (err.status === 404) return null throw err } } /** Link zur Stripe-Rechnung; fehlt sie noch oder ist sie nicht lesbar, geht die Mail ohne Link raus. */ async function invoiceOf(invoiceRef) { const id = typeof invoiceRef === 'string' ? invoiceRef : invoiceRef?.id if (!id) return { invoiceId: '', invoiceUrl: '' } try { const invoice = await getStripe().invoices.retrieve(id) return { invoiceId: id, invoiceUrl: invoice.hosted_invoice_url || '' } } catch (err) { console.warn(`[funktionen] Rechnung ${id} nicht lesbar:`, err.message) return { invoiceId: id, invoiceUrl: '' } } } async function ticketWoidOf(doc) { return doc.ticketId ? (await ticketWoid(doc.ticketId)) || '' : '' } async function teamNotice(doc, customer, { failed = false } = {}) { await sendAdminNotice({ template: 'T16', values: { kunde_name: customer?.name || customer?.email || doc.customerId, email: customer?.email || '', projekt: await projectName(doc.projectId), woid: failed ? '' : await ticketWoidOf(doc), sitekey: failed ? '' : doc.siteKey || '', sitzung: '', }, blocks: { zahlung_fehlgeschlagen: failed, ungeklaert: false }, }) } /** Bezahlte Sitzung ohne passende Bestellung: das Team muss von Hand klaeren (T16, Block ungeklaert). */ async function unmatchedNotice(session) { const { customerId, projectId } = session.metadata || {} const customer = customerId ? await customerOf(customerId).catch(() => null) : null await sendAdminNotice({ template: 'T16', values: { kunde_name: customer?.name || customer?.email || customerId || 'unbekannt', email: customer?.email || '', projekt: projectId ? (await projectName(projectId).catch(() => '')) || projectId : '', woid: '', sitekey: '', sitzung: `${session.id} (Kunde ${customerId || '-'}, Projekt ${projectId || '-'})`, }, blocks: { zahlung_fehlgeschlagen: false, ungeklaert: true }, }) } /** * Bezahlte Sitzung, die kein Dokument ueber die Sitzungs-ID findet (zwei parallele Bestellungen haben die * ID ueberschrieben): das Dokument aus den Metadaten nehmen, wenn es noch 'ordered' oder 'off' ist, und die * bezahlte Sitzung eintragen. Sonst laut loggen - der Kunde hat gezahlt und nichts wird eingerichtet. */ async function adoptPaidSession(session) { const { customerId, projectId, featureKey } = session.metadata || {} const doc = customerId && projectId && featureKey ? await getFeatureDoc(customerId, projectId, featureKey) : null if (!doc || (doc.state !== 'ordered' && doc.state !== 'off')) { console.error( `[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen` ) try { await unmatchedNotice(session) } catch (err) { console.error('[funktionen] Hinweis zur ungeklaerten Zahlung fehlgeschlagen:', err.message) } return false } await updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('ordered'), stripeSessionId: session.id, }) return true } async function onFeaturePaid(session) { const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice) const paid = { sessionId: session.id, invoiceId, invoiceUrl } let { doc, created } = await markPaid(paid) // Kein Dokument zur Sitzung, oder es fiel auf 'off' (Ablauf/SEPA-Fehler) und die Zahlung kam doch noch an if ((!doc || doc.state === 'off') && (await adoptPaidSession(session))) ({ doc, created } = await markPaid(paid)) if (!created) return // Ab hier ist die Bestellung gebucht; ein Wiederholungsversuch von Stripe sieht created:false und // wuerde keine Mail mehr schicken. Darum scheitert jede Mail einzeln und der Webhook antwortet trotzdem 200. let customer = null try { customer = await customerOf(doc.customerId) } catch (err) { console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message) } if (customer?.email) { try { await sendCustomerMail(customer.email, { template: 'K15', values: { kunde_name: customer.name || '', projekt: await projectName(doc.projectId), preis: eur(config.newsSetup.amountCents), zeit: config.funktionen.setupEstimate, rechnung_url: invoiceUrl, }, blocks: { rechnung: Boolean(invoiceUrl) }, }) } catch (err) { console.error('[funktionen] K15 fehlgeschlagen:', err.message) } } try { await teamNotice(doc, customer) } catch (err) { console.error('[funktionen] T16 fehlgeschlagen:', err.message) } } /** Stripe-Ereignis eines Funktions-Checkouts (isFeatureCheckoutEvent); idempotent, Fehler -> Stripe wiederholt. */ export async function handleFeatureCheckoutEvent(event) { const session = event.data.object switch (event.type) { case 'checkout.session.completed': // SEPA: die Sitzung ist abgeschlossen, das Geld noch nicht da -> erst async_payment_succeeded if (session.payment_status !== 'paid') return await onFeaturePaid(session) return case 'checkout.session.async_payment_succeeded': await onFeaturePaid(session) return case 'checkout.session.async_payment_failed': { const doc = await markCheckoutFailed(session.id) if (doc && doc.state === 'off') await teamNotice(doc, await customerOf(doc.customerId), { failed: true }) return } case 'checkout.session.expired': await markCheckoutFailed(session.id) return } } /** Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden. */ export async function runActivation({ now = new Date(), customerId = null } = {}) { const activated = await activateClosedTickets({ now, customerId }) for (const doc of activated) { try { const customer = await customerOf(doc.customerId) if (!customer?.email) continue await sendCustomerMail(customer.email, { template: 'K16', values: { kunde_name: customer.name || '', projekt: await projectName(doc.projectId), portal_url: portalUrl() }, }) } catch (err) { console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message) } } return activated }