Merge branch 'feature/terminplaner-tp2' into feature/webshop
This commit is contained in:
commit
a4a18a1c89
8 files changed
+367
-83
No files matched your search
+27
-5
@@ -18,7 +18,7 @@ import {
|
||||
reconcilePendingSwitch,
|
||||
switchToWartung,
|
||||
} from '../services/planSwitch.js'
|
||||
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js'
|
||||
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js'
|
||||
import { featureConfig } from '../services/featureRegistry.js'
|
||||
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
|
||||
import {
|
||||
@@ -68,6 +68,8 @@ function minTermEndOf(sub) {
|
||||
// Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6)
|
||||
const productKeyOf = (sub) => sub?.productKey || 'wartung'
|
||||
const isWartung = (sub) => Boolean(sub) && productKeyOf(sub) === 'wartung'
|
||||
// Funktionen, die zum Wartungs-Abo gehoeren und mit ihm enden (portalFeatures.featureKey): Website-Analyse und Terminplaner
|
||||
const ABO_FUNKTIONEN = ['analyse', 'termine']
|
||||
// {{produkt}} in den Mails: Katalogname ohne "WEBklar " (F33), ohne gefundenes Abo neutral "Abo"
|
||||
const productLabelOf = (sub) => (sub ? productLabel(productKeyOf(sub)) : 'Abo')
|
||||
// {{vertragsende_paragraph}}: Paragraph zum Vertragsende in der Fassung des Kunden (v3 § 9, v4 § 11); ohne
|
||||
@@ -408,6 +410,24 @@ router.post('/switch', requireSession, async (req, res) => {
|
||||
// Stripe-Webhook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hat der Kunde dieses Abos die Website-Analyse oder den Terminplaner (Funktionsdokument in portalFeatures mit featureKey analyse
|
||||
* oder termine, Zustand ausser off; ein Dokument ohne Zustand gilt wie off, wie in der Ansicht)? Nur dann gilt der Hinweis in K02 und
|
||||
* K03 (Block funktionen), dass beide mit dem Wartungs-Abo enden (Entscheidung 2026-10-05). Der Kunde ist der des Abos (sub.customerId,
|
||||
* wie in customerNameOf); gelesen wird wie sonst auch ueber listCustomerFeatureDocs. Wirft nie: ein Lesefehler zaehlt als ja (der Kunde
|
||||
* wird eher informiert als nicht), eine Logzeile, die Mail geht in jedem Fall raus.
|
||||
*/
|
||||
async function hatAboFunktionen(sub) {
|
||||
try {
|
||||
if (!sub?.customerId) throw new Error('Abo ohne Kunden-ID')
|
||||
const docs = await listCustomerFeatureDocs(sub.customerId)
|
||||
return docs.some((doc) => ABO_FUNKTIONEN.includes(doc.featureKey) && (doc.state || 'off') !== 'off')
|
||||
} catch (err) {
|
||||
console.error(`[abo] Funktionen des Kunden nicht lesbar, der Hinweis auf das Ende von Analyse und Terminplaner bleibt in der Kündigungsbestätigung (Abo ${sub?.$id || '-'}): ${err.message}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */
|
||||
async function customerNameOf(sub, fallback = '') {
|
||||
const customer = sub?.customerId
|
||||
@@ -475,8 +495,9 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) {
|
||||
vertragsende: fmtDate(endsAt),
|
||||
vertragsende_paragraph: endParagraphOf(sub),
|
||||
},
|
||||
// Hinweis auf nicht genutzte Arbeitszeit und auf das Ende von Website-Analyse und Terminplaner nur beim Wartungs-Abo
|
||||
blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) },
|
||||
// Hinweis auf nicht genutzte Arbeitszeit nur beim Wartungs-Abo; auf das Ende von Website-Analyse und Terminplaner nur beim
|
||||
// Wartungs-Abo eines Kunden, der eine von beiden hat (hatAboFunktionen)
|
||||
blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) && (await hatAboFunktionen(sub)) },
|
||||
})
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
cancelRequestedAt: receivedAt.toISOString(),
|
||||
@@ -948,8 +969,9 @@ router.post('/public/cancel', async (req, res) => {
|
||||
abo: !storage,
|
||||
// Hinweis auf nicht genutzte Arbeitszeit nur im Wartungs-Abo (Block innerhalb von abo)
|
||||
wartung: !storage && isWartung(sub),
|
||||
// Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung)
|
||||
funktionen: !storage && isWartung(sub),
|
||||
// Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung),
|
||||
// nur fuer einen Kunden, der eine von beiden hat (hatAboFunktionen)
|
||||
funktionen: !storage && isWartung(sub) && (await hatAboFunktionen(sub)),
|
||||
automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
|
||||
sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
|
||||
ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich',
|
||||
|
||||
@@ -8,6 +8,18 @@ function buildQueries(queries = []) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* URL einer Anfrage: Pfad plus eine `queries[]` je Abfrage (JSON, URL-kodiert). Die einzige Stelle, die sie baut (adminFetch und
|
||||
* dokumenteUrl), damit die Laenge, die der nginx vor Appwrite zaehlt (siehe AUSSCHLUSS_MAX in termine/outbox.js), pruefbar bleibt.
|
||||
*/
|
||||
function buildUrl(path, queries = []) {
|
||||
const url = new URL(`${config.appwrite.endpoint}${path}`)
|
||||
for (const q of buildQueries(queries)) {
|
||||
url.searchParams.append('queries[]', q)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function adminHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -33,10 +45,7 @@ async function adminFetch(path, { method = 'GET', body, queries = [], raw = fals
|
||||
throw error
|
||||
}
|
||||
|
||||
const url = new URL(`${config.appwrite.endpoint}${path}`)
|
||||
for (const q of buildQueries(queries)) {
|
||||
url.searchParams.append('queries[]', q)
|
||||
}
|
||||
const url = buildUrl(path, queries)
|
||||
|
||||
const useRawBody = raw || path.startsWith('/users')
|
||||
const requestBody = formatRequestBody(body, method, { raw: useRawBody })
|
||||
@@ -93,12 +102,30 @@ export const Query = {
|
||||
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
|
||||
/** Nur diese Felder zurueckgeben (die $-Felder wie $id liefert Appwrite immer mit) */
|
||||
select: (attributes) => ({ method: 'select', values: attributes }),
|
||||
/** Ein Wert oder eine Liste: kein Dokument mit einem der Werte ("keiner von") */
|
||||
notEqual: (attribute, value) => ({
|
||||
method: 'notEqual',
|
||||
attribute,
|
||||
values: Array.isArray(value) ? value : [value],
|
||||
}),
|
||||
/**
|
||||
* Kein Dokument mit diesem Wert. Genau EIN Wert: Appwrite 1.8.1 lehnt eine Liste ab (HTTP 400 general_query_invalid
|
||||
* "NotEqual queries require exactly one value.", gemessen 2026-10-05, M1). Fuer mehrere Werte keinerVon(attribute, werte).
|
||||
* Zwei Eigenheiten der echten Datenbank, die auch der Nachbau der Tests hat: mehrere notEqual in einer Anfrage werden
|
||||
* geschnitten (M2, bis 40 getestet), und ein Dokument, dessen Feld null ist oder fehlt, passt NICHT zu notEqual, SQL-Semantik
|
||||
* (M3): nur verwenden, wenn das Feld an jedem Dokument gesetzt ist.
|
||||
*/
|
||||
notEqual: (attribute, value) => {
|
||||
if (Array.isArray(value)) throw new TypeError(`Query.notEqual('${attribute}') nimmt genau einen Wert (Appwrite lehnt eine Liste ab); fuer mehrere keinerVon('${attribute}', werte) benutzen`)
|
||||
return { method: 'notEqual', attribute, values: [value] }
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* "Keiner von diesen Werten": je Wert eine notEqual-Abfrage mit genau einem Wert; Appwrite schneidet sie (M2). Ohne Werte
|
||||
* keine Abfrage (leeres Array). Jede Abfrage steht als eigenes queries[] in der URL, die Zahl der Werte also begrenzen
|
||||
* (M4: der nginx vor Appwrite antwortet mit 414, in der Abfrage der Mail-Warteschlange ab 46 Werten); fuer M1 bis M3
|
||||
* siehe Query.notEqual.
|
||||
* @param {string} attribute
|
||||
* @param {Iterable<string|number|boolean>} werte
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function keinerVon(attribute, werte) {
|
||||
return [...(werte ?? [])].map((wert) => Query.notEqual(attribute, wert))
|
||||
}
|
||||
|
||||
export const ID = {
|
||||
@@ -109,6 +136,11 @@ function collectionPath(collectionId) {
|
||||
return `/databases/${config.appwrite.databaseId}/collections/${collectionId}/documents`
|
||||
}
|
||||
|
||||
/** Die URL der Listenabfrage einer Collection (ohne Netz), so wie listDocuments sie sendet: fuer Tests der URL-Laenge. */
|
||||
export function dokumenteUrl(collectionId, queries = []) {
|
||||
return buildUrl(collectionPath(collectionId), queries).toString()
|
||||
}
|
||||
|
||||
export async function getUserById(userId) {
|
||||
return adminFetch(`/users/${userId}`)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { config } from '../../config.js'
|
||||
import { Query, countDocuments, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
|
||||
import { Query, countDocuments, createDocument, deleteDocument, getDocument, keinerVon, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { sendGuestMail } from '../mailer.js'
|
||||
import { renderMail } from '../mailTemplates.js'
|
||||
@@ -376,16 +376,24 @@ async function speichereFehlschlag(doc, err, nowMs) {
|
||||
const KLASSEN = ['vorrang', 'normal', 'nachrangig']
|
||||
/** Hoechstens so viele Abfragen je Klasse und Lauf: die erste und bis zu vier weitere Runden mit neuer Ausschlussliste. */
|
||||
const RUNDEN_MAX = 5
|
||||
/** Hoechstens so viele Funktionen in der Ausschlussliste einer Abfrage (Appwrite erlaubt hoechstens 100 Werte je Abfrage). */
|
||||
const AUSSCHLUSS_MAX = 90
|
||||
/**
|
||||
* Hoechstens so viele Funktionen in der Ausschlussliste einer Abfrage. Jede ist ein eigenes notEqual in der URL (Appwrite 1.8.1
|
||||
* nimmt je notEqual nur einen Wert, M1). Gemessen am Produktionsserver (M4, 2026-10-05): in dieser Abfrageform antwortet der nginx
|
||||
* ab 46 Funktions-IDs mit 36 Zeichen mit 414 (URL zu lang); 30 lassen Luft fuer laengere IDs. Eine blockierte Funktion ausserhalb
|
||||
* der Liste wird gelesen und uebersprungen (verarbeiteKlasse), das kostet nur Platz in der Liste.
|
||||
*/
|
||||
export const AUSSCHLUSS_MAX = 30
|
||||
/** Die Felder der Liste: genug fuer pruefePlatz und sendeEintrag, nie ein Mailtext. */
|
||||
const LISTENFELDER = ['$id', 'featureId', 'art', 'naechsterVersuch']
|
||||
|
||||
/** Filter auf die Art der Mail: die Arten der Klasse (equal) bzw. alle anderen (notEqual, die Klasse normal). */
|
||||
/**
|
||||
* Filter auf die Art der Mail, immer eine Liste von Abfragen: die Arten der Klasse (equal mit Liste, das erlaubt Appwrite) bzw.
|
||||
* alle anderen (die Klasse normal: je Art ein notEqual, Appwrite schneidet sie; art ist an jedem Eintrag gesetzt, einreihen prueft es).
|
||||
*/
|
||||
function klassenFilter(klasse) {
|
||||
if (klasse === 'vorrang') return Query.equal('art', [...MAIL_VORRANG])
|
||||
if (klasse === 'nachrangig') return Query.equal('art', [...MAIL_NACHRANGIG])
|
||||
return Query.notEqual('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG])
|
||||
if (klasse === 'vorrang') return [Query.equal('art', [...MAIL_VORRANG])]
|
||||
if (klasse === 'nachrangig') return [Query.equal('art', [...MAIL_NACHRANGIG])]
|
||||
return keinerVon('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG])
|
||||
}
|
||||
|
||||
/** Eine Runde der Klasse: faellige wartende Eintraege, fuer die Funktionen ohne Kontingent nicht mitgelesen werden. */
|
||||
@@ -393,8 +401,8 @@ function listenAbfrage(klasse, nowMs, blockiert) {
|
||||
return [
|
||||
Query.equal('status', 'wartet'),
|
||||
Query.lessThanEqual('naechsterVersuch', iso(nowMs)),
|
||||
klassenFilter(klasse),
|
||||
...(blockiert.size > 0 ? [Query.notEqual('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX))] : []),
|
||||
...klassenFilter(klasse),
|
||||
...keinerVon('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX)),
|
||||
Query.orderAsc('naechsterVersuch'),
|
||||
Query.limit(config.termine.mailListe),
|
||||
Query.select(LISTENFELDER),
|
||||
|
||||
Reference in new issue
Block a user