import { config } from '../../config.js' import { Query, countDocuments, listDocuments } from '../appwriteAdmin.js' import { isHanging } from '../newsPosts.js' import { heute, plusTage, uhrzeit, zuUtc } from '../termine/zeit.js' /** * Kurzinfo der aktiven Funktionen fuer GET /api/funktionen?kurzinfo=1 in der neuen Ansicht (nur "Meine" fragt danach): je Projekt und Funktion `kurzinfo`, ein kurzer * Text fuer die zweite Zeile in "Meine" ("3 Termine heute · nächster 10:30", "2 neue Bestellungen", ...) oder null. Berliner Zeit. * * Regeln: * - Nur Funktionen im Zustand active bekommen einen Text (und eine Abfrage); alle anderen null. Die Analyse hat keine Kurzinfo * (ihre Zahlen kommen aus dem Auswertungsdienst, nicht aus Appwrite: eine Netzabfrage je Seitenaufruf lohnt dafuer nicht). * - Je Funktion hoechstens zwei schmale Abfragen (limit, select, Filter auf Index-Felder), alle Funktionen und Websites gleichzeitig. * - Jede Funktion ist einzeln begrenzt (kurzinfoDeps.limitMs) und fehlertolerant: Fehler oder Zeitueberschreitung ergibt null, die * Oberflaeche zeigt dann "Aktiv". Im Log stehen nur Funktion, Art und Status, nie Text oder Daten (Gaeste, Kaeufer). * - Ist eine Seite voll (limit erreicht), ist die Zahl unbekannt: null statt einer falschen Zahl. * - Die Texte nennen weder Namen noch Adressen noch Betraege. */ /** Einhaengepunkt fuer Tests: Zeitgrenze je Funktion in Millisekunden. */ export const kurzinfoDeps = { limitMs: 1500 } const SEITE = 100 const SEITE_NEWS = 200 const MAX_ZEICHEN = 80 const mehrzahl = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}` const istText = (wert) => typeof wert === 'string' && wert.trim() !== '' const zeitMs = (wert) => Date.parse(wert) /** Texte der Funktionen (rein): Eingabe sind die gezaehlten Werte, Ausgabe der Satz. */ export const KURZINFO_TEXTE = { termine: ({ anzahl, naechster }) => anzahl === 0 ? 'Heute keine Termine' : `${mehrzahl(anzahl, 'Termin', 'Termine')} heute${naechster ? ` · nächster ${naechster}` : ''}`, shop: ({ anzahl }) => (anzahl === 0 ? 'Keine offenen Bestellungen' : `${mehrzahl(anzahl, 'neue Bestellung', 'neue Bestellungen')}`), news: ({ anzahl }) => (anzahl === 0 ? 'Keine Beiträge sichtbar' : `${mehrzahl(anzahl, 'Beitrag', 'Beiträge')} sichtbar`), unterkunft: ({ offen, anreise }) => { if (offen > 0) return mehrzahl(offen, 'offene Anfrage', 'offene Anfragen') return anreise ? anreise : 'Keine offenen Anfragen' }, zeitenLeer: () => 'Noch keine Öffnungszeiten eingetragen', } /** 'JJJJ-MM-TT' → 'TT.MM.' */ const tagKurz = (tag) => `${tag.slice(8, 10)}.${tag.slice(5, 7)}.` /** Terminplaner: bestaetigte und erledigte Termine von heute (Berliner Tag) ohne Sperren; der naechste beginnt nach jetzt. */ async function termine({ dok, jetzt }) { if (!dok) return null const tag = heute(jetzt) const von = zuUtc(tag, '00:00') const bis = zuUtc(plusTage(tag, 1), '00:00') if (!von || !bis) return null const liste = await listDocuments(config.collections.termineBookings, [ Query.equal('featureId', dok.$id), Query.equal('status', ['bestaetigt', 'erledigt']), Query.greaterThanEqual('startsAt', von.toISOString()), Query.lessThan('startsAt', bis.toISOString()), Query.limit(SEITE), Query.select(['startsAt', 'quelle']), ]) if (liste.length >= SEITE) return null const starts = liste.filter((b) => b.quelle !== 'sperre').map((b) => zeitMs(b.startsAt)).filter(Number.isFinite) const spaeter = starts.filter((ms) => ms > jetzt.getTime()) const naechster = spaeter.length ? uhrzeit(new Date(Math.min(...spaeter))) : '' return KURZINFO_TEXTE.termine({ anzahl: starts.length, naechster }) } /** Webshop: bezahlte Bestellungen, die noch niemand bearbeitet hat (nur die Anzahl, total der Antwort). */ async function shop({ dok }) { if (!dok) return null const anzahl = await countDocuments(config.collections.shopOrders, [Query.equal('shopId', dok.$id), Query.equal('status', 'bezahlt')]) return Number.isInteger(anzahl) ? KURZINFO_TEXTE.shop({ anzahl }) : null } /** News-Pinnwand: haengende Beitraege dieser Website (Zeiten und Abnahme wie die Website sie auswertet, isHanging). */ async function news({ projekt, jetzt }) { const liste = await listDocuments(config.collections.newsPosts, [ Query.equal('projectId', projekt.projectId), Query.limit(SEITE_NEWS), Query.select(['visibleFrom', 'visibleUntil', 'takenDownAt']), ]) if (liste.length >= SEITE_NEWS) return null return KURZINFO_TEXTE.news({ anzahl: liste.filter((p) => isHanging(p, jetzt)).length }) } /** Buchungskalender: offene (neue) Anfragen; sonst die naechste Anreise einer gebuchten oder bezahlten Anfrage. */ async function unterkunft({ dok, jetzt }) { if (!dok) return null const tag = heute(jetzt) const [offen, kommende] = await Promise.all([ countDocuments(config.collections.ukAnfragen, [Query.equal('featureId', dok.$id), Query.equal('status', 'neu')]), listDocuments(config.collections.ukAnfragen, [ Query.equal('featureId', dok.$id), Query.equal('status', ['gebucht', 'bezahlt']), Query.greaterThanEqual('bis', tag), Query.limit(SEITE), Query.select(['von', 'bis']), ]), ]) if (!Number.isInteger(offen) || kommende.length >= SEITE) return null const anreisen = kommende.map((a) => a.von).filter((v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) && v >= tag).sort() let anreise = '' if (anreisen.length) { const erste = anreisen[0] anreise = erste === tag ? 'Anreise heute' : erste === plusTage(tag, 1) ? 'Anreise morgen' : `Nächste Anreise ${tagKurz(erste)}` } return KURZINFO_TEXTE.unterkunft({ offen, anreise }) } /** Oeffnungszeiten live: der Status, den die Projektliste ohnehin berechnet hat (zeitenStatus), ohne eigene Abfrage. */ function zeiten({ projekt }) { const text = projekt.zeitenStatus?.text if (istText(text)) return text return projekt.zeitenLeer === true ? KURZINFO_TEXTE.zeitenLeer() : null } const LESER = { termine, shop, news, unterkunft, zeiten } /** Nur ein nicht leerer, kurzer Text zaehlt, alles andere ist null. */ const gueltig = (wert) => (istText(wert) && wert.length <= MAX_ZEICHEN ? wert : null) function logge(key, err) { console.error(`[funktionen] Kurzinfo (${key}) nicht lesbar: ${err ? `${err?.name ?? 'Fehler'} ${err?.status ?? ''}` : 'Zeitüberschreitung'}`.trim()) } /** Fuehrt einen Leser mit Zeitgrenze aus: Text oder null, wirft nie. Eine zu spaete Antwort wird verworfen. */ function begrenzt(key, leser) { return new Promise((resolve) => { const uhr = setTimeout(() => { logge(key, null); resolve(null) }, kurzinfoDeps.limitMs) Promise.resolve() .then(leser) .then( (wert) => { clearTimeout(uhr); resolve(gueltig(wert)) }, (err) => { clearTimeout(uhr); logge(key, err); resolve(null) }, ) }) } /** * Setzt `kurzinfo` an jede Funktion jedes Projekts (projects[].features[key], die Form von listCustomerFeatures): einen Text fuer aktive * Funktionen, sonst null. Wirft nie wegen einer einzelnen Funktion; alle Abfragen laufen gleichzeitig. * @param {{ projects: Array<{ projectId: string, features: object, zeitenStatus?: object|null, zeitenLeer?: boolean }>, docs: object[], jetzt: Date }} p * docs: portalFeatures-Dokumente des Kunden; jetzt: der eine Zeitpunkt der Antwort */ export async function ergaenzeKurzinfos({ projects, docs, jetzt }) { const aufgaben = [] for (const projekt of projects) { for (const [key, funktion] of Object.entries(projekt.features ?? {})) { funktion.kurzinfo = null const leser = LESER[key] if (!leser || funktion.state !== 'active') continue const dok = docs.find((d) => d.projectId === projekt.projectId && d.featureKey === key) aufgaben.push(begrenzt(key, () => leser({ projekt, dok, jetzt })).then((text) => { funktion.kurzinfo = text })) } } await Promise.all(aufgaben) }