Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten
Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.
Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.
Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e87140e5ba
245 files changed
+38799
-1050
No files matched your search
@@ -11,8 +11,10 @@ import http from 'node:http'
|
||||
*
|
||||
* Bulk-Create (POST .../documents mit { documents: [{ $id, ...felder }] }, 1 bis 100 Dokumente) ist alles oder
|
||||
* nichts: vorhandene oder doppelte $id => 409, ungueltige $id, unbekanntes Attribut oder ungueltiges Datum => 400,
|
||||
* dann ist nichts geschrieben. Abfragen: equal, notEqual (mit Liste: keiner von; ein Dokument ohne das Feld passt),
|
||||
* greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/orderDesc, limit, cursorAfter, select (nur die
|
||||
* dann ist nichts geschrieben. Abfragen: equal (mit Liste: einer von), notEqual (wie Appwrite 1.8.1, gemessen 2026-10-05:
|
||||
* genau EIN Wert, sonst HTTP 400 "Invalid query: NotEqual queries require exactly one value." general_query_invalid; mehrere
|
||||
* notEqual in einer Anfrage werden geschnitten; ein Dokument, dessen Feld null ist oder fehlt, passt NICHT zu notEqual, SQL-
|
||||
* Semantik), greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/orderDesc, limit, cursorAfter, select (nur die
|
||||
* genannten Felder plus alle $-Felder). Die Antwort traegt total = Treffer der Filter, unabhaengig von limit und
|
||||
* cursorAfter. listCalls(collection?) / resetListCalls() zeichnen die Listenabfragen auf. Zwei Werte, die beide
|
||||
* wie "YYYY-MM-DDT..." aussehen, vergleicht er als Zeitpunkte (Schreibweise Z, +00:00, +02:00 ist egal), alle
|
||||
@@ -133,6 +135,8 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
|
||||
case 'equal':
|
||||
return query.values.some((candidate) => same(value, candidate))
|
||||
case 'notEqual':
|
||||
// SQL: NULL <> 'x' ist nicht wahr, ein Dokument ohne Wert faellt heraus (Appwrite 1.8.1)
|
||||
if (value === undefined || value === null) return false
|
||||
return !query.values.some((candidate) => same(value, candidate))
|
||||
case 'greaterThan':
|
||||
return greater(value, first)
|
||||
@@ -158,6 +162,10 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
|
||||
const queries = rawQueries.map((q) => JSON.parse(q))
|
||||
const unsupported = queries.find((q) => !supportedQueries.includes(q.method))
|
||||
if (unsupported) return [400, { message: `Query ${unsupported.method} wird nicht nachgebaut`, code: 400 }]
|
||||
// Appwrite 1.8.1: notEqual nimmt genau einen Wert (der Nachbau lehnt die Liste wie die echte Datenbank ab)
|
||||
if (queries.some((q) => q.method === 'notEqual' && (!Array.isArray(q.values) || q.values.length !== 1))) {
|
||||
return [400, { message: 'Invalid query: NotEqual queries require exactly one value.', code: 400, type: 'general_query_invalid' }]
|
||||
}
|
||||
// cursorAfter: das Dokument mit dieser ID muss es geben (es muss aber nicht zu den Filtern passen)
|
||||
const cursorId = queries.find((q) => q.method === 'cursorAfter')?.values[0]
|
||||
if (cursorId !== undefined && !docs(collection).has(cursorId)) {
|
||||
|
||||
+22
-3
@@ -3,7 +3,8 @@
|
||||
// const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] })
|
||||
// try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() }
|
||||
//
|
||||
// installFakeDom stellt `document`, `window` (nur location mit href, pathname, search, hash und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
|
||||
// installFakeDom stellt `document`, `window` (location mit href, pathname, search, hash und reload() - jeder Aufruf steht in fx.reloads -, history.replaceState und
|
||||
// addEventListener/removeEventListener/dispatchEvent fuer Ereignisse des Fensters wie 'pageshow'), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
|
||||
// alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte.
|
||||
// Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`.
|
||||
//
|
||||
@@ -471,7 +472,7 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch']
|
||||
|
||||
/**
|
||||
* Fake-DOM und Fake-fetch auf globalThis stellen.
|
||||
* => { document, window, fetchLog, antworten, finde, alle, text, klick, setze, sende, ruhe, restore }
|
||||
* => { document, window, fetchLog, reloads, antworten, finde, alle, text, klick, setze, sende, ruhe, restore }
|
||||
* finde(baum, vergleich) / alle(baum, vergleich): erstes / alle Elemente im Baum (baum selbst eingeschlossen, Dokumentreihenfolge), fuer die
|
||||
* vergleich(el) wahr ist (Funktion) oder die zum Selektor passen (String).
|
||||
* text(baum): Text des Baums (textContent). klick(el), setze(el, wert), ruhe(): siehe Kopf der Datei.
|
||||
@@ -479,11 +480,28 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch']
|
||||
export function installFakeDom({ antworten = [] } = {}) {
|
||||
const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]))
|
||||
const document = neuesDokument()
|
||||
const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '' }
|
||||
const reloads = []
|
||||
const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '', reload: () => { reloads.push(location.href) } }
|
||||
const fensterEreignisse = new Map()
|
||||
const window = {
|
||||
document,
|
||||
location,
|
||||
history: { replaceState: (_zustand, _titel, url) => { location.hash = String(url ?? '').includes('#') ? `#${String(url).split('#')[1]}` : '' } },
|
||||
addEventListener(typ, fn) {
|
||||
if (!fensterEreignisse.has(typ)) fensterEreignisse.set(typ, [])
|
||||
fensterEreignisse.get(typ).push(fn)
|
||||
},
|
||||
removeEventListener(typ, fn) {
|
||||
const liste = fensterEreignisse.get(typ)
|
||||
if (liste?.includes(fn)) liste.splice(liste.indexOf(fn), 1)
|
||||
},
|
||||
/** Ereignis an die Handler des Fensters (z. B. { type: 'pageshow', persisted: true }); Rueckgaben werden nicht abgewartet. */
|
||||
dispatchEvent(ev) {
|
||||
for (const fn of [...(fensterEreignisse.get(ev.type) ?? [])]) fn(ev)
|
||||
return true
|
||||
},
|
||||
/** Zahl der Handler eines Typs (fuer Tests, die Aufraeumen pruefen). */
|
||||
anzahlHandler: (typ) => (fensterEreignisse.get(typ) ?? []).length,
|
||||
}
|
||||
const fetchLog = []
|
||||
const warteschlange = [...antworten]
|
||||
@@ -503,6 +521,7 @@ export function installFakeDom({ antworten = [] } = {}) {
|
||||
document,
|
||||
window,
|
||||
fetchLog,
|
||||
reloads,
|
||||
antworten: warteschlange,
|
||||
finde: (baum, vergleich) => knoten(baum).find(pruefung(vergleich)) ?? null,
|
||||
alle: (baum, vergleich) => knoten(baum).filter(pruefung(vergleich)),
|
||||
|
||||
+183
-3
@@ -32,6 +32,16 @@ import http from 'node:http'
|
||||
* Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern:
|
||||
* email, name, address, metadata; unbekannte ID -> 404). Wie Stripe fuehrt ein Update die Anschrift feldweise zusammen
|
||||
* (nur gesendete Felder aendern sich) und ein leerer Text loescht das Feld (gespeichert als null).
|
||||
*
|
||||
* Connect (Webshop D1): die Kopfzeile Stripe-Account steht als account in calls (nur bei Aufrufen mit Konto). POST /v1/accounts
|
||||
* (v1 mit controller; Konto startet unfertig: charges_enabled false, requirements.currently_due ['external_account'];
|
||||
* gleicher Idempotency-Key liefert dasselbe Konto), GET /v1/accounts/:id (unbekannt -> 404), POST /v1/account_links
|
||||
* (Konto muss existieren). st.setAccount(id, patch) legt ein Konto an oder aendert es (requirements wird feldweise gemischt).
|
||||
* Aufrufe mit Stripe-Account arbeiten auf der Ablage dieses Kontos (st.accountSessions(id), st.refunds(id)); ohne Kopfzeile
|
||||
* bleibt alles wie bisher in st.checkoutSessions. Dort: POST /v1/checkout/sessions, GET .../:id, POST .../:id/expire (nur open),
|
||||
* POST /v1/refunds (amount fehlt = Rest der Zahlung; Zahlungen legt st.setPaymentIntent(acct, id, { amount }) an; Summe der
|
||||
* Erstattungen darf die Zahlung nicht uebersteigen) und GET /v1/refunds/:id. Idempotency-Key wiederholt die erste Antwort
|
||||
* (je Konto und Schluessel) fuer Sitzungen und Erstattungen.
|
||||
*/
|
||||
|
||||
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
|
||||
@@ -54,6 +64,16 @@ export async function startFakeStripe() {
|
||||
// Rechnungsposten ohne Rechnung (pending invoice items), je ID
|
||||
const invoiceItems = new Map()
|
||||
const checkoutSessions = new Map()
|
||||
// Connect: Konten, deren Sitzungen, Zahlungen und Erstattungen (je Konto-ID), Antworten je Idempotency-Key
|
||||
const accounts = new Map()
|
||||
const accountSessionMaps = new Map()
|
||||
const paymentIntentMaps = new Map()
|
||||
const refundMaps = new Map()
|
||||
const idempotentReplies = new Map()
|
||||
const bucket = (maps, accountId) => {
|
||||
if (!maps.has(accountId)) maps.set(accountId, new Map())
|
||||
return maps.get(accountId)
|
||||
}
|
||||
// Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID
|
||||
const pendingUpdates = new Map()
|
||||
const calls = []
|
||||
@@ -166,8 +186,117 @@ export async function startFakeStripe() {
|
||||
return merged
|
||||
}
|
||||
|
||||
const route = (method, path, query, body) => {
|
||||
const route = (method, path, query, body, account = null) => {
|
||||
let m
|
||||
// ---- Connect ----
|
||||
if (method === 'POST' && path === '/v1/accounts') {
|
||||
const data = unflatten(body)
|
||||
const id = `acct_test${(seq += 1)}`
|
||||
const obj = {
|
||||
id,
|
||||
object: 'account',
|
||||
type: 'standard',
|
||||
country: data.country || 'DE',
|
||||
email: data.email ?? null,
|
||||
business_profile: data.business_profile || {},
|
||||
controller: { type: 'application', is_controller: false, ...(data.controller || {}) },
|
||||
capabilities: Object.fromEntries(Object.keys(data.capabilities || {}).map((k) => [k, 'inactive'])),
|
||||
metadata: data.metadata || {},
|
||||
charges_enabled: false,
|
||||
payouts_enabled: false,
|
||||
details_submitted: false,
|
||||
requirements: { currently_due: ['external_account'], eventually_due: ['external_account'], past_due: [], errors: [], disabled_reason: null },
|
||||
}
|
||||
accounts.set(id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/accounts\/([^/]+)$/))) {
|
||||
return accounts.has(m[1]) ? [200, accounts.get(m[1])] : noSuch('account', m[1])
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/account_links') {
|
||||
const data = unflatten(body)
|
||||
if (!accounts.has(data.account)) return noSuch('account', data.account)
|
||||
if (!data.type) return invalid('type', 'Missing required param: type.')
|
||||
return [200, {
|
||||
object: 'account_link',
|
||||
created: nowUnix(),
|
||||
expires_at: nowUnix() + 300,
|
||||
url: `https://connect.stripe.test/setup/${data.account}/${(seq += 1)}`,
|
||||
}]
|
||||
}
|
||||
if (account) {
|
||||
const sessions = bucket(accountSessionMaps, account)
|
||||
if (method === 'POST' && path === '/v1/checkout/sessions') {
|
||||
const data = unflatten(body)
|
||||
const id = nextId('cs')
|
||||
const obj = {
|
||||
id,
|
||||
object: 'checkout.session',
|
||||
url: `https://checkout.stripe.test/c/pay/${id}`,
|
||||
mode: data.mode || 'payment',
|
||||
client_reference_id: data.client_reference_id ?? null,
|
||||
success_url: data.success_url ?? null,
|
||||
cancel_url: data.cancel_url ?? null,
|
||||
expires_at: data.expires_at ? Number(data.expires_at) : nowUnix() + 86400,
|
||||
metadata: data.metadata || {},
|
||||
status: 'open',
|
||||
payment_status: 'unpaid',
|
||||
payment_intent: null,
|
||||
amount_total: null,
|
||||
currency: 'eur',
|
||||
customer_details: null,
|
||||
}
|
||||
sessions.set(id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)$/))) {
|
||||
return sessions.has(m[1]) ? [200, sessions.get(m[1])] : noSuch('checkout.session', m[1])
|
||||
}
|
||||
if (method === 'POST' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)\/expire$/))) {
|
||||
const found = sessions.get(m[1])
|
||||
if (!found) return noSuch('checkout.session', m[1])
|
||||
if (found.status !== 'open') return invalid(undefined, 'Only Checkout Sessions with a status in open can be expired.')
|
||||
const expired = { ...found, status: 'expired' }
|
||||
sessions.set(found.id, expired)
|
||||
return [200, expired]
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/refunds') {
|
||||
const data = unflatten(body)
|
||||
const intents = bucket(paymentIntentMaps, account)
|
||||
const refunds = bucket(refundMaps, account)
|
||||
const intent = intents.get(data.payment_intent)
|
||||
if (!data.payment_intent) return invalid('payment_intent', 'Either payment_intent or charge must be provided.')
|
||||
if (!intent && !data.amount) return noSuch('payment_intent', data.payment_intent)
|
||||
const already = [...refunds.values()].filter((r) => r.payment_intent === data.payment_intent).reduce((sum, r) => sum + r.amount, 0)
|
||||
const rest = intent ? intent.amount - already : null
|
||||
const amount = data.amount ? Number(data.amount) : rest
|
||||
if (!Number.isInteger(amount) || amount <= 0) return invalid('amount', 'Invalid positive integer')
|
||||
if (rest !== null && amount > rest) return invalid('amount', `Refund amount (${amount}) is greater than unrefunded amount on payment (${rest}).`)
|
||||
const obj = {
|
||||
id: nextId('re'),
|
||||
object: 'refund',
|
||||
amount,
|
||||
currency: 'eur',
|
||||
payment_intent: data.payment_intent,
|
||||
reason: data.reason ?? null,
|
||||
metadata: data.metadata || {},
|
||||
status: 'succeeded',
|
||||
created: nowUnix(),
|
||||
}
|
||||
refunds.set(obj.id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/refunds') {
|
||||
const pi = query.get('payment_intent')
|
||||
const items = [...bucket(refundMaps, account).values()].filter((r) => !pi || r.payment_intent === pi)
|
||||
return [200, { object: 'list', data: items, has_more: false, url: '/v1/refunds' }]
|
||||
}
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/refunds\/([^/]+)$/))) {
|
||||
const found = bucket(refundMaps, account).get(m[1])
|
||||
return found ? [200, found] : noSuch('refund', m[1])
|
||||
}
|
||||
}
|
||||
// ---- Plattform ----
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
|
||||
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
|
||||
}
|
||||
@@ -626,7 +755,9 @@ export async function startFakeStripe() {
|
||||
const body = await readBody(req)
|
||||
// idempotencyKey (Kopfzeile Idempotency-Key) steht nur an Aufrufen, die einen mitschicken
|
||||
const idempotencyKey = req.headers['idempotency-key']
|
||||
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}) })
|
||||
// Connect: Kopfzeile Stripe-Account (Aufruf im Namen eines Haendlerkontos)
|
||||
const account = req.headers['stripe-account']
|
||||
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}), ...(account ? { account } : {}) })
|
||||
const delay = plannedDelay(req.method, url.pathname)
|
||||
if (delay) await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
const failure = plannedFailure(req.method, url.pathname)
|
||||
@@ -640,7 +771,10 @@ export async function startFakeStripe() {
|
||||
res.writeHead(200, { 'Content-Type': 'application/pdf' })
|
||||
return res.end(`%PDF-1.4\n% Stripe-Nachbau ${pdfMatch[1]}\n%%EOF\n`)
|
||||
}
|
||||
const [status, payload] = failure || route(req.method, url.pathname, url.searchParams, body)
|
||||
// Wiederholung mit gleichem Idempotency-Key (nur Connect-Aufrufe und Kontoanlage): erste Antwort, kein neuer Zustand
|
||||
const replyKey = !failure && req.method === 'POST' && idempotencyKey && (account || url.pathname === '/v1/accounts') ? `${account || ''}|${idempotencyKey}` : null
|
||||
const [status, payload] = failure || (replyKey && idempotentReplies.get(replyKey)) || route(req.method, url.pathname, url.searchParams, body, account || null)
|
||||
if (replyKey && status === 200 && !idempotentReplies.has(replyKey)) idempotentReplies.set(replyKey, [status, payload])
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
|
||||
res.end(JSON.stringify(payload))
|
||||
})
|
||||
@@ -659,6 +793,52 @@ export async function startFakeStripe() {
|
||||
payouts,
|
||||
invoiceItems,
|
||||
checkoutSessions,
|
||||
accounts,
|
||||
/** Sitzungen eines Haendlerkontos (Map id -> Sitzung); Tests setzen status/payment_status/payment_intent direkt darin. */
|
||||
accountSessions: (accountId) => bucket(accountSessionMaps, accountId),
|
||||
/** Erstattungen eines Haendlerkontos (Map id -> Erstattung). */
|
||||
refunds: (accountId) => bucket(refundMaps, accountId),
|
||||
/** Konto anlegen oder aendern (z. B. { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }). */
|
||||
setAccount(id, patch = {}) {
|
||||
const base = accounts.get(id) || {
|
||||
id, object: 'account', type: 'standard', country: 'DE', email: null, business_profile: {}, metadata: {},
|
||||
controller: { type: 'application', is_controller: false }, capabilities: {},
|
||||
charges_enabled: false, payouts_enabled: false, details_submitted: false,
|
||||
requirements: { currently_due: ['external_account'], eventually_due: [], past_due: [], errors: [], disabled_reason: null },
|
||||
}
|
||||
const next = { ...base, ...patch, requirements: { ...base.requirements, ...(patch.requirements || {}) } }
|
||||
accounts.set(id, next)
|
||||
return next
|
||||
},
|
||||
/**
|
||||
* Sitzung eines Haendlerkontos als abgeschlossen markieren (status complete) und die Felder setzen, die Stripe nach der Kasse
|
||||
* liefert: completeSession('acct_x', 'cs_x', { payment_status: 'paid', amount_total, customer_details, shipping, payment_intent,
|
||||
* payment_method_types }). shipping steht wie bei Stripe unter collected_information.shipping_details und shipping_details.
|
||||
* Gibt die neue Sitzung zurueck (so schickt der Webhook sie).
|
||||
*/
|
||||
completeSession(accountId, id, { payment_status = 'paid', amount_total = null, customer_details = null, shipping = null, payment_intent = null, payment_method_types } = {}) {
|
||||
const sessions = bucket(accountSessionMaps, accountId)
|
||||
const found = sessions.get(id)
|
||||
if (!found) throw new Error(`Stripe-Nachbau: keine Sitzung ${id} bei ${accountId}`)
|
||||
const next = {
|
||||
...found,
|
||||
status: 'complete',
|
||||
payment_status,
|
||||
amount_total,
|
||||
customer_details,
|
||||
payment_intent,
|
||||
...(payment_method_types ? { payment_method_types } : {}),
|
||||
...(shipping ? { collected_information: { shipping_details: shipping }, shipping_details: shipping } : {}),
|
||||
}
|
||||
sessions.set(id, next)
|
||||
return next
|
||||
},
|
||||
/** Zahlung eines Haendlerkontos (Betrag in Cent) fuer Erstattungen: setPaymentIntent('acct_x', 'pi_x', { amount: 2500 }). */
|
||||
setPaymentIntent(accountId, id, { amount, currency = 'eur' }) {
|
||||
const obj = { id, object: 'payment_intent', amount, currency }
|
||||
bucket(paymentIntentMaps, accountId).set(id, obj)
|
||||
return obj
|
||||
},
|
||||
/**
|
||||
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit
|
||||
* pending_update bleibt das Abo wie bei Stripe unveraendert (und traegt pending_update), bis
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js'
|
||||
import { SHOP_COLLECTIONS } from '../../server/services/shop/schema.js'
|
||||
import { ZEITEN_COLLECTIONS, ZEITEN_FEATURE_ATTRIBUTES } from '../../server/services/zeiten/schema.js'
|
||||
import { FEATURE_CONSENT_ATTRIBUTES, PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs'
|
||||
|
||||
@@ -11,6 +12,7 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
|
||||
* scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema:
|
||||
* Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in
|
||||
* Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs),
|
||||
* die Shop-Collections aus server/services/shop/schema.js (scripts/shop-setup.mjs),
|
||||
* die Zeiten-Collections aus server/services/zeiten/schema.js (scripts/zeiten-setup.mjs);
|
||||
* portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES) und die
|
||||
* Zeiten-Felder aus server/services/zeiten/schema.js (ZEITEN_FEATURE_ATTRIBUTES, angelegt von scripts/zeiten-setup.mjs);
|
||||
@@ -18,6 +20,7 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
|
||||
*/
|
||||
export function schemaKeys(collection) {
|
||||
if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||
if (Object.hasOwn(SHOP_COLLECTIONS, collection)) return SHOP_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||
if (Object.hasOwn(ZEITEN_COLLECTIONS, collection)) return ZEITEN_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||
if (collection === 'featureConsents') return FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key)
|
||||
for (const script of SETUP_SCRIPTS) {
|
||||
|
||||
Reference in new issue
Block a user