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>
182 lines
11 KiB
JavaScript
182 lines
11 KiB
JavaScript
import { config } from '../../config.js'
|
|
import { getDocument, updateDocument } from '../appwriteAdmin.js'
|
|
import { pruefeAboFuerEinschalten } from '../featureOrder.js'
|
|
import { featureConfig, ticketTexte } from '../featureRegistry.js'
|
|
import { withLocks } from '../keyedLock.js'
|
|
import { customerOf, ensureFeatureDoc, getFeatureDoc, stateFields, ticketWoid, unlockedByVon } from '../siteFeatures.js'
|
|
import { createFeatureTicket } from '../ticketAdmin.js'
|
|
import { k27Mail, sendeZeitenMail } from './mails.js'
|
|
import { stosseNeubauAn } from './neubau.js'
|
|
import { ladeZeiten } from './speicher.js'
|
|
import { hauptplan, statusZu } from './zeitlogik.js'
|
|
|
|
/**
|
|
* Gratisweg "Einschalten" von Oeffnungszeiten live im Wartungs-Abo (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 3.2)
|
|
* und die Selbstheilung der Schritte danach. Vorbild ist activateAnalyse (analyse.js).
|
|
*
|
|
* einschaltenZeiten Schritte 1-4 unter der Sperre `feature-order:<kunde>:<projekt>` (dieselbe wie im Checkout, damit sich
|
|
* Bestellen und Einschalten ausschliessen): Abo frisch pruefen, Zustand pruefen (idempotent), Nachweis speichern,
|
|
* Dokument auf setup. Danach, ohne diese Sperre: holeZeitenNach (Neubau, Ticket, K27).
|
|
* holeZeitenNach Schritte 5 und 6, jeder fuer sich nur, wenn er noch fehlt: Neubau (einbau), Einbau-Ticket, K27, dann einschaltMailAt.
|
|
* Eigene Sperre `zeiten-nachholen:<dokument>`: ein Doppelklick und der 5-Minuten-Job legen nie zwei Tickets an.
|
|
*
|
|
* Sperren (keyedLock) sind nicht wiederbetretbar: stosseNeubauAn nimmt seine eigene Sperre `zeiten-neubau`, darum laufen Neubau, Ticket
|
|
* und Mail nie innerhalb von `feature-order`. Aufrufer, die `feature-order` oder `zeiten-nachholen` halten, duerfen diese Funktionen
|
|
* nicht aufrufen.
|
|
*/
|
|
|
|
const KEY = 'zeiten'
|
|
|
|
/** Einhaengepunkt fuer Tests: die Uhr der Projektliste (GET /api/funktionen), die den Kurzstatus der Website berechnet. */
|
|
export const einschaltenDeps = { jetzt: () => new Date() }
|
|
|
|
const NOTIZ_MAX = 300
|
|
const NICHT_FERTIG = 'Öffnungszeiten live ist eingeschaltet, aber das Einrichten ist noch nicht fertig. Bitte versuch es in einer Minute noch einmal.'
|
|
|
|
const konflikt = (text) => Object.assign(new Error(text), { status: 409 })
|
|
|
|
/** Notiz am Dokument: die der Route (staff.adminNote) oder "<Zeit> <Name>: Eingeschaltet ohne Zahlung" vor der bisherigen Notiz. */
|
|
function notizDesTeams(staff, bisherige, now) {
|
|
if (typeof staff?.adminNote === 'string' && staff.adminNote) return staff.adminNote.slice(0, NOTIZ_MAX)
|
|
if (!staff?.name) return null
|
|
const eintrag = `${now.toISOString()} ${staff.name}: Eingeschaltet ohne Zahlung`
|
|
return (bisherige ? `${eintrag} | ${bisherige}` : eintrag).slice(0, NOTIZ_MAX)
|
|
}
|
|
|
|
/**
|
|
* Oeffnungszeiten live fuer ein Projekt einschalten (Spec 3.2, Schritte 1 bis 6).
|
|
* @param {{ customerId: string, projectId: string, by?: 'kunde'|'team', staff?: { name?: string, adminNote?: string }|null,
|
|
* nachweis?: (() => Promise<unknown>)|null, now?: Date }} p
|
|
* by 'kunde': Abo frisch pruefen (kein_abo/gekuendigt/nicht_aktiv 409, Lesefehler 503), unlockedBy 'abo', K27 geht an den Kunden.
|
|
* by 'team': keine Abo-Pruefung, unlockedBy 'team' (ein bisher gekauftes Dokument bleibt kauf), keine K27; staff ergibt die Notiz am Dokument.
|
|
* nachweis: speichert den Zustimmungsnachweis (Schritt 3, nur wenn wirklich eingeschaltet wird); wirft er, bleibt der Zustand unveraendert.
|
|
* @returns {Promise<{ doc: object, changed: boolean }>} doc: das Dokument nach dem Nachholen; changed: false bei idempotentem Aufruf
|
|
* @throws {Error} 409 (Abo, anderer Zustand), 503 (Abo nicht lesbar; oder Zustand gespeichert, aber Neubau/Ticket nicht fertig: erneut aufrufen)
|
|
*/
|
|
export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', staff = null, nachweis = null, now = new Date() } = {}) {
|
|
if (by !== 'kunde' && by !== 'team') throw new TypeError(`einschaltenZeiten: by muss kunde oder team sein, nicht ${String(by)}`)
|
|
const feature = featureConfig(KEY)
|
|
|
|
const { doc, changed } = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
|
// 1 Abo frisch (nie aus einem Zwischenspeicher); fuer das Team entfaellt es
|
|
if (by === 'kunde') await pruefeAboFuerEinschalten(customerId)
|
|
|
|
// 2 Zustand: schon eingeschaltet (setup/active) ist idempotent; sonst nur aus off (der Kunde) bzw. off/ordered/disabled (das Team)
|
|
const bisher = await getFeatureDoc(customerId, projectId, KEY)
|
|
if (bisher?.state === 'setup' || bisher?.state === 'active') {
|
|
if (by === 'team' || unlockedByVon(bisher) === 'abo') return { doc: bisher, changed: false }
|
|
throw konflikt(feature.orderedConflictMessage)
|
|
}
|
|
if (by === 'kunde' && bisher?.state === 'ordered') throw konflikt(feature.orderedConflictMessage)
|
|
if (by === 'kunde' && bisher?.state === 'disabled') throw konflikt(feature.disabledMessage)
|
|
|
|
// 3 Zustimmung speichern (ohne Stripe-Sitzung), vor dem Zustand: scheitert sie, bleibt alles wie es war
|
|
if (nachweis) await nachweis()
|
|
|
|
// 4 Dokument auf setup. Reste einer frueheren Abschaltung oder eines Abo-Endes gehoeren zum alten Lauf und enden hier;
|
|
// einbau beginnt leer, der Neubau (holeZeitenNach) setzt es neu.
|
|
const vorhanden = bisher ?? (await ensureFeatureDoc(customerId, projectId, KEY))
|
|
const notiz = by === 'team' ? notizDesTeams(staff, vorhanden.adminNote, now) : null
|
|
// Ein gekauftes Dokument (bisher kauf), das das Team wieder einschaltet, bleibt gekauft: unlockedBy/unlockedByPurchase ordnen den
|
|
// Widerruf zu (paidFeatureOrders). Neubau und Ticket kennt dort markPaid, nicht das Nachholen (holeZeitenNach laesst kauf aus): einbau
|
|
// bleibt darum stehen, statt auf leer gesetzt zu werden, ohne dass es je neu gefuellt wuerde.
|
|
const gekauft = by === 'team' && unlockedByVon(bisher) === 'kauf'
|
|
const gespeichert = await updateDocument(config.collections.portalFeatures, vorhanden.$id, {
|
|
...stateFields('setup'),
|
|
featureKey: KEY,
|
|
unlockedBy: gekauft ? 'kauf' : by === 'kunde' ? 'abo' : 'team',
|
|
unlockedByPurchase: gekauft,
|
|
...(gekauft ? {} : { einbau: '' }),
|
|
aboEndeAt: null,
|
|
behaltenSessionId: '',
|
|
disabledAt: null,
|
|
disabledReason: null,
|
|
aboEndeMailAt: null,
|
|
...(notiz ? { adminNote: notiz } : {}),
|
|
})
|
|
return { doc: gespeichert, changed: true }
|
|
})
|
|
|
|
// 5 und 6 nach der Sperre: Neubau, Ticket, K27
|
|
try {
|
|
return { doc: await holeZeitenNach(doc, { now }), changed }
|
|
} catch (err) {
|
|
// der Zustand ist gespeichert; ein erneuter Aufruf (oder der 5-Minuten-Job) holt nach. Im Log Art und Meldung, nie beim Kunden.
|
|
console.error(`[zeiten] Einschalten für Projekt ${projectId}: Nachholen fehlgeschlagen: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.message ?? ''}`.trim())
|
|
throw Object.assign(new Error(NICHT_FERTIG), { status: 503, cause: err })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fehlende Schritte des Einschaltens nachholen (Spec 3.2 Schritt 5/6 und Selbstheilung): fuer ein Dokument im Zustand setup, das nicht
|
|
* vom Kauf stammt (unlockedBy kauf bringt markPaid in Ordnung):
|
|
* 1. einbau leer => Neubau anstossen (stosseNeubauAn), Ergebnis in einbau
|
|
* 2. ticketId leer => Einbau-Ticket aus dem Register (Text mit dem frischen einbau), ticketId
|
|
* 3. unlockedBy abo und einschaltMailAt leer => K27 an den Kunden, danach einschaltMailAt. Gesendet wird erst nach dem Ticket (die Mail nennt es);
|
|
* nicht gesendet (Fehler) laesst einschaltMailAt leer, ein Kunde ohne Adresse oder ein geloeschter Kunde bekommt einschaltMailAt (es gibt nichts zu senden).
|
|
* Jeder Schritt erst nach dem Erfolg des vorigen. Liest das Dokument unter der Sperre frisch; andere Zustaende bleiben unberuehrt.
|
|
* Wirft bei Lesefehlern und wenn das Ticket nicht angelegt werden kann (der Aufrufer faengt und versucht es spaeter); eine
|
|
* nicht gesendete K27 wirft nicht.
|
|
* @param {{ $id: string }} doc portalFeatures-Dokument der Zeiten (nur $id zaehlt)
|
|
* @param {{ now?: Date }} [options]
|
|
* @returns {Promise<object>} das aktuelle Dokument
|
|
*/
|
|
export async function holeZeitenNach(doc, { now = new Date() } = {}) {
|
|
if (!doc?.$id) return doc
|
|
return withLocks([`zeiten-nachholen:${doc.$id}`], async () => {
|
|
let frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
|
if (frisch.state !== 'setup' || unlockedByVon(frisch) === 'kauf') return frisch
|
|
const mitK27 = unlockedByVon(frisch) === 'abo'
|
|
// nichts fehlt: weder Projekt noch Kunde lesen (der 5-Minuten-Job fragt jedes Dokument in setup)
|
|
if (frisch.einbau && frisch.ticketId && !(mitK27 && !frisch.einschaltMailAt)) return frisch
|
|
const feature = featureConfig(KEY)
|
|
// das Projekt frisch: der Neubau und der Text des Tickets brauchen den Stand nach dem Speichern des Zustands
|
|
const project = await getDocument(config.collections.websiteProjects, frisch.projectId)
|
|
|
|
if (!frisch.einbau) {
|
|
const einbau = await stosseNeubauAn(project)
|
|
frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { einbau })
|
|
}
|
|
|
|
const customer = await customerOf(frisch.customerId)
|
|
if (!frisch.ticketId) {
|
|
const texte = await ticketTexte({ feature, doc: frisch, project, customer })
|
|
const { ticketId } = await createFeatureTicket({ customerId: frisch.customerId, project, featureKey: KEY, siteKey: frisch.siteKey, ...texte })
|
|
frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId })
|
|
}
|
|
|
|
if (mitK27 && !frisch.einschaltMailAt) {
|
|
const nachricht = k27Mail({
|
|
kunde: customer,
|
|
projekt: project?.projectName || project?.subdomain || '',
|
|
einbau: frisch.einbau,
|
|
wieder: false,
|
|
ticketNr: await ticketWoid(frisch.ticketId).catch(() => null),
|
|
})
|
|
const { sent, reason } = await sendeZeitenMail(customer, nachricht)
|
|
if (sent || reason === 'keine_adresse') {
|
|
frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { einschaltMailAt: now.toISOString() })
|
|
}
|
|
}
|
|
return frisch
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Teil der Projektliste (GET /api/funktionen) zu den Zeiten eines Projekts: { zeitenStatus, zeitenLeer }. Eine Abfrage je Projekt mit
|
|
* Funktionsdokument (nicht je Kachel im Browser). zeitenLeer: es sind keine Plaene gespeichert (auch ohne Dokument oder im Zustand off).
|
|
* zeitenStatus { offen, kurz, text } (Hauptplan, wie ihn die Website zeigt): nur in setup und active und nur mit gespeichertem
|
|
* Hauptplan, sonst null. Wirft bei Lesefehlern.
|
|
* @param {object|null|undefined} doc portalFeatures-Dokument der Zeiten
|
|
* @param {Date} now
|
|
*/
|
|
export async function zeitenProjektAnsicht(doc, now) {
|
|
if (!doc || doc.state === 'off') return { zeitenStatus: null, zeitenLeer: true }
|
|
const daten = (await ladeZeiten(doc.$id))?.daten ?? null
|
|
const leer = !Array.isArray(daten?.plaene) || daten.plaene.length === 0
|
|
const haupt = !leer && (doc.state === 'setup' || doc.state === 'active') ? hauptplan(daten) : null
|
|
if (!haupt) return { zeitenStatus: null, zeitenLeer: leer }
|
|
const { offen, kurz, text } = statusZu(daten, haupt, now)
|
|
return { zeitenStatus: { offen, kurz, text }, zeitenLeer: false }
|
|
}
|