Kundenansicht neu (Meine, Live-Detail): GET /api/funktionen liefert je Projekt und Funktion die Kurzinfo

In der neuen Ansicht trägt jede Funktion unter projects[].features.<key> zusätzlich kurzinfo (Text oder null).
Nur aktive Funktionen bekommen einen Text: Terminplaner "3 Termine heute · nächster 10:30", Webshop "2 neue
Bestellungen", News-Pinnwand "4 Beiträge sichtbar", Buchungskalender "1 offene Anfrage" bzw. "Nächste Anreise
24.10.", Öffnungszeiten live der Status der Website (ohne eigene Abfrage). Berliner Zeit, je Funktion höchstens
zwei schmale Abfragen (limit, select, Index-Felder), alles gleichzeitig; jede Funktion einzeln begrenzt (1,5 s)
und fehlertolerant: Fehler oder Zeitüberschreitung ergibt null. Ohne neue Ansicht bleibt die Antwort wie sie
war und es wird nichts gelesen. Die Analyse hat keine Kurzinfo (Zahlen kommen aus dem Auswertungsdienst).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-17 15:09:25 +00:00
1 parent 5cd49d6a4a
commit 53a191219e
3 files changed
+521 -1

No files matched your search

+4 -1
View File
@@ -31,6 +31,7 @@ import { anfrageStatusJeProjekt, katalogFuerKunde } from '../services/aboKatalog
import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js'
import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.js'
import { neueAnsicht } from '../services/kundenansicht/schalter.js'
import { ergaenzeKurzinfos } from '../services/kundenansicht/kurzinfo.js'
import { abowegAnalyse, aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js'
import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
@@ -253,7 +254,8 @@ router.get('/', requireSession, async (req, res) => {
// dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten"
zeiten: await zeitenAngebot(req.session.email, customerId, abos),
}
// Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt.
// Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt,
// dazu je Projekt und Funktion die Kurzinfo (Live-Detail der Zeile in "Meine", Text oder null; jede Abfrage begrenzt und fehlertolerant).
// Ohne neue Ansicht bleibt die Antwort wie sie war. Ein Lesefehler beim Abo-Stand ist ein Fehler der Liste (nie ein erfundenes "kein Abo").
if (neu) {
const [kunde, abo, katalog, anfrageStatus] = await Promise.all([
@@ -261,6 +263,7 @@ router.get('/', requireSession, async (req, res) => {
aboProdukt(customerId, { now: jetzt }),
katalogFuerKunde({ email: req.session.email }),
anfrageStatusJeProjekt({ docs, projects }),
ergaenzeKurzinfos({ projects, docs, jetzt }).catch((err) => console.error(`[funktionen] Kurzinfo nicht lesbar: ${err?.name ?? 'Fehler'}`)),
])
for (const p of projects) {
p.features.shop.anfrageStatus = anfrageStatus.get(p.projectId)?.shop ?? ''
+158
View File
@@ -0,0 +1,158 @@
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 in der neuen Ansicht: 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)
}