K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name. Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1531 lines
75 KiB
JavaScript
1531 lines
75 KiB
JavaScript
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 { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
|
||
import { zustimmungFuerSitzung } from './featureConsents.js'
|
||
import { aboLage, laeuft } from './aboStatus.js'
|
||
import { berlinDateTime } from './berlinTime.js'
|
||
import { loeschTag } from './termine/frist.js'
|
||
import { createFeatureTicket } from './ticketAdmin.js'
|
||
|
||
export const FEATURE_KEYS = featureKeys()
|
||
/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion; aus dem Register. */
|
||
export const FEATURE_LABELS = Object.fromEntries(
|
||
FEATURE_KEYS.map((key) => {
|
||
const { name, keine } = featureConfig(key).label
|
||
return [key, { name, keine }]
|
||
})
|
||
)
|
||
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
|
||
/** Warum eine Funktion abgeschaltet ist (portalFeatures.disabledReason); 'team' ist der Vorgabewert (Abschalten im WOMS, Aufbewahrungsfrist). */
|
||
export const DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht']
|
||
|
||
export const DISABLED_MESSAGE = featureConfig('news').disabledMessage
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Das eigene Projekt des Kunden (sonst 403). forNewOrder: fuer alles, was eine Funktion neu einschaltet oder bestellt; ein archiviertes
|
||
* Projekt ist dann 409 (Spec 11: eine laufende Funktion laeuft weiter, aber Neubestellung ist gesperrt).
|
||
*/
|
||
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 === true) throw httpError(409, 'Dieses Projekt ist archiviert. Dafür kann keine Funktion neu 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
|
||
}
|
||
}
|
||
|
||
/** Ticketnummer (woid) zur Ticket-ID; ein unbekanntes Ticket ergibt null. */
|
||
export 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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Wodurch die Funktion freigeschaltet wurde: 'kauf' | 'abo' | 'team'. Altbestand ohne unlockedBy: unlockedByPurchase => 'kauf', sonst
|
||
* 'team' (Kulanz, Pilot, Team-Aktivierung).
|
||
*/
|
||
export const unlockedByVon = (doc) => doc?.unlockedBy || (doc?.unlockedByPurchase ? 'kauf' : 'team')
|
||
|
||
/**
|
||
* Ansicht einer Funktion fuer Kunde und Team. Abschaltgrund (disabledReason, ohne Grund = Altbestand => null), Zeitpunkt
|
||
* (disabledAt) und Berliner Loeschtag (loeschungAm, 30 Tage danach, termine/frist.js) gibt es nur im Zustand `disabled`;
|
||
* in jedem anderen Zustand sind alle drei null, auch wenn am Dokument noch Reste einer frueheren Abschaltung stehen.
|
||
* loeschungAm gibt es nur fuer den Terminplaner (R105: der 30-Tage-Loeschlauf in jobs.js loescht nur Terminplaner-Daten,
|
||
* bei News verspraeche das Datum eine Loeschung, die nie kommt) und fuer Oeffnungszeiten live, dort nur beim Abschaltgrund
|
||
* abo_ende (bei einer Abschaltung durch das Team gibt es keine Loeschfrist); es ist null, wenn disabledAt fehlt oder nicht lesbar ist.
|
||
* disabledReason und disabledAt gelten fuer jede Funktion.
|
||
* Funktionen mit aboInklusive (Oeffnungszeiten live) tragen zusaetzlich unlockedBy (kauf | abo | team, null solange die Funktion
|
||
* aus ist), aboEndeAt (Ende des Wartungs-Abos erkannt) und einbau (angestossen | manuell, Ergebnis des Neubaus).
|
||
* key: Funktionsschluessel (auch ohne Dokument); ohne Angabe der des Dokuments.
|
||
*/
|
||
export async function featureView(doc, key = doc?.featureKey) {
|
||
const abgeschaltet = doc?.state === 'disabled'
|
||
const mitAbo = featureConfig(key)?.aboInklusive === true
|
||
const loeschtag = abgeschaltet && (key === 'termine' || (mitAbo && doc.disabledReason === 'abo_ende'))
|
||
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,
|
||
disabledReason: abgeschaltet ? doc.disabledReason || null : null,
|
||
disabledAt: abgeschaltet ? doc.disabledAt || null : null,
|
||
loeschungAm: loeschtag ? loeschTag(doc.disabledAt) : null,
|
||
...(mitAbo
|
||
? {
|
||
unlockedBy: doc && doc.state !== 'off' ? unlockedByVon(doc) : null,
|
||
aboEndeAt: doc?.aboEndeAt || null,
|
||
einbau: doc?.einbau || 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. */
|
||
export async function listCustomerFeatures(customerId, { docs: geladen } = {}) {
|
||
const projects = await listDocuments(config.collections.websiteProjects, [
|
||
Query.equal('customerId', customerId),
|
||
Query.orderDesc('$createdAt'),
|
||
Query.limit(1000),
|
||
])
|
||
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
|
||
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, key)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Zeitpunkt des Vertragsschlusses einer bezahlten Funktion (Anker der Widerrufsfrist): Zeit des Klicks aus dem Nachweis
|
||
* zur bezahlten Stripe-Sitzung, sonst das Zustimmungsdatum am Dokument (Altbestand), sonst orderedAt. Eine spaetere,
|
||
* unbezahlte Zustimmung (andere Sitzung) zaehlt nie. Die Widerrufspruefung darf an der Nachweis-Collection nicht
|
||
* scheitern: ein Lesefehler faellt auf orderedAt zurueck (mit Log).
|
||
*/
|
||
async function vertragAt(doc) {
|
||
try {
|
||
const nachweis = await zustimmungFuerSitzung(doc.stripeSessionId)
|
||
if (nachweis?.acceptedAt) return nachweis.acceptedAt
|
||
} catch (err) {
|
||
console.warn(`[funktionen] Zustimmungsnachweis zu Sitzung ${doc.stripeSessionId} nicht lesbar, Fristanker ist orderedAt:`, err.message)
|
||
return doc.orderedAt || ''
|
||
}
|
||
return doc.consentAt || doc.orderedAt || ''
|
||
}
|
||
|
||
/**
|
||
* Bezahlte Funktionen eines Kunden mit Projektname und Ticketnummer (Widerruf der Funktion, Team-Hinweis T09).
|
||
* Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht. vertragAt ist der Anker der Widerrufsfrist.
|
||
*/
|
||
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({
|
||
featureKey: doc.featureKey || '',
|
||
projekt: await projectName(doc.projectId).catch(() => ''),
|
||
state: doc.state,
|
||
orderedAt: doc.orderedAt || '',
|
||
vertragAt: await vertragAt(doc),
|
||
ticketWoid: doc.ticketId ? await ticketWoid(doc.ticketId).catch(() => null) : null,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Wortlaut der Zustimmung am Dokument (consentText): die Texte der Haken-Items der Funktion, durch eine Leerzeile getrennt.
|
||
* Eine bequeme Kopie fuer das Team; massgeblich ist der vollstaendige Nachweis in featureConsents.
|
||
*/
|
||
export function hakenTexte(feature) {
|
||
return feature.consent.items
|
||
.filter((item) => item.haken)
|
||
.map((item) => item.text)
|
||
.join('\n\n')
|
||
}
|
||
|
||
/** Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion. */
|
||
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
|
||
assertKey(key)
|
||
const feature = featureConfig(key)
|
||
const doc = await ensureFeatureDoc(customerId, projectId, key)
|
||
if (doc.state === 'setup' || doc.state === 'active') throw httpError(409, feature.orderedConflictMessage)
|
||
if (doc.state === 'disabled') throw httpError(409, feature.disabledMessage)
|
||
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
|
||
}
|
||
|
||
/** Faelle einer Zahlung, die kein bestellbares Dokument trifft (portalFeatures.zahlungMeldung); 'gemeldet' setzt erst die Meldung (B10b). */
|
||
const ZAHLUNG_FAELLE = ['bereits_aktiv', 'abgeschaltet', 'abo_ende']
|
||
const NOTE_MAX = 300
|
||
|
||
/** Fall einer Zahlung auf ein Dokument im Zustand active, disabled oder setup mit Ticket. */
|
||
function zahlungsFall(doc) {
|
||
if (doc.state !== 'disabled') return 'bereits_aktiv'
|
||
return doc.disabledReason === 'abo_ende_vor_einrichtung' ? 'abo_ende' : 'abgeschaltet'
|
||
}
|
||
|
||
/** Der noch zu meldende Fall am Dokument (zahlungMeldung), sonst null; '', 'gemeldet' und alles andere ist kein offener Fall. */
|
||
function offenerFall(doc) {
|
||
return ZAHLUNG_FAELLE.includes(doc.zahlungMeldung) ? doc.zahlungMeldung : null
|
||
}
|
||
|
||
/** Neuester Eintrag zuerst, hoechstens 300 Zeichen (wie die Notiz im Ticketsystem). */
|
||
export function notizVorn(previous, entry) {
|
||
const neu = entry.slice(0, NOTE_MAX)
|
||
const aelter = String(previous || '')
|
||
return aelter ? `${neu} | ${aelter}`.slice(0, NOTE_MAX) : neu
|
||
}
|
||
|
||
/**
|
||
* Funktionen mit aboInklusive (Oeffnungszeiten live) halten am Dokument fest, wodurch sie freigeschaltet wurden (unlockedBy) und was der
|
||
* Neubau fuer den Einbau ergab (einbau). Fuer die uebrigen Funktionen (News, Terminplaner) schreibt der Kaufweg beides nicht: das Schema
|
||
* dieser Felder gibt es nur, wo zeiten-setup.mjs gelaufen ist.
|
||
*/
|
||
const mitAbo = (key) => featureConfig(key)?.aboInklusive === true
|
||
|
||
/**
|
||
* Neubau fuer den Einbau des Zeiten-Skripts (Spec 4.6) beim Kauf, VOR dem Einbau-Ticket: das Ticket nennt das Ergebnis (einbau).
|
||
* Steht einbau schon am Dokument (Wiederholung des Webhooks nach einem gescheiterten Ticket), wird nicht noch einmal gebaut.
|
||
* stosseNeubauAn nimmt die eigene Sperre 'zeiten-neubau' (andere Schluessel als feature-paid, also kein Wiederbetreten) und wirft nie;
|
||
* zeiten/* nur per dynamischem Import (siehe Kopf). Liefert das Dokument mit dem gespeicherten einbau.
|
||
*/
|
||
async function baueZeitenEin(doc, project) {
|
||
if (doc.einbau) return doc
|
||
const { stosseNeubauAn } = await import('./zeiten/neubau.js')
|
||
const einbau = await stosseNeubauAn(project)
|
||
return updateDocument(config.collections.portalFeatures, doc.$id, { einbau })
|
||
}
|
||
|
||
/**
|
||
* Eine Zahlung trifft ein Dokument, das nicht (mehr) auf die Bezahlung wartet: active, disabled oder setup mit Ticket. Sie wird
|
||
* einmalig verbucht (purchaseStatus 'paid', Rechnung, Notiz) und der Fall im eigenen Feld zahlungMeldung vermerkt - in einem einzigen
|
||
* Schreibzugriff, damit nie eine verbuchte Zahlung ohne Marker bleibt. purchaseStatus bekommt nie einen Meldezustand (die
|
||
* Widerrufspruefung liest ihn). Ein Folgeereignis bucht nichts mehr, liefert den Fall aber erneut, solange er nicht gemeldet ist.
|
||
* Aufruf nur unter der Sperre der Sitzung.
|
||
*/
|
||
async function bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) {
|
||
if (doc.purchaseStatus === 'paid') return { doc, created: false, fall: offenerFall(doc) }
|
||
const fall = zahlungsFall(doc)
|
||
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||
purchaseStatus: 'paid',
|
||
unlockedByPurchase: true,
|
||
// Eine Zahlung, die nichts einrichtet, aendert nicht, wodurch das Dokument freigeschaltet wurde: ein abo oder team bleibt (der
|
||
// Abo-Abgleich und die Loeschfrist haengen an unlockedBy abo); nur ohne Angabe (Altbestand) wird es zu kauf, passend zu unlockedByPurchase
|
||
...(mitAbo(doc.featureKey) && !doc.unlockedBy ? { unlockedBy: 'kauf' } : {}),
|
||
stripeInvoiceId: invoiceId || '',
|
||
invoiceUrl: invoiceUrl || '',
|
||
adminNote: notizVorn(doc.adminNote, `${new Date().toISOString()} System: Zahlung eingegangen (Sitzung ${sessionId}), nichts eingerichtet`),
|
||
zahlungMeldung: fall,
|
||
})
|
||
return { doc: updated, created: false, fall }
|
||
}
|
||
|
||
/**
|
||
* 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).
|
||
* Funktionen mit aboInklusive (Oeffnungszeiten live): 'setup' traegt unlockedBy 'kauf' (und einbau leer), danach stoesst baueZeitenEin den
|
||
* Neubau an und speichert einbau, erst dann entsteht das Ticket mit dem Ergebnis. Fortsetzbar ist dort nur setup mit unlockedBy kauf; ein
|
||
* per Abo oder Team eingeschaltetes Dokument ohne Ticket heilt holeZeitenNach, die Zahlung ist fuer es ein Fall (bereits_aktiv).
|
||
*
|
||
* Liefert { doc, created, fall }. fall ist null, ausser die Zahlung traf ein Dokument, das nicht mehr bestellt ist ('active',
|
||
* 'disabled', 'setup' mit Ticket): dann wird sie einmalig verbucht und 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' geliefert,
|
||
* auch bei jedem Folgeereignis, bis zahlungMeldung 'gemeldet' ist (siehe bucheSpaeteZahlung). Ein Dokument im Zustand 'off'
|
||
* bleibt unveraendert (die Uebernahme macht adoptPaidSession).
|
||
*/
|
||
export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
|
||
if (!sessionId) return { doc: null, created: false, fall: null }
|
||
return withLocks([`feature-paid:${sessionId}`], async () => {
|
||
const doc = await findBySession(sessionId)
|
||
if (!doc) return { doc: null, created: false, fall: null }
|
||
if (doc.state === 'off') return { doc, created: false, fall: null }
|
||
const mitAboKauf = mitAbo(doc.featureKey)
|
||
// Funktionen mit aboInklusive: nur ein Dokument, das der Kauf auf setup gestellt hat (unlockedBy kauf), ist fortsetzbar. Eines, das per Abo
|
||
// oder vom Team auf setup steht, bekommt sein Ticket vom Nachholen (holeZeitenNach); die Zahlung ist dort ein Fall fuers Team
|
||
const resumable = doc.state === 'setup' && !doc.ticketId && (!mitAboKauf || unlockedByVon(doc) === 'kauf')
|
||
if (doc.state !== 'ordered' && !resumable) return bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl })
|
||
|
||
let setup = resumable
|
||
? doc
|
||
: await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||
...stateFields('setup'),
|
||
purchaseStatus: 'paid',
|
||
unlockedByPurchase: true,
|
||
// einbau beginnt leer: Reste eines frueheren Laufs gelten nicht, der Neubau (baueZeitenEin) setzt es neu
|
||
...(mitAboKauf ? { unlockedBy: 'kauf', einbau: '' } : {}),
|
||
stripeInvoiceId: invoiceId || '',
|
||
invoiceUrl: invoiceUrl || '',
|
||
zahlungMeldung: '',
|
||
})
|
||
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
|
||
// Oeffnungszeiten live: erst der Neubau (Zustand ist schon gespeichert), dann das Ticket mit dem frischen einbau (Spec 4.6)
|
||
if (mitAboKauf) setup = await baueZeitenEin(setup, project)
|
||
const customer = await customerOf(doc.customerId).catch(() => null)
|
||
const ticketId = await legeTicketAn(setup, project, customer)
|
||
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
|
||
return { doc: updated, created: true, fall: null }
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Das Einrichtungs-Ticket einer bezahlten Funktion anlegen (Betreff und Details aus dem Register, ticketTexte) und seine ID liefern.
|
||
* Die einzige Stelle, die das Ticket anlegt: markPaid (Zahlung) und ticketNachlegen (b, geloeschtes Ticket ersetzen). Schreibt nichts ins
|
||
* Funktionsdokument (die ticketId traegt der Aufrufer ein) und sendet keine Mail.
|
||
* @param {object} doc portalFeatures-Dokument (customerId, featureKey, siteKey)
|
||
* @param {object} project websiteProjects-Dokument
|
||
* @param {object|null} customer Kundendokument, best effort
|
||
* @returns {Promise<string>} ID des neuen Tickets
|
||
*/
|
||
async function legeTicketAn(doc, project, customer) {
|
||
const feature = featureConfig(doc.featureKey)
|
||
const texte = feature ? await ticketTexte({ feature, doc, project, customer }) : {}
|
||
const { ticketId } = await createFeatureTicket({
|
||
customerId: doc.customerId,
|
||
project,
|
||
featureKey: doc.featureKey,
|
||
siteKey: doc.siteKey,
|
||
...texte,
|
||
})
|
||
return ticketId
|
||
}
|
||
|
||
/**
|
||
* 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',
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Oeffnungszeiten live (Spec 5): das erkannte Abo-Ende und eine offene Behalten-Zahlung gehoeren zum alten Lauf und enden mit der
|
||
* Aktivierung. Als `extra` an activate() (nur der Zeiten-Pfad; die Felder legt scripts/zeiten-setup.mjs an, News und Terminplaner
|
||
* teilen die Collection).
|
||
*/
|
||
export const ZEITEN_AKTIVIERUNG_FELDER = Object.freeze({ aboEndeAt: null, behaltenSessionId: '' })
|
||
|
||
function byNote(by, verb) {
|
||
return by ? `${verb} von ${by}`.slice(0, 300) : undefined
|
||
}
|
||
|
||
/**
|
||
* Funktion aktivieren (jede Funktion gleich). `extra` sind zusaetzliche Felder, die der Aufrufer mitschreibt: Oeffnungszeiten live
|
||
* leert damit sein erkanntes Abo-Ende und eine offene Behalten-Zahlung (ZEITEN_AKTIVIERUNG_FELDER). Diese Felder legt erst
|
||
* scripts/zeiten-setup.mjs an; darum schreibt activate() sie nie von selbst, sonst scheiterte die Aktivierung von News und
|
||
* Terminplaner mit "Unknown attribute", solange der Code vor dem Skript laeuft.
|
||
* @param {string} docId
|
||
* @param {{ by?: string, adminNote?: string, extra?: Record<string, unknown> }} [options]
|
||
*/
|
||
export async function activate(docId, { by, adminNote, extra } = {}) {
|
||
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);
|
||
// Grund und Marker der Abo-Ende-Mail gehoeren zur Abschaltung und enden mit ihr. zahlungMeldung bleibt bewusst unberuehrt:
|
||
// ein noch offener Zahlungsfall darf durch eine Aktivierung nicht verloren gehen.
|
||
disabledAt: null,
|
||
disabledReason: null,
|
||
aboEndeMailAt: null,
|
||
...(extra ?? {}),
|
||
...(note ? { adminNote: note } : {}),
|
||
})
|
||
}
|
||
|
||
function assertReason(reason) {
|
||
if (!DISABLED_REASONS.includes(reason)) throw httpError(400, 'Unbekannter Abschaltgrund')
|
||
}
|
||
|
||
/**
|
||
* Funktion abschalten. Idempotent: steht das Dokument schon auf `disabled`, bleibt alles wie es ist (disabledAt als Beginn der
|
||
* 30-Tage-Loeschfrist, Grund, Notiz) und es gibt keinen Schreibzugriff; ein Doppelklick im WOMS, die Kundenloeschung oder ein
|
||
* zweiter Lauf verschieben die Frist also nie. Einzige Ausnahme Altbestand: fehlt einem `disabled`-Dokument disabledAt, wird nur
|
||
* dieses Feld gesetzt (sonst fiele es nie in die Loeschung). Die Nacharbeit der Analyse (Sammeldienst) laeuft bei jedem Aufruf.
|
||
* @param {string} docId
|
||
* @param {{ by?: string, adminNote?: string, reason?: 'team'|'abo_ende'|'abo_ende_vor_einrichtung'|'kunde_geloescht' }} [options]
|
||
* @throws {Error} 400 bei unbekanntem Grund (ohne Schreibzugriff), 404 bei unbekanntem Dokument
|
||
*/
|
||
export async function disable(docId, { by, adminNote, reason = 'team' } = {}) {
|
||
assertReason(reason)
|
||
const doc = await withLocks([`feature-disable:${docId}`], async () => {
|
||
const current = await getDocument(config.collections.portalFeatures, docId)
|
||
if (current.state === 'disabled') {
|
||
if (current.disabledAt) return current
|
||
return updateDocument(config.collections.portalFeatures, docId, { disabledAt: new Date().toISOString() })
|
||
}
|
||
const note = adminNote ?? byNote(by, 'Abgeschaltet')
|
||
return updateDocument(config.collections.portalFeatures, docId, {
|
||
...stateFields('disabled'),
|
||
disabledAt: new Date().toISOString(),
|
||
disabledReason: reason,
|
||
...(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<object[]>} die abgeschalteten Dokumente
|
||
*/
|
||
export async function disableCustomerFeatures(customerId, key, { by, adminNote, reason = 'team' } = {}) {
|
||
assertKey(key)
|
||
assertReason(reason)
|
||
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, reason }))
|
||
} 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)
|
||
}
|
||
}
|
||
|
||
const KEIN_ABO_AKTIVIERUNG =
|
||
'Das Wartungs-Abo des Kunden läuft nicht. Der Terminplaner kann erst wieder freigeschaltet werden, wenn es läuft.'
|
||
|
||
/**
|
||
* Schutz der Team-Aktivierung (E9): ein vom Kauf freigeschalteter Terminplaner gehoert zum Wartungs-Abo. Laeuft es nicht, wuerde der
|
||
* naechste Abgleichslauf die Kulanz zuruecknehmen und K22 ein zweites Mal senden; darum 409 ohne Schreibzugriff. Frisch gelesen
|
||
* (aboLage direkt, nie der Memo), mit der Wechsel-Regel des Abgleichs. Ein Lesefehler ist ein Fehler (503, geloggt), nie "laeuft".
|
||
*/
|
||
async function assertAboLaeuftFuerAktivierung(customerId) {
|
||
let laeuftJetzt
|
||
try {
|
||
laeuftJetzt = (await aboLage(customerId)).laeuft
|
||
} catch (err) {
|
||
console.error(`[funktionen] Wartungs-Abo von Kunde ${customerId} für die Aktivierung nicht lesbar:`, err.message)
|
||
throw httpError(503, 'Das Wartungs-Abo des Kunden konnte nicht geprüft werden. Bitte später erneut versuchen.')
|
||
}
|
||
if (!laeuftJetzt) throw httpError(409, KEIN_ABO_AKTIVIERUNG)
|
||
}
|
||
|
||
/**
|
||
* Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). Ausnahme: ein Terminplaner, den ein Kauf
|
||
* freigeschaltet hat (unlockedByPurchase true), wird nur bei laufendem Wartungs-Abo aktiviert (409 sonst, Lesefehler => Fehler).
|
||
* News und Dokumente ohne Kauf (Pilot, Kulanz) sind nicht betroffen.
|
||
*/
|
||
export async function adminActivate(customerId, projectId, key, { by, adminNote } = {}) {
|
||
assertKey(key)
|
||
await assertOwnProject(customerId, projectId)
|
||
const doc = await ensureFeatureDoc(customerId, projectId, key)
|
||
if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId)
|
||
return activate(doc.$id, { by, adminNote, ...(key === 'zeiten' ? { extra: ZEITEN_AKTIVIERUNG_FELDER } : {}) })
|
||
}
|
||
|
||
/**
|
||
* Haken freischaltbar(doc) der Funktion, falls das Register einen hat (nur true gibt frei). Liefert null (frei) oder den Grund,
|
||
* warum das Dokument in 'setup' bleibt: false => Voraussetzungen offen, Fehler (z. B. Abo oder Einstellung nicht lesbar) =>
|
||
* Pruefung nicht moeglich; der Fehler wird geloggt, nie geworfen (ein Dokument darf die uebrigen nicht aufhalten).
|
||
*/
|
||
async function freischaltHindernis(doc) {
|
||
const { freischaltbar } = featureHaken(doc.featureKey)
|
||
if (typeof freischaltbar !== 'function') return null
|
||
try {
|
||
return (await freischaltbar(doc)) === true ? null : 'Voraussetzungen noch nicht erfüllt'
|
||
} catch (err) {
|
||
console.error(`[funktionen] Freischaltung ${doc.featureKey} (${doc.$id}) nicht prüfbar:`, err.message)
|
||
return 'Prüfung nicht möglich'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Eine blockierte Freischaltung einmal in adminNote vermerken ("<Zeit> System: Freischaltung wartet: <Grund>", neuester Eintrag
|
||
* vorn). Steht derselbe Eintrag schon vorn, bleibt alles wie es ist: der 5-Minuten-Job schreibt nicht alle 5 Minuten und sendet
|
||
* keine Mail (der Hinweis kein_abo kam mit der Zahlung, siehe onFeaturePaid). Ein Schreibfehler wird nur geloggt.
|
||
* Aufruf unter der Sperre des Dokuments mit dem frisch gelesenen Dokument.
|
||
*/
|
||
async function vermerkeWartet(doc, grund, now) {
|
||
const eintrag = `System: Freischaltung wartet: ${grund}`
|
||
const neuester = String(doc.adminNote || '').split(' | ')[0]
|
||
if (neuester.endsWith(eintrag)) return
|
||
console.warn(`[funktionen] Freischaltung ${doc.featureKey} (${doc.$id}) wartet: ${grund}`)
|
||
try {
|
||
await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: notizVorn(doc.adminNote, `${now.toISOString()} ${eintrag}`) })
|
||
} catch (err) {
|
||
console.error(`[funktionen] Wartenotiz für ${doc.$id} nicht gespeichert:`, err.message)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Alle 'setup'-Funktionen (optional nur eines Kunden) mit ticketFreischaltung im Register (ticketFreischaltungKeys), deren Ticket
|
||
* 'Closed' ist, werden 'active' - sofern der Haken freischaltbar der Funktion (Termine: Einstellung gespeichert und Wartungs-Abo
|
||
* laeuft) es erlaubt. Das prueft der Job unter der Sperre feature-activate:<id> nach dem frischen Lesen, damit ein Abo, das zwischen
|
||
* Listenlesen und Sperre endet, nicht mehr freischaltet. Gesperrte Dokumente bleiben 'setup' (Notiz und Log, keine Mail).
|
||
*/
|
||
export async function activateClosedTickets({ now = new Date(), customerId = null } = {}) {
|
||
const keys = ticketFreischaltungKeys()
|
||
if (keys.length === 0) return []
|
||
const queries = [Query.equal('featureKey', keys), 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
|
||
const grund = await freischaltHindernis(fresh)
|
||
if (grund) {
|
||
await vermerkeWartet(fresh, grund, now)
|
||
return null
|
||
}
|
||
return updateDocument(config.collections.portalFeatures, doc.$id, {
|
||
...stateFields('active'),
|
||
activatedAt: now.toISOString(),
|
||
})
|
||
})
|
||
if (updated) activated.push(updated)
|
||
}
|
||
return activated
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Team-Aktionen "Freischalten (Abnahme)" und "Ticket nachlegen" (WOMS, Kunden-Reiter Funktionen)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ABNAHME_NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.'
|
||
const TICKET_NUR_SETUP = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.'
|
||
/** Klartext und Status je Grund aus freischaltHindernis (termine/freischaltung.js); ein unbekannter Grund sperrt mit einem allgemeinen 409. */
|
||
const ABNAHME_HINDERNIS = {
|
||
kein_abo: [409, 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'],
|
||
keine_einstellung: [409, 'Es sind noch keine Einstellungen gespeichert.'],
|
||
abo_unbekannt: [503, 'Das Abo konnte gerade nicht geprüft werden.'],
|
||
// Gruende des Registerhakens freischaltbar anderer Funktionen (Oeffnungszeiten live: eingetragene Zeiten), siehe freischaltHindernis oben
|
||
'Voraussetzungen noch nicht erfüllt': [409, 'Die Voraussetzungen für das Freischalten sind noch nicht erfüllt.'],
|
||
'Prüfung nicht möglich': [503, 'Die Voraussetzungen konnten gerade nicht geprüft werden.'],
|
||
}
|
||
|
||
/** Funktionsdokument zu (Kunde, Projekt, Funktion) fuer eine Team-Aktion; ohne Dokument 409 "Für dieses Projekt gibt es noch {keine}". */
|
||
async function dokumentFuerTeamAktion(customerId, projectId, key) {
|
||
assertKey(key)
|
||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||
if (!doc) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
|
||
return doc
|
||
}
|
||
|
||
/** Das Dokument unter der Sperre frisch lesen; ist es inzwischen geloescht (Kunde geloescht), gilt dieselbe Meldung wie ohne Dokument. */
|
||
async function frischesDokument(vorab, key) {
|
||
try {
|
||
return await getDocument(config.collections.portalFeatures, vorab.$id)
|
||
} catch (err) {
|
||
if (err.status === 404) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
|
||
throw err
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen; fehlt der Haken
|
||
* (News), gibt es keine Pruefung und kein Abo wird gelesen. Terminplaner: laufendes Wartungs-Abo und gespeicherte Einstellung, die
|
||
* Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert). Jede andere
|
||
* Funktion mit Haken (Oeffnungszeiten live) fragt wie der 5-Minuten-Job ihren Haken (freischaltHindernis oben, fail-closed): ihre
|
||
* Voraussetzung hat nichts mit Abo und Einstellung des Terminplaners zu tun.
|
||
*/
|
||
async function abnahmeHindernis(doc) {
|
||
if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null
|
||
if (doc.featureKey !== 'termine') return freischaltHindernis(doc)
|
||
const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js')
|
||
return pruefe(doc)
|
||
}
|
||
|
||
/**
|
||
* Nacharbeit nach der Freischaltung: Haken nachFreischaltung der Funktion, falls das Register einen hat (Oeffnungszeiten: Ersatz-Hash
|
||
* merken). Laeuft vor der K16; ein Fehler wird geloggt und bricht nichts ab (die Freischaltung ist dann schon geschehen).
|
||
*/
|
||
async function nacharbeitNachFreischaltung(doc) {
|
||
const { nachFreischaltung } = featureHaken(doc.featureKey)
|
||
if (typeof nachFreischaltung !== 'function') return
|
||
try {
|
||
await nachFreischaltung(doc)
|
||
} catch (err) {
|
||
console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* "Freischalten (Abnahme)": eine Funktion in Einrichtung ('setup') wird freigeschaltet, ohne dass das Einrichtungs-Ticket geschlossen
|
||
* wird. Gesetzt werden nur state/enabled 'active', activatedAt und adminNote; ticketId, Ticket und unlockedByPurchase bleiben.
|
||
* Danach K16 (meldeFreigeschaltet, Fehler werden dort geloggt). Unter derselben Sperre feature-activate:<id> wie der Freischalt-Job
|
||
* (activateClosedTickets) nach frischem Lesen: wer zuerst kommt, aktiviert und sendet; der andere sieht 'active' und tut nichts.
|
||
* - Dokument fehlt: 409 "Für dieses Projekt gibt es noch {keine}".
|
||
* - schon 'active': Erfolg ohne Schreibzugriff und ohne Mail, activated false.
|
||
* - 'off' | 'ordered' | 'disabled': 409. Ein Hindernis (kein Wartungs-Abo, keine Einstellung): 409; Abo nicht lesbar: 503.
|
||
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string, now?: Date }} args
|
||
* @returns {Promise<{ doc: object, activated: boolean }>}
|
||
*/
|
||
export async function freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now = new Date() }) {
|
||
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
|
||
const ergebnis = await withLocks([`feature-activate:${vorab.$id}`], async () => {
|
||
const doc = await frischesDokument(vorab, key)
|
||
if (doc.state === 'active') return { doc, activated: false }
|
||
if (doc.state !== 'setup') throw httpError(409, ABNAHME_NUR_SETUP)
|
||
const grund = await abnahmeHindernis(doc)
|
||
if (grund) {
|
||
const [status, text] = ABNAHME_HINDERNIS[grund] ?? [409, 'Freischalten ist noch nicht möglich.']
|
||
throw httpError(status, text)
|
||
}
|
||
const note = adminNote ?? byNote(by, 'Freigeschaltet (Abnahme)')
|
||
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||
...stateFields('active'),
|
||
activatedAt: now.toISOString(),
|
||
...(note ? { adminNote: note } : {}),
|
||
})
|
||
return { doc: updated, activated: true }
|
||
})
|
||
if (ergebnis.activated) {
|
||
await nacharbeitNachFreischaltung(ergebnis.doc)
|
||
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
|
||
await meldeFreigeschaltet(ergebnis.doc)
|
||
}
|
||
return ergebnis
|
||
}
|
||
|
||
/** Ob das Ticket noch existiert; nur 404 heisst "geloescht", jeder andere Fehler wird weitergereicht. */
|
||
async function ticketBesteht(ticketId) {
|
||
try {
|
||
await getDocument(config.collections.workorders, ticketId)
|
||
return true
|
||
} catch (err) {
|
||
if (err.status === 404) return false
|
||
throw err
|
||
}
|
||
}
|
||
|
||
/** Die bezahlte Stripe-Sitzung des Dokuments; nicht bezahlt: 409, Stripe nicht erreichbar oder nicht konfiguriert: 503 (Ursache im Log). */
|
||
async function bezahlteSitzung(doc) {
|
||
if (!doc.stripeSessionId) throw httpError(409, 'Zu dieser Funktion ist keine Zahlung vermerkt.')
|
||
let session
|
||
try {
|
||
session = await retrieveCheckoutSession(doc.stripeSessionId)
|
||
} catch (err) {
|
||
console.error(`[funktionen] Stripe-Sitzung ${doc.stripeSessionId} nicht abrufbar:`, err.message)
|
||
throw httpError(503, 'Stripe konnte gerade nicht gefragt werden. Bitte später erneut versuchen.')
|
||
}
|
||
// Eine Behalten-Zahlung (Oeffnungszeiten live, zeiten/behalten.js) hat kein Einrichtungs-Ticket nachzulegen. Vor allem aber darf sie nie in
|
||
// onFeaturePaid landen: dessen Behalten-Zweig (bucheBehalten) nimmt feature-admin:<projekt>, und die Team-Route haelt diese Sperre schon
|
||
// (withLocks ist nicht wiederbetretbar => Stillstand, der den Schluessel dauerhaft blockiert).
|
||
if (session?.metadata?.zweck === 'behalten') {
|
||
throw httpError(409, 'Zu dieser Funktion gehört nur eine Behalten-Zahlung, es gibt kein Einrichtungs-Ticket nachzulegen.')
|
||
}
|
||
if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.')
|
||
return session
|
||
}
|
||
|
||
/**
|
||
* Ticket nachlegen (a): 'setup' ohne ticketId, das Ticket entstand nie. Die Zahlung wird bei Stripe geprueft und dann derselbe Weg wie
|
||
* im Webhook gegangen (handleFeatureCheckoutEvent): Ticket, K15 und T16 entstehen genau einmal, ein spaeterer Stripe-Retry findet das
|
||
* Ticket und sendet nichts. Keine Sperre feature-paid:<session> hier, markPaid nimmt sie selbst (withLocks ist nicht wiederbetretbar).
|
||
*/
|
||
async function ticketNachZahlung(doc, note) {
|
||
const session = await bezahlteSitzung(doc)
|
||
await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } })
|
||
const nachher = await getDocument(config.collections.portalFeatures, doc.$id)
|
||
if (!nachher.ticketId) return { doc: nachher, created: false }
|
||
if (!note) return { doc: nachher, created: true }
|
||
try {
|
||
return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true }
|
||
} catch (err) {
|
||
// Ticket und Mails sind raus; die Notiz ist nur ein Vermerk
|
||
console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message)
|
||
return { doc: nachher, created: true }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ticket nachlegen (a) fuer Oeffnungszeiten live ohne Kauf (Abo, Team): es gibt keine Zahlung zu pruefen; das Einrichten holt
|
||
* holeZeitenNach nach (Neubau, Einbau-Ticket, K27 nur bei Abo), derselbe Weg wie der 5-Minuten-Job und das Einschalten. Dienst
|
||
* erst beim Aufruf laden (zeiten/* nur per await import()). Sperren: feature-ticket (hier gehalten) -> zeiten-nachholen ->
|
||
* zeiten-neubau; nichts darin nimmt feature-admin. Wirft, wenn das Ticket nicht angelegt werden kann (der Aufrufer meldet es).
|
||
*/
|
||
async function ticketNachHolen(doc, note) {
|
||
const { holeZeitenNach } = await import('./zeiten/einschalten.js')
|
||
const nachher = await holeZeitenNach(doc)
|
||
if (!nachher.ticketId) return { doc: nachher, created: false }
|
||
if (!note) return { doc: nachher, created: true }
|
||
try {
|
||
return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true }
|
||
} catch (err) {
|
||
// Ticket und Mail sind raus; die Notiz ist nur ein Vermerk
|
||
console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message)
|
||
return { doc: nachher, created: true }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim
|
||
* ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:<session>), damit kein Stripe-Retry daneben
|
||
* schreibt; das Dokument wird darunter erneut gelesen.
|
||
*/
|
||
async function ersetzeGeloeschtesTicket(doc, note) {
|
||
return withLocks([`feature-paid:${doc.stripeSessionId || doc.$id}`], async () => {
|
||
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||
if (frisch.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
|
||
if (frisch.ticketId !== doc.ticketId) return { doc: frisch, created: false }
|
||
const project = await getDocument(config.collections.websiteProjects, frisch.projectId)
|
||
const customer = await customerOf(frisch.customerId).catch(() => null)
|
||
const ticketId = await legeTicketAn(frisch, project, customer)
|
||
const updated = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId, ...(note ? { adminNote: note } : {}) })
|
||
return { doc: updated, created: true }
|
||
})
|
||
}
|
||
|
||
/**
|
||
* "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409).
|
||
* (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen,
|
||
* Ticket + K15 + T16 genau einmal (ticketNachZahlung); Oeffnungszeiten live ohne Kauf (Abo, Team) haben keine Zahlung: dort holt
|
||
* holeZeitenNach Neubau, Ticket und K27 nach (ticketNachHolen);
|
||
* (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket);
|
||
* (c) Ticket vorhanden: nichts, created false.
|
||
* Eine Sperre feature-ticket:<id> je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten
|
||
* (ein Ticket, created nur einmal true).
|
||
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string }} args
|
||
* @returns {Promise<{ doc: object, created: boolean }>}
|
||
*/
|
||
export async function ticketNachlegen({ customerId, projectId, key, by, adminNote }) {
|
||
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
|
||
const note = adminNote ?? byNote(by, 'Ticket nachgelegt')
|
||
return withLocks([`feature-ticket:${vorab.$id}`], async () => {
|
||
const doc = await frischesDokument(vorab, key)
|
||
if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
|
||
if (!doc.ticketId) return key === 'zeiten' && unlockedByVon(doc) !== 'kauf' ? ticketNachHolen(doc, note) : ticketNachZahlung(doc, note)
|
||
if (await ticketBesteht(doc.ticketId)) return { doc, created: false }
|
||
return ersetzeGeloeschtesTicket(doc, note)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null.
|
||
* states ersetzt die Vorgabe ['active'] (die Oeffnungszeiten antworten schon in 'setup', damit das Team die Leisten
|
||
* vor dem Schliessen des Tickets pruefen kann); eine leere Liste passt auf nichts und fragt Appwrite gar nicht erst.
|
||
*/
|
||
export async function findActiveBySiteKey(siteKey, featureKey = 'news', { states = ['active'] } = {}) {
|
||
if (typeof siteKey !== 'string' || !/^[a-z2-7]{16}$/.test(siteKey) || !FEATURE_KEYS.includes(featureKey)) return null
|
||
if (!Array.isArray(states) || states.length === 0) return null
|
||
const [doc] = await listDocuments(config.collections.portalFeatures, [
|
||
Query.equal('siteKey', siteKey),
|
||
Query.equal('featureKey', featureKey),
|
||
Query.equal('state', states),
|
||
Query.limit(1),
|
||
])
|
||
return doc || null
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stripe-Webhook, Mails und Freischalt-Job
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const eur = (cents) => `${(cents / 100).toFixed(2).replace('.', ',')} €`
|
||
/** Adresse der Funktion im Kundenportal (Anker aus dem Register: returnHash). */
|
||
const portalUrl = (feature) => `${config.publicBaseUrl}/dashboard.html#${feature.returnHash}`
|
||
/** Preis der Einrichtung aus dem Katalog der Funktion (feature.product: newsSetup | termineSetup); config.* bei jedem Aufruf gelesen. */
|
||
function einrichtungsPreis(feature) {
|
||
const amountCents = config[feature.product]?.amountCents
|
||
if (!Number.isInteger(amountCents)) throw new Error(`Katalogpreis ${feature.product} fehlt`)
|
||
return eur(amountCents)
|
||
}
|
||
|
||
/**
|
||
* Absolute, versionierte Adressen der Vertragsunterlagen einer Funktion aus dem Register (Fassung der Leistungsbeschreibung und
|
||
* die Adressen von Leistungsbeschreibung, AVV und Widerrufsbelehrung); null bei Funktionen ohne Rechtsdokumente (News).
|
||
* @returns {{ fassung: string, lb: string, avv: string, widerruf: string } | null}
|
||
*/
|
||
export function unterlagenAdressen(feature) {
|
||
const { leistungsbeschreibung: lb, avv, widerruf } = feature ?? {}
|
||
if (!lb?.url || !avv?.url || !widerruf?.url) return null
|
||
const absolut = (pfad) => `${config.publicBaseUrl}${pfad}`
|
||
return { fassung: lb.version, lb: absolut(lb.url), avv: absolut(avv.url), widerruf: absolut(widerruf.url) }
|
||
}
|
||
|
||
/**
|
||
* K15 (Bestellung eingegangen) einer Funktion: Vorlage, Platzhalterwerte und Bloecke ausschliesslich aus dem Register-Eintrag
|
||
* (mails.*, setupEstimate, returnHash) und dem Katalogpreis. Die Werte decken genau die Platzhalter der Vorlage ab.
|
||
* Funktionen mit Rechtsdokumenten (Terminplaner): `unterlagen` (unterlagenAdressen) schaltet den Block anlagen ein und fuellt
|
||
* fassung und die drei Adressen; `anhang` sagt, dass die PDFs an der Mail haengen (Block anhang in anlagen); `zustimmungenText`
|
||
* (zustimmungenText) schaltet den Block zustimmungen ein. Die PDFs selbst gibt onFeaturePaid als `attachments` mit; es gibt keinen
|
||
* Registerhaken dafuer.
|
||
* behalten: der Kunde behaelt die Funktion nach dem Abo-Ende fuer einmalig 30 EUR (Oeffnungszeiten live): die Mail sagt, dass nichts
|
||
* neu eingerichtet wird. Die Vorlage kennt dafuer zwei Gegenstuecke, behalten und neu (Normalfall); genau eins ist an.
|
||
*/
|
||
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '', behalten = false }) {
|
||
const { funktion, formen, preisHinweis, danach } = feature.mails
|
||
return {
|
||
template: 'K15',
|
||
values: {
|
||
kunde_name: kundeName,
|
||
projekt,
|
||
funktion,
|
||
funktion_gen: formen.gen,
|
||
funktion_akk: formen.akk,
|
||
funktion_nom: formen.nom,
|
||
funktion_pron: formen.pron,
|
||
preis: einrichtungsPreis(feature),
|
||
preis_hinweis: preisHinweis,
|
||
zeit: feature.setupEstimate,
|
||
danach,
|
||
portal_url: portalUrl(feature),
|
||
rechnung_url: invoiceUrl || '',
|
||
fassung: unterlagen?.fassung ?? '',
|
||
lb_url: unterlagen?.lb ?? '',
|
||
avv_url: unterlagen?.avv ?? '',
|
||
widerruf_url: unterlagen?.widerruf ?? '',
|
||
zustimmungen_text: zustimmungenText,
|
||
},
|
||
blocks: {
|
||
rechnung: Boolean(invoiceUrl),
|
||
anlagen: unterlagen !== null,
|
||
anhang: unterlagen !== null && anhang === true,
|
||
zustimmungen: zustimmungenText !== '',
|
||
behalten: Boolean(behalten),
|
||
neu: !behalten,
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Wortlaut und Zeitpunkt der erteilten Zustimmungen fuer K15 (Du-Form): die Haken-Texte aus dem Nachweis zur bezahlten Sitzung, der
|
||
* Zeitpunkt des Klicks (Europe/Berlin) und der Knopf, mit dem bestellt wurde. '' ohne verwertbaren Nachweis.
|
||
* @param {{ texte?: string, acceptedAt?: string } | null} nachweis featureConsents-Dokument (JSON-Text im Feld texte)
|
||
*/
|
||
export function zustimmungenText(nachweis) {
|
||
let texte
|
||
try {
|
||
texte = JSON.parse(nachweis?.texte ?? '')
|
||
} catch {
|
||
return ''
|
||
}
|
||
if (!Array.isArray(texte)) return ''
|
||
const haken = texte.filter((t) => t?.haken === true && typeof t.text === 'string' && t.text !== '')
|
||
if (haken.length === 0) return ''
|
||
const am = new Date(nachweis.acceptedAt)
|
||
const zeit = Number.isNaN(am.getTime()) ? '' : (({ date, time }) => ` am ${date} um ${time.slice(0, 2)}:${time.slice(2)} Uhr`)(berlinDateTime(am))
|
||
const knopf = texte.find((t) => t?.id === 'knopf' && typeof t.text === 'string' && t.text !== '')
|
||
return [
|
||
`Du hast${zeit} im Kundenportal zugestimmt${knopf ? ` und mit „${knopf.text}“ bestellt` : ''}:`,
|
||
...haken.map((t) => `– ${t.text}`),
|
||
].join('\n')
|
||
}
|
||
|
||
/** K16 (Funktion bereit) einer Funktion, Werte wie bei bestellMail aus dem Register-Eintrag. */
|
||
export function bereitMail(feature, { kundeName, projekt }) {
|
||
const { funktion, formen, bereit, knopf } = feature.mails
|
||
return {
|
||
template: 'K16',
|
||
values: {
|
||
kunde_name: kundeName,
|
||
projekt,
|
||
funktion,
|
||
funktion_dein: formen.dein,
|
||
funktion_nom: formen.nom,
|
||
bereit,
|
||
knopf,
|
||
portal_url: portalUrl(feature),
|
||
},
|
||
}
|
||
}
|
||
|
||
/** 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)
|
||
}
|
||
|
||
/** Projektname zur ID; ein unbekanntes Projekt ergibt ''. */
|
||
export async function projectName(projectId) {
|
||
try {
|
||
return (await getDocument(config.collections.websiteProjects, projectId)).projectName || ''
|
||
} catch (err) {
|
||
if (err.status === 404) return ''
|
||
throw err
|
||
}
|
||
}
|
||
|
||
/** Kundendokument zur ID; ein unbekannter Kunde ergibt null. */
|
||
export 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. */
|
||
export 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)) || '' : ''
|
||
}
|
||
|
||
/**
|
||
* T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus.
|
||
* Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". Liefert das Ergebnis von sendAdminNotice ({ sent, reason? }); der
|
||
* Der Block gesundheit (Terminplaner) steht nur, wenn der Aufrufer ihn mit gesundheit_text einschaltet (teamNotice).
|
||
*/
|
||
function sendT16({ funktion, ...werte }, bloecke) {
|
||
return sendAdminNotice({
|
||
template: 'T16',
|
||
values: {
|
||
kunde_name: '',
|
||
email: '',
|
||
projekt: '',
|
||
funktion: funktion || 'Funktion',
|
||
woid: '',
|
||
sitekey: '',
|
||
sitzung: '',
|
||
zustand: '',
|
||
anleitung_url: '',
|
||
gesundheit_text: '',
|
||
...werte,
|
||
},
|
||
blocks: {
|
||
zahlung_fehlgeschlagen: false,
|
||
ungeklaert: false,
|
||
kein_abo: false,
|
||
bereits_aktiv: false,
|
||
anleitung: false,
|
||
gesundheit: false,
|
||
anhang_fehlt: false,
|
||
abo: false,
|
||
team: false,
|
||
...bloecke,
|
||
},
|
||
})
|
||
}
|
||
|
||
/** Der Warntext des T16-Blocks gesundheit hat hoechstens so viele Zeichen (ganze Meldungen, danach "… und N weitere"). */
|
||
const GESUNDHEIT_TEXT_MAX = 500
|
||
|
||
/** Meldungen als ein Text: durch "; " getrennt; passt es nicht, so viele ganze Meldungen wie moeglich und "… und N weitere" (nie mitten in einer Meldung). */
|
||
function gesundheitText(meldungen) {
|
||
const alle = meldungen.join('; ')
|
||
if (alle.length <= GESUNDHEIT_TEXT_MAX) return alle
|
||
let text = ''
|
||
let n = 0
|
||
for (const meldung of meldungen) {
|
||
const naechster = text ? `${text}; ${meldung}` : meldung
|
||
if (`${naechster} … und ${meldungen.length - n - 1} weitere`.length > GESUNDHEIT_TEXT_MAX) break
|
||
text = naechster
|
||
n += 1
|
||
}
|
||
return text ? `${text} … und ${meldungen.length - n} weitere` : kuerze(meldungen[0], GESUNDHEIT_TEXT_MAX, { endzeile: '…' })
|
||
}
|
||
|
||
/**
|
||
* T16-Hinweis "Gesundheitsbereich pruefen" (nur Terminplaner): die Treffer der Stichwortpruefung ueber die gespeicherte Einstellung des Betriebs
|
||
* (gesundheitsMeldungen: wohlgeformt, einzeilig, Klartext; renderMail maskiert sie fuer HTML), hoechstens 500 Zeichen. '' ohne Treffer und bei
|
||
* jedem Lesefehler: T16 geht dann ohne Block raus (nie Geld ohne Spur); das Log nennt keine Daten des Betriebs. Dynamische Importe, weil
|
||
* termine/kontext.js dieses Modul importiert.
|
||
*/
|
||
async function gesundheitHinweis(doc) {
|
||
if (doc.featureKey !== 'termine') return ''
|
||
try {
|
||
const [{ ladeEinstellung }, { gesundheitsMeldungen }] = await Promise.all([import('./termine/kontext.js'), import('./termine/einbauanleitung.js')])
|
||
const meldungen = gesundheitsMeldungen((await ladeEinstellung(doc.$id))?.einstellung)
|
||
return meldungen.length ? gesundheitText(meldungen) : ''
|
||
} catch (err) {
|
||
console.error(`[funktionen] Gesundheitshinweis fuer T16 (Funktion ${doc.$id}) nicht lesbar, der Block entfaellt:`, err.message)
|
||
return ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo).
|
||
* anhangFehlt: die Vertragsunterlagen sind nicht beim Kunden angekommen, das Team sendet sie von Hand nach (Block anhang_fehlt).
|
||
*/
|
||
async function teamNotice(doc, customer, { failed = false, keinAbo = false, anhangFehlt = false } = {}) {
|
||
const mails = featureConfig(doc.featureKey)?.mails
|
||
// Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde
|
||
const anleitung = Boolean(mails?.anleitung) && !failed
|
||
const gesundheit = failed ? '' : await gesundheitHinweis(doc)
|
||
await sendT16(
|
||
{
|
||
kunde_name: customer?.name || customer?.email || doc.customerId,
|
||
email: customer?.email || '',
|
||
projekt: await projectName(doc.projectId),
|
||
funktion: mails?.funktion,
|
||
woid: failed ? '' : await ticketWoidOf(doc),
|
||
sitekey: failed ? '' : doc.siteKey || '',
|
||
anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '',
|
||
gesundheit_text: gesundheit,
|
||
},
|
||
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '', anhang_fehlt: anhangFehlt }
|
||
)
|
||
}
|
||
|
||
/** Bezahlte Sitzung ohne passende Bestellung: das Team muss von Hand klaeren (T16, Block ungeklaert). */
|
||
export async function unmatchedNotice(session) {
|
||
const { customerId, projectId, featureKey } = session.metadata || {}
|
||
const customer = customerId ? await customerOf(customerId).catch(() => null) : null
|
||
await sendT16(
|
||
{
|
||
kunde_name: customer?.name || customer?.email || customerId || 'unbekannt',
|
||
email: customer?.email || '',
|
||
projekt: projectId ? (await projectName(projectId).catch(() => '')) || projectId : '',
|
||
funktion: featureConfig(featureKey)?.mails.funktion,
|
||
sitzung: `${session.id} (Kunde ${customerId || '-'}, Projekt ${projectId || '-'})`,
|
||
},
|
||
{ 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
|
||
}
|
||
|
||
/**
|
||
* Meldung zu einer Zahlung genau einmal (T16 bereits_aktiv, T19): unter feature-meldung:<docId> das Dokument frisch lesen;
|
||
* zahlungMeldung === 'gemeldet' => { sent: false, schonGemeldet: true } ohne Versand. Sonst senden(frisch) (liefert { sent }) und
|
||
* erst bei sent === true den Marker 'gemeldet' schreiben (auch wenn dort vorher ein offener Fall stand). Wirft nie: jeder Fehler
|
||
* (Lesen, Aufbau, Versand) ergibt { sent: false } samt Log, der Aufrufer wiederholt. Scheitert nur das Schreiben des Markers, nachdem
|
||
* die Mail raus ist, bleibt es bei { sent: true } (Log): die Spur ist da, eine Wiederholung durch den Aufrufer waere ein Doppel.
|
||
* Nie innerhalb einer feature-paid:*-Sperre aufrufen (withLocks ist nicht wiederbetretbar).
|
||
*/
|
||
async function meldeEinmal(doc, was, senden) {
|
||
try {
|
||
return await withLocks([`feature-meldung:${doc.$id}`], async () => {
|
||
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||
if (frisch.zahlungMeldung === 'gemeldet') return { sent: false, schonGemeldet: true }
|
||
const { sent } = await senden(frisch)
|
||
if (!sent) return { sent: false }
|
||
try {
|
||
await updateDocument(config.collections.portalFeatures, frisch.$id, { zahlungMeldung: 'gemeldet' })
|
||
} catch (err) {
|
||
console.error(`[funktionen] ${was} zur Sitzung ${frisch.stripeSessionId} gesendet, aber der Marker gemeldet blieb ungeschrieben:`, err.message)
|
||
}
|
||
return { sent: true }
|
||
})
|
||
} catch (err) {
|
||
console.error(`[funktionen] ${was} zur Sitzung ${doc.stripeSessionId} nicht gesendet:`, err.message)
|
||
return { sent: false }
|
||
}
|
||
}
|
||
|
||
/** Kunde, Projekt und Funktionsname fuer die Team-Meldungen; was sich nicht lesen laesst, faellt auf IDs zurueck (die Meldung geht trotzdem raus). */
|
||
async function meldeKopf(doc, customer) {
|
||
const kunde = customer ?? (await customerOf(doc.customerId).catch(() => null))
|
||
return {
|
||
kunde_name: kunde?.name || kunde?.email || doc.customerId,
|
||
email: kunde?.email || '',
|
||
projekt: (await projectName(doc.projectId).catch(() => '')) || doc.projectId,
|
||
funktion: featureConfig(doc.featureKey)?.mails.funktion,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Zahlung auf eine Funktion, die schon freigeschaltet, in Einrichtung oder abgeschaltet war (markPaid-Fall 'bereits_aktiv' |
|
||
* 'abgeschaltet'): T16 nur mit Block bereits_aktiv (zustand "freigeschaltet", "in Einrichtung" bzw. "abgeschaltet", woid leer), keine
|
||
* K15, kein Ticket. Genau einmal je Dokument (siehe meldeEinmal).
|
||
* @param {object} doc portalFeatures-Dokument
|
||
* @param {'bereits_aktiv'|'abgeschaltet'} fall
|
||
* @param {{ customer?: object|null }} [options] bereits gelesener Kunde, sonst wird er gelesen
|
||
* @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>}
|
||
*/
|
||
export function meldeZahlungFall(doc, fall, { customer = null } = {}) {
|
||
return meldeEinmal(doc, 'T16 bereits_aktiv', async (frisch) => {
|
||
const zustand = fall === 'abgeschaltet' ? 'abgeschaltet' : frisch.state === 'setup' ? 'in Einrichtung' : 'freigeschaltet'
|
||
return sendT16({ ...(await meldeKopf(frisch, customer)), sitzung: frisch.stripeSessionId || '', zustand }, { bereits_aktiv: true })
|
||
})
|
||
}
|
||
|
||
/**
|
||
* T19 "Erstattung faellig" an das Team: die einzige Stelle, die T19 sendet (Zahlung nach dem Abo-Ende hier, die Meldung des
|
||
* Abo-Abgleichs fuer bezahlte, abgeschaltete Einrichtungen in Block E). Blocks: zahlung_nach_ende, wenn nachAboEnde gesetzt ist oder
|
||
* am (frisch gelesenen) Dokument der offene Fall 'abo_ende' steht - so auch, wenn der Abgleich vor dem Webhook-Versand zuerst meldet;
|
||
* ticket_offen, wenn das Dokument ein Ticket hat (kein Ticket und keine spaete Zahlung: keiner von beiden); rechnung mit Rechnungslink.
|
||
* Genau einmal je Dokument (siehe meldeEinmal), wirft nie.
|
||
* ticketOffen und rechnungUrl ueberschreiben, was sonst am Dokument steht (Ticket vorhanden, invoiceUrl): bei einer Zahlung, die nicht zu
|
||
* dessen Ticket und Rechnung gehoert (Behalten, zeiten/behalten.js), sagt T19 sonst "Ticket offen" und zeigt die Rechnung einer anderen Zahlung.
|
||
* rechnungUrl '' heisst "keine Rechnung"; null (Vorgabe) nimmt die des Dokuments.
|
||
* @param {object} doc portalFeatures-Dokument
|
||
* @param {{ customer?: object|null, sitzung?: string, nachAboEnde?: boolean, ticketOffen?: boolean|null, rechnungUrl?: string|null }} [options]
|
||
* @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>}
|
||
*/
|
||
export async function meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false, ticketOffen = null, rechnungUrl = null } = {}) {
|
||
return meldeEinmal(doc, 'T19 Erstattung faellig', async (frisch) => {
|
||
const kopf = await meldeKopf(frisch, customer)
|
||
const woid = frisch.ticketId ? await ticketWoidOf(frisch).catch(() => frisch.ticketId) : ''
|
||
const rechnung = rechnungUrl ?? frisch.invoiceUrl ?? ''
|
||
return sendAdminNotice({
|
||
template: 'T19',
|
||
values: {
|
||
...kopf,
|
||
funktion: kopf.funktion || 'Funktion',
|
||
betrag: featureConfig(frisch.featureKey)?.priceLabel || '',
|
||
sitzung: sitzung || '',
|
||
woid,
|
||
rechnung_url: rechnung,
|
||
},
|
||
blocks: {
|
||
zahlung_nach_ende: nachAboEnde || frisch.zahlungMeldung === 'abo_ende',
|
||
ticket_offen: ticketOffen ?? Boolean(frisch.ticketId),
|
||
rechnung: Boolean(rechnung),
|
||
},
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Eine Zahlung traf ein Dokument, das nicht mehr bestellt war (markPaid-Fall): gebucht ist sie schon, hier geht die Meldung ans Team -
|
||
* 'abo_ende' als T19 (Erstattung), 'bereits_aktiv' und 'abgeschaltet' als T16. Kein ordentliches Ticket, keine K15.
|
||
* Wirft, wenn die Meldung weder raus ist noch schon draussen war: der Webhook antwortet 500, Stripe wiederholt, markPaid liefert den
|
||
* noch offenen Fall erneut und der naechste Versuch sendet genau einmal.
|
||
*/
|
||
async function meldeFallZurZahlung(doc, fall, session) {
|
||
console.error(
|
||
`[funktionen] BEZAHLTE Sitzung ${session.id} traf die Funktion ${doc.featureKey} im Zustand ${doc.state} (Fall ${fall}, Kunde ${doc.customerId}, Projekt ${doc.projectId}) - nichts eingerichtet, das Team wird benachrichtigt`
|
||
)
|
||
const customer = await customerOf(doc.customerId).catch((err) => {
|
||
console.error('[funktionen] Kunde fuer die Zahlungsmeldung nicht lesbar:', err.message)
|
||
return null
|
||
})
|
||
const ergebnis =
|
||
fall === 'abo_ende'
|
||
? await meldeErstattung(doc, { customer, nachAboEnde: true })
|
||
: await meldeZahlungFall(doc, fall, { customer })
|
||
if (!ergebnis.sent && !ergebnis.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (${fall}) nicht gesendet`)
|
||
}
|
||
|
||
/**
|
||
* Zahlung "fuer 30 EUR behalten" (Oeffnungszeiten live nach dem Abo-Ende, Spec 3.3): eigener Weg in zeiten/behalten.js, nie ueber
|
||
* markPaid und adoptPaidSession (die schalten nur aus 'ordered' ein und meldeten die Zahlung einer laufenden Funktion als ungeklaert).
|
||
* Dynamischer Import: zeiten/* importiert dieses Modul.
|
||
*/
|
||
async function onBehaltenPaid(session) {
|
||
const { bucheBehalten } = await import('./zeiten/behalten.js')
|
||
await bucheBehalten(session)
|
||
}
|
||
|
||
/** K15 einer Funktion ohne Rechtsdokumente (News): keine Anhaenge, keine Bloecke anlagen und zustimmungen. */
|
||
const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), unterlagen: null, anhang: false, zustimmungen: '', fehlt: false })
|
||
|
||
/**
|
||
* Rechtsunterlagen der K15 einer Funktion mit Rechtsdokumenten: die PDFs und Adressen der Fassung, der der Kunde bei der Bestellung
|
||
* zugestimmt hat (Fassung des Nachweises zur bezahlten Sitzung), und der Wortlaut seiner Zustimmungen. Das ist meist die aktuelle
|
||
* Fassung (legalDocs.featureLegalAttachments, erst beim Aufruf geladen; kein Registerhaken). Wurde die Fassung zwischen Klick und
|
||
* Zahlung angehoben, gehen die Unterlagen der vereinbarten Fassung raus, wenn es sie gibt; fehlen sie, geht K15 ohne PDFs und Links
|
||
* raus (bei der aktuellen Fassung bleiben die Links, nur die PDFs fehlen). Ist der Nachweis nicht lesbar oder seine Fassung nicht bestimmbar, ist die vereinbarte Fassung unbekannt: nie die aktuelle
|
||
* ungeprueft anhaengen, K15 geht ohne PDFs und Links raus. Ohne Nachweis (Altbestand) gilt die aktuelle Fassung. In allen Faellen ohne
|
||
* Unterlagen ist fehlt true (T16 sagt "von Hand nachsenden") und der Grund steht im Log. Wirft nie.
|
||
* @returns {Promise<{ attachments: object[], unterlagen: { fassung: string, lb: string, avv: string, widerruf: string } | null, anhang: boolean, zustimmungen: string, fehlt: boolean }>}
|
||
*/
|
||
async function k15Rechtsunterlagen(doc, feature, session) {
|
||
const ohne = (zustimmungen) => ({ attachments: [], unterlagen: null, anhang: false, zustimmungen, fehlt: true })
|
||
let nachweis = null
|
||
try {
|
||
nachweis = await zustimmungFuerSitzung(session.id)
|
||
} catch (err) {
|
||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Zustimmungsnachweis zu Sitzung ${session.id} nicht lesbar, die vereinbarte Fassung ist unbekannt - ohne Unterlagen, das Team sendet sie von Hand:`, err.message)
|
||
return ohne('')
|
||
}
|
||
const zustimmungen = zustimmungenText(nachweis)
|
||
const aktuell = feature.consent.version
|
||
let vereinbart = aktuell
|
||
if (nachweis) {
|
||
try {
|
||
vereinbart = JSON.parse(nachweis.fassungen)?.zustimmung
|
||
} catch {
|
||
vereinbart = undefined
|
||
}
|
||
if (typeof vereinbart !== 'string' || vereinbart === '') {
|
||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Fassung im Zustimmungsnachweis nicht bestimmbar - ohne Unterlagen, das Team sendet sie von Hand`)
|
||
return ohne(zustimmungen)
|
||
}
|
||
}
|
||
try {
|
||
const legal = await import('./legalDocs.js')
|
||
const eigene = vereinbart === aktuell ? {} : { fassung: vereinbart }
|
||
if (vereinbart !== aktuell) {
|
||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Der Kunde hat der Fassung ${vereinbart} zugestimmt, aktuell ist ${aktuell} - die Unterlagen der vereinbarten Fassung gehen raus, wenn es sie gibt`)
|
||
if (!(await legal.featureLegalReady(doc.featureKey, eigene))) throw new Error(`Unterlagen der Fassung ${vereinbart} fehlen`)
|
||
}
|
||
const attachments = await legal.featureLegalAttachments(doc.featureKey, eigene)
|
||
if (attachments.length === 0) throw new Error('keine Anhaenge fuer diese Funktion')
|
||
const adressen = legal.featureLegalPaths(doc.featureKey, eigene)
|
||
const absolut = (pfad) => `${config.publicBaseUrl}${pfad}`
|
||
const unterlagen = { fassung: vereinbart, lb: absolut(adressen.lb), avv: absolut(adressen.avv), widerruf: absolut(adressen.widerruf) }
|
||
return { attachments, unterlagen, anhang: true, zustimmungen, fehlt: false }
|
||
} catch (err) {
|
||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Unterlagen nicht lesbar, K15 geht ohne PDFs raus:`, err.message)
|
||
// die Seiten der aktuellen (= vereinbarten) Fassung bleiben verlinkt; Links auf eine andere Fassung gibt es nur, wenn deren Dateien da sind
|
||
return { ...ohne(zustimmungen), unterlagen: vereinbart === aktuell ? unterlagenAdressen(feature) : null }
|
||
}
|
||
}
|
||
|
||
async function onFeaturePaid(session) {
|
||
if (session.metadata?.zweck === 'behalten') return onBehaltenPaid(session)
|
||
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
|
||
const paid = { sessionId: session.id, invoiceId, invoiceUrl }
|
||
let { doc, created, fall } = 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, fall } = await markPaid(paid))
|
||
// Das Dokument wartet nicht mehr auf die Zahlung (aktiv, abgeschaltet, in Einrichtung): nur Meldung, kein Ticket, keine K15
|
||
if (fall) return meldeFallZurZahlung(doc, fall, session)
|
||
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.
|
||
const feature = featureConfig(doc.featureKey)
|
||
let customer = null
|
||
try {
|
||
customer = await customerOf(doc.customerId)
|
||
} catch (err) {
|
||
console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message)
|
||
}
|
||
// K15 mit den Vertragsunterlagen (Terminplaner: drei PDFs als dauerhafter Datentraeger, § 312f BGB). Die Bestaetigung geht vor dem
|
||
// Anhang: sind die PDFs nicht lesbar oder kommt die Mail nicht an, bleibt die Zahlung nicht ohne Spur, T16 sagt dem Team
|
||
// "Unterlagen von Hand nachsenden" (Block anhang_fehlt).
|
||
const mitRechtsdokumenten = feature ? unterlagenAdressen(feature) !== null : false
|
||
let anhangFehlt = false
|
||
if (customer?.email) {
|
||
try {
|
||
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
||
const rechtlich = mitRechtsdokumenten ? await k15Rechtsunterlagen(doc, feature, session) : OHNE_RECHTSUNTERLAGEN
|
||
anhangFehlt = rechtlich.fehlt
|
||
await sendCustomerMail(customer.email, {
|
||
...bestellMail(feature, {
|
||
kundeName: customer.name || '',
|
||
projekt: await projectName(doc.projectId),
|
||
invoiceUrl,
|
||
unterlagen: rechtlich.unterlagen,
|
||
anhang: rechtlich.anhang,
|
||
zustimmungenText: rechtlich.zustimmungen,
|
||
}),
|
||
attachments: [...rechtlich.attachments],
|
||
})
|
||
} catch (err) {
|
||
console.error('[funktionen] K15 fehlgeschlagen:', err.message)
|
||
if (mitRechtsdokumenten) anhangFehlt = true
|
||
}
|
||
} else if (mitRechtsdokumenten) {
|
||
console.error(`[funktionen] K15 (Funktion ${doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt - die Vertragsunterlagen muessen von Hand nachgesendet werden`)
|
||
anhangFehlt = true
|
||
}
|
||
// Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo).
|
||
// Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500.
|
||
let keinAbo = false
|
||
if (feature?.requiresWartungsAbo) {
|
||
try {
|
||
keinAbo = !(await laeuft(doc.customerId))
|
||
} catch (err) {
|
||
console.error(`[funktionen] Wartungs-Abo von Kunde ${doc.customerId} fuer T16 nicht lesbar, Hinweis kein_abo entfaellt:`, err.message)
|
||
}
|
||
}
|
||
try {
|
||
await teamNotice(doc, customer, { keinAbo, anhangFehlt })
|
||
} 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': {
|
||
if (session.metadata?.zweck === 'behalten') return verwerfeBehaltenSitzung(session)
|
||
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':
|
||
if (session.metadata?.zweck === 'behalten') return verwerfeBehaltenSitzung(session)
|
||
await markCheckoutFailed(session.id)
|
||
return
|
||
}
|
||
}
|
||
|
||
/** Behalten-Zahlung abgelaufen oder fehlgeschlagen: die offene Sitzung am Dokument wird frei (Dynamischer Import wie onBehaltenPaid). */
|
||
async function verwerfeBehaltenSitzung(session) {
|
||
const { verwerfeBehalten } = await import('./zeiten/behalten.js')
|
||
await verwerfeBehalten(session)
|
||
}
|
||
|
||
/**
|
||
* Selbstheilung der Oeffnungszeiten (Spec 3.2): fuer jedes Dokument der Zeiten in 'setup' holt holeZeitenNach fehlende Schritte
|
||
* nach (Neubau, Einbau-Ticket, K27; es sperrt je Dokument selbst und fasst Kauf, aktiv und aus nicht an). Hierher und nicht in
|
||
* activateClosedTickets: dort zaehlen nur Dokumente MIT Ticket. Jedes Dokument fuer sich abgesichert (ein Fehler stoppt weder die
|
||
* uebrigen noch den Job; der naechste Lauf versucht es erneut), das Log nennt Dokument-ID und Fehler, nie Kundendaten.
|
||
* Dynamischer Import: zeiten/* importiert dieses Modul.
|
||
*/
|
||
async function heileZeiten({ now }) {
|
||
if (!featureConfig('zeiten')) return
|
||
let docs
|
||
try {
|
||
docs = await listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'zeiten'), Query.equal('state', 'setup'), Query.limit(1000)])
|
||
} catch (err) {
|
||
console.error('[funktionen] Selbstheilung Öffnungszeiten: Dokumente nicht lesbar:', err.message)
|
||
return
|
||
}
|
||
if (docs.length === 0) return
|
||
const { holeZeitenNach } = await import('./zeiten/einschalten.js')
|
||
for (const doc of docs) {
|
||
try {
|
||
await holeZeitenNach(doc, { now })
|
||
} catch (err) {
|
||
console.error(`[funktionen] Selbstheilung Öffnungszeiten (${doc.$id}) fehlgeschlagen:`, [err?.name ?? 'Fehler', err?.status, err?.message].filter(Boolean).join(' '))
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active' (je Funktion nur, wenn ihr Haken freischaltbar es erlaubt; Oeffnungszeiten:
|
||
* erst mit eingetragenen Zeiten), danach je Dokument der Haken nachFreischaltung (Oeffnungszeiten: Ersatz-Hash) und K16 an jeden
|
||
* betroffenen Kunden (meldeFreigeschaltet: Fehler werden geloggt und halten weder die Aktivierung noch die uebrigen Mails auf).
|
||
* termine/freischaltung.js importiert diese Datei, darum wird es hier erst im Aufruf geladen. Zum Schluss, nur im Lauf des Jobs
|
||
* (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die Projektliste eines Kunden (GET /api/funktionen, mit
|
||
* customerId) schaltet nur frei und baut, bestellt und sendet nichts nach.
|
||
*/
|
||
export async function runActivation({ now = new Date(), customerId = null } = {}) {
|
||
const activated = await activateClosedTickets({ now, customerId })
|
||
if (activated.length > 0) {
|
||
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
|
||
for (const doc of activated) {
|
||
await nacharbeitNachFreischaltung(doc)
|
||
await meldeFreigeschaltet(doc)
|
||
}
|
||
}
|
||
if (!customerId) await heileZeiten({ now })
|
||
return activated
|
||
}
|