From e43cdb667b02b61e612861e567866ed63aabcfc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:22:53 +0000 Subject: [PATCH 001/105] Projekt-Ablauf im Ticketsystem: Anlegen, Archivieren, Import repariert - Neues Projekt ohne Subdomain: subdomain null statt '' (Unique-Index subdomain_unique, jedes weitere scheiterte mit "Document with the requested ID already exists"); ebenso Typ "Internes Projekt" - Anlegen unter derselben Repo-Sperre wie der Webhook repository/created; upsertWebsiteProjectByRepo macht aus einem 409 ein Update - Gitea-Beschreibung = Anzeigename; Art (kind) beim Anlegen aus dem Ticket - Reaktivieren setzt status zurueck (vorher blieb 'archived' stehen) - Import: archivierte Repos als archiviert, Archiv-Stand aus Gitea wird gespiegelt, ein fehlerhaftes Repo bricht den Lauf nicht mehr ab - Abgleich ueberschreibt Projektnamen nicht mehr - CORS erlaubt PUT (Preview-Variablen speichern) - Vorlage zieht beim Zuweisen nie in die Org um - Erinnerungs-Lauf ueberspringt archivierte Projekte - kaputte Umlaute (U+FFFD) in drei Fehlermeldungen Co-Authored-By: Claude Opus 5.5 --- server/middleware/appwriteStaff.js | 2 +- server/middleware/cors.js | 2 +- server/routes/admin/websiteProjects.js | 4 +- server/services/appwriteAdmin.js | 39 +++-- server/services/customerAdmin.js | 2 +- server/services/giteaAdmin.js | 205 ++++++++++++++++------ server/services/giteaClient.js | 2 +- server/services/repoAssignJob.js | 2 +- server/services/repoMove.js | 2 + test/giteaAdmin.test.js | 6 +- test/helpers/fakeAppwrite.js | 50 +++++- test/helpers/fakeGitea.js | 32 ++++ test/projektAblauf.test.js | 233 +++++++++++++++++++++++++ 13 files changed, 498 insertions(+), 83 deletions(-) create mode 100644 test/projektAblauf.test.js diff --git a/server/middleware/appwriteStaff.js b/server/middleware/appwriteStaff.js index ba7df49..d9ca95e 100644 --- a/server/middleware/appwriteStaff.js +++ b/server/middleware/appwriteStaff.js @@ -29,7 +29,7 @@ export async function requireAppwriteStaff(req, res, next) { } if (!response.ok) { - return res.status(401).json({ error: data?.message || 'Ung�ltiges JWT' }) + return res.status(401).json({ error: data?.message || 'Ungültiges JWT' }) } if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) { diff --git a/server/middleware/cors.js b/server/middleware/cors.js index b5562c2..deab6d5 100644 --- a/server/middleware/cors.js +++ b/server/middleware/cors.js @@ -7,7 +7,7 @@ export function corsMiddleware(allowedOrigins) { if (origin && allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS) - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') if (req.method === 'OPTIONS') { return res.sendStatus(204) } diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index a22bb46..f55aa04 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -13,7 +13,7 @@ const router = Router() router.use(requireAppwriteStaff) router.post('/create-from-template', async (req, res) => { - const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {} + const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {} if (!repoName && !displayName) { return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' }) @@ -27,6 +27,8 @@ router.post('/create-from-template', async (req, res) => { customerId, ticketId, owner, + kind, + staffName: req.appwriteUser?.name || req.appwriteUser?.email || '', }) return res.status(201).json({ ok: true, ...result }) } catch (err) { diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index e7c14fb..9b585f3 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -300,19 +300,32 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) { } const now = new Date().toISOString() - return adminFetch(collectionPath(config.collections.websiteProjects), { - method: 'POST', - body: { - ...payload, - customerId: payload.customerId ?? '', - ticketId: payload.ticketId ?? '', - status: payload.status ?? 'pending', - provisioningStatus: payload.provisioningStatus ?? 'pending', - projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, - createdAt: now, - documentId: ID.unique(), - }, - }) + try { + return await adminFetch(collectionPath(config.collections.websiteProjects), { + method: 'POST', + body: { + ...payload, + customerId: payload.customerId ?? '', + ticketId: payload.ticketId ?? '', + status: payload.status ?? 'pending', + provisioningStatus: payload.provisioningStatus ?? 'pending', + projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, + createdAt: now, + documentId: ID.unique(), + }, + }) + } catch (err) { + // Ein anderer Schreiber ohne gemeinsame Sperre hat das Dokument zwischen Suche und Anlage + // angelegt: der Unique-Index auf repoFullName meldet 409. Dann das vorhandene Dokument + // aktualisieren, statt den Fehler bis in die Oberflaeche zu reichen. + if (err.status !== 409) throw err + const raced = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + if (!raced[0]) throw err + return updateDocument(config.collections.websiteProjects, raced[0].$id, buildProjectPayload(raced[0], data)) + } } export async function verifyDatabaseAccess() { diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index e91a689..dd67ec8 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -198,7 +198,7 @@ export async function updateCustomerWithPortalAccess(customerId, payload) { portalPassword = normalizedPassword } } else if (normalizedPassword) { - const error = new Error('Portal-Login fehlt � bitte E-Mail setzen und Passwort erneut speichern') + const error = new Error('Portal-Login fehlt – bitte E-Mail setzen und Passwort erneut speichern') error.status = 400 throw error } diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 878333b..073ce05 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -18,6 +18,7 @@ import { giteaFetch } from './giteaClient.js' import { slugify } from './slug.js' import { withLocks } from './keyedLock.js' import { repoLockKey } from './repoMove.js' +import { isKind } from './projectKinds.js' // Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte // Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht @@ -49,12 +50,16 @@ export async function listAllAccessibleRepos() { /** * Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer - * jedes nicht archivierte ein minimales Dokument an (status "synced", ohne Hosting/Subdomain) - - * ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem + * jedes ein minimales Dokument an (status "synced", ohne Hosting/Subdomain; in Gitea archivierte + * als archiviertes Projekt) - ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Bei + * vorhandenen Dokumenten wird nur der Archiv-Stand aus Gitea gespiegelt (Repo dort direkt + * archiviert oder reaktiviert). Wird vom Projekt-Dialog bei jedem * Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert * parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die * innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere * gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung). + * Ein Fehler bei einem Repo bricht den Lauf nicht ab (sonst fehlten alle Repos danach). + * `createdRepos` enthaelt nur neue, nicht archivierte Repos (nur sie bekommen Zuordnungs-Mails). */ export async function importNewRepos() { return withLocks(['import'], async () => { @@ -64,39 +69,71 @@ export async function importNewRepos() { ]) // Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling). - const existingNames = new Set( - docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean) - ) - - let created = 0 - const createdRepos = [] - for (const repo of repos) { - const fullName = repo.full_name - if (!fullName || repo.archived) continue - if (existingNames.has(fullName.toLowerCase())) continue - - await withLocks([repoLockKey(fullName)], async () => { - const recheck = await listDocuments(config.collections.websiteProjects, [ - Query.equal('repoFullName', fullName), - Query.limit(1), - ]) - if (recheck[0]) return - - const doc = await upsertWebsiteProjectByRepo(fullName, { - projectName: repo.description || repo.name || fullName.split('/').pop(), - giteaRepoUrl: repo.html_url, - giteaRepoName: repo.name, - repoFullName: fullName, - archived: false, - status: 'synced', - giteaSyncedAt: new Date().toISOString(), - }) - created += 1 - createdRepos.push({ projectId: doc.$id, repo }) - }) + const docsByName = new Map() + for (const d of docs) { + const key = String(d.repoFullName || '').toLowerCase() + if (key && !docsByName.has(key)) docsByName.set(key, d) } - return { created, createdRepos } + let created = 0 + let archivedImported = 0 + let archiveSynced = 0 + const createdRepos = [] + const failed = [] + for (const repo of repos) { + const fullName = repo.full_name + if (!fullName) continue + const repoArchived = Boolean(repo.archived) + + try { + const existing = docsByName.get(fullName.toLowerCase()) + if (existing) { + if (Boolean(existing.archived) === repoArchived) continue + await withLocks([repoLockKey(fullName)], async () => { + const fresh = await getDocument(config.collections.websiteProjects, existing.$id) + if (!fresh || Boolean(fresh.archived) === repoArchived) return + await updateDocument(config.collections.websiteProjects, fresh.$id, { + archived: repoArchived, + ...(repoArchived + ? { status: 'archived' } + : fresh.status === 'archived' ? { status: 'synced' } : {}), + updatedAt: new Date().toISOString(), + }) + archiveSynced += 1 + }) + continue + } + + await withLocks([repoLockKey(fullName)], async () => { + const recheck = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', fullName), + Query.limit(1), + ]) + if (recheck[0]) return + + const doc = await upsertWebsiteProjectByRepo(fullName, { + projectName: repo.description || repo.name || fullName.split('/').pop(), + giteaRepoUrl: repo.html_url, + giteaRepoName: repo.name, + repoFullName: fullName, + archived: repoArchived, + status: repoArchived ? 'archived' : 'synced', + giteaSyncedAt: new Date().toISOString(), + }) + if (repoArchived) { + archivedImported += 1 + return + } + created += 1 + createdRepos.push({ projectId: doc.$id, repo }) + }) + } catch (err) { + failed.push(fullName) + console.error(`[importNewRepos] ${fullName} übersprungen:`, err.message) + } + } + + return { created, createdRepos, archivedImported, archiveSynced, failed } }) } @@ -104,6 +141,7 @@ export async function syncReposToAppwrite() { const repos = await listAllAccessibleRepos() let created = 0 let updated = 0 + const failed = [] for (const repo of repos) { const repoFullName = repo.full_name @@ -115,25 +153,38 @@ export async function syncReposToAppwrite() { ]) const payload = { - projectName: repo.description || repo.name || repoFullName.split('/').pop(), giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || repoFullName.split('/').pop(), repoFullName, // In Gitea archivierte Repos werden im Ticketsystem ausgeblendet. archived: Boolean(repo.archived), } + const repoTitle = repo.description || repo.name || repoFullName.split('/').pop() if (!existing[0]) { - payload.status = 'synced' + payload.projectName = repoTitle + payload.status = repo.archived ? 'archived' : 'synced' created += 1 } else { + // Der Name im Ticketsystem bleibt: frueher ueberschrieb der Abgleich ihn mit der + // Gitea-Beschreibung (aus "Döner Nation" wurde so "D-ner-Nation"). + if (!existing[0].projectName) payload.projectName = repoTitle + // Archiv-Stand aus Gitea uebernehmen, ohne dass status auf 'archived' haengen bleibt + if (repo.archived && existing[0].status !== 'archived') payload.status = 'archived' + if (!repo.archived && existing[0].status === 'archived') payload.status = 'synced' updated += 1 } - await upsertWebsiteProjectByRepo(repoFullName, payload) + try { + await upsertWebsiteProjectByRepo(repoFullName, payload) + } catch (err) { + // Ein fehlerhaftes Repo darf den Abgleich aller folgenden nicht abbrechen + failed.push(repoFullName) + console.error(`[syncReposToAppwrite] ${repoFullName} übersprungen:`, err.message) + } } - return { synced: repos.length, created, updated } + return { synced: repos.length, created, updated, failed } } export async function ensureSubdomainAvailable(subdomain) { @@ -148,7 +199,7 @@ export async function ensureSubdomainAvailable(subdomain) { } } -export async function generateRepoFromTemplate({ owner, repoName }) { +export async function generateRepoFromTemplate({ owner, repoName, description }) { const templateOwner = config.gitea.templateOwner const templateRepo = config.gitea.templateRepo const targetOwner = owner || config.gitea.defaultOwner @@ -163,7 +214,8 @@ export async function generateRepoFromTemplate({ owner, repoName }) { owner: targetOwner, name: repoName, private: false, - description: `Website-Projekt ${repoName}`, + // Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort) + description: description || `Website-Projekt ${repoName}`, git_content: true, }), }) @@ -278,6 +330,8 @@ export async function createProjectFromTemplate({ customerId = '', ticketId = '', owner, + kind = '', + staffName = '', }) { const targetOwner = owner || config.gitea.defaultOwner const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain) @@ -289,6 +343,11 @@ export async function createProjectFromTemplate({ if (!safeRepoName) { throw new Error('repoName oder displayName ist erforderlich') } + if (kind && !isKind(kind)) { + const error = new Error('Unbekannte Projektart.') + error.status = 400 + throw error + } if (hasWebsite) { await ensureSubdomainAvailable(safeSubdomain) @@ -297,6 +356,7 @@ export async function createProjectFromTemplate({ const repo = await generateRepoFromTemplate({ owner: targetOwner, repoName: safeRepoName, + description: displayName || safeRepoName, }) const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}` @@ -324,7 +384,11 @@ export async function createProjectFromTemplate({ } const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : '' - const project = await upsertWebsiteProjectByRepo(repoFullName, { + // Gitea meldet das neue Repo sofort per Webhook (repository/created, dann der Push der Config). + // Der Webhook legt das Dokument unter dieser Repo-Sperre an; ohne dieselbe Sperre legen beide + // gleichzeitig an und der Unique-Index auf repoFullName lehnt dieses hier ab ("Document with + // the requested ID already exists") - das Repo gab es dann schon, Kunde und Ticket fehlten. + const project = await withLocks([repoLockKey(repoFullName)], () => upsertWebsiteProjectByRepo(repoFullName, { customerId, ticketId, projectName: previewConfig.displayName, @@ -332,9 +396,23 @@ export async function createProjectFromTemplate({ giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || safeRepoName, repoFullName, - subdomain: safeSubdomain, + // Ohne Website null statt '': auf subdomain liegt ein Unique-Index, und ein zweites + // Projekt mit '' scheiterte mit "Document with the requested ID already exists". + subdomain: safeSubdomain || null, previewUrl, - projectType: hasWebsite ? 'website' : 'repo', + // Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem + projectType: hasWebsite ? 'preview' : 'repo', + // Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf + // "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment. + ...(kind + ? { + kind, + forCustomer: Boolean(customerId), + kindSetBy: staffName || '', + kindSetAt: new Date().toISOString(), + previewMode: kind === 'website' || kind === 'webapp' ? 'auto' : 'off', + } + : {}), ...(hasWebsite && deployKeepsStatus(deployResult) ? {} // Preview-Runner (C1): Status setzt der Verfolger : { @@ -343,14 +421,14 @@ export async function createProjectFromTemplate({ ? (deployResult.deployed ? 'ready' : 'deploy_failed') : 'ready', }), - }) + }), { waitMs: 10000 }) return { projectId: project.$id, repoFullName, previewUrl, subdomain: safeSubdomain, - projectType: hasWebsite ? 'website' : 'repo', + projectType: hasWebsite ? 'preview' : 'repo', deploy: deployResult, } } @@ -494,7 +572,7 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain, const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : '' const updated = await updateDocument(config.collections.websiteProjects, projectId, { projectName: nextName, - subdomain: nextSubdomain, + subdomain: nextSubdomain || null, // null statt '' (Unique-Index, siehe createProjectFromTemplate) previewUrl, projectType: nextType, isPublic, @@ -573,7 +651,26 @@ export async function archiveRepo(projectId, archived = true) { } } - if (!archived && !repoMissing && doc.subdomain) { + if (archived) { + const updated = await updateDocument(config.collections.websiteProjects, projectId, { + archived: true, + status: 'archived', + updatedAt: new Date().toISOString(), + }) + return { project: updated, archived: true } + } + + // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner + // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden. + // Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an. + const redeploy = !repoMissing && Boolean(doc.subdomain) + let updated = await updateDocument(config.collections.websiteProjects, projectId, { + archived: false, + status: redeploy ? 'pending' : 'synced', + updatedAt: new Date().toISOString(), + }) + + if (redeploy) { try { const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main' const cfg = (await fetchPreviewConfig(repoFullName, branch)) || { @@ -583,19 +680,19 @@ export async function archiveRepo(projectId, archived = true) { subdomain: doc.subdomain, displayName: doc.projectName, } - await runDeploy(repoFullName, branch, cfg, { project: doc }) + const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc }) + if (deployResult?.deployed) { + updated = await updateDocument(config.collections.websiteProjects, projectId, { + status: 'deployed', + provisioningStatus: 'ready', + }) + } } catch (err) { console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message) } } - const updated = await updateDocument(config.collections.websiteProjects, projectId, { - archived: Boolean(archived), - ...(archived ? { status: 'archived' } : {}), - updatedAt: new Date().toISOString(), - }) - - return { project: updated, archived: Boolean(archived) } + return { project: updated, archived: false } } /** Repo-Basisdaten (Groesse in KB, Default-Branch). */ diff --git a/server/services/giteaClient.js b/server/services/giteaClient.js index 003ac5f..32ce603 100644 --- a/server/services/giteaClient.js +++ b/server/services/giteaClient.js @@ -52,7 +52,7 @@ export async function giteaFetch(path, options = {}) { if (!response.ok) { let message = data?.message || `Gitea ${response.status}` if (response.status === 409 && /same name already exists/i.test(message)) { - message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.' + message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen wählen.' } const error = new Error(message) error.status = response.status diff --git a/server/services/repoAssignJob.js b/server/services/repoAssignJob.js index d1fadc0..95e5c6b 100644 --- a/server/services/repoAssignJob.js +++ b/server/services/repoAssignJob.js @@ -149,7 +149,7 @@ export async function runRepoAssignJob({ const docs = await listDocuments(collection(), [Query.limit(5000)]) for (const p of docs) { - if (!p.createdByGitea || p.kind || handled.has(p.$id)) continue + if (!p.createdByGitea || p.kind || p.archived || handled.has(p.$id)) continue // Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h. const attempts = Number(p.assignMailAttempts || 0) if (!p.assignMailSentAt && attempts < 3) { diff --git a/server/services/repoMove.js b/server/services/repoMove.js index 17d1ce0..ad963de 100644 --- a/server/services/repoMove.js +++ b/server/services/repoMove.js @@ -59,6 +59,8 @@ function sameRepoFullName(a, b) { export async function isInternalRepo(repoFullName) { const lower = repoFullName.toLowerCase() if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true + // Die Vorlage fuer "Neues Projekt" darf nie umziehen, sonst findet generate sie nicht mehr + if (`${config.gitea.templateOwner}/${config.gitea.templateRepo}`.toLowerCase() === lower) return true const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`) const portalUrl = portalWebhookUrl() diff --git a/test/giteaAdmin.test.js b/test/giteaAdmin.test.js index f1c3eca..01e86d9 100644 --- a/test/giteaAdmin.test.js +++ b/test/giteaAdmin.test.js @@ -65,7 +65,11 @@ test('importNewRepos legt nur fehlende an', async () => { const neu = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/neu') assert.deepEqual([neu.projectName, neu.status, neu.subdomain || '', neu.previewUrl || ''], ['Döner Nation', 'synced', '', '']) assert.ok(aw.all('websiteProjects').some((d) => d.repoFullName === 'knso/privat')) - assert.ok(!aw.all('websiteProjects').some((d) => d.repoFullName === 'JUSN/archiv')) + // In Gitea archivierte Repos kommen als archiviertes Projekt an (sichtbar unter "Archiviert"), + // aber nicht in createdRepos (keine Zuordnungs-Mail) + const archiv = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/archiv') + assert.deepEqual([archiv.archived, archiv.status], [true, 'archived']) + assert.equal(r.archivedImported, 1) assert.equal(aw.calls.filter((c) => c.method === 'PATCH').length, 0) }) test('importNewRepos läuft nie doppelt', async () => { diff --git a/test/helpers/fakeAppwrite.js b/test/helpers/fakeAppwrite.js index 6051145..61a8ecd 100644 --- a/test/helpers/fakeAppwrite.js +++ b/test/helpers/fakeAppwrite.js @@ -28,6 +28,10 @@ import { randomUUID } from 'node:crypto' export async function startFakeAppwrite(options = {}) { const calls = [] const pendingFailures = [] + // Optionale Unique-Indizes wie in Appwrite/MariaDB: { websiteProjects: ['repoFullName', 'subdomain'] }. + // Strings vergleichen ohne Gross-/Kleinschreibung, null/undefined kollidieren nie, '' schon. + const uniqueAttrs = options.unique || {} + let beforeCreate = null /** @type {Map>} Sammlungsname -> (Dokument-ID -> Dokument). */ const store = new Map() @@ -114,11 +118,24 @@ export async function startFakeAppwrite(options = {}) { send(res, 200, { total, documents: result }) } - function handleCreateDocument(match, searchParams, body, headers, res) { + function uniqueConflict(col, data, ownId) { + for (const attr of uniqueAttrs[col] || []) { + const value = data?.[attr] + if (value === null || value === undefined) continue + const norm = (v) => (typeof v === 'string' ? v.toLowerCase() : v) + for (const doc of collectionOf(col).values()) { + if (doc.$id !== ownId && doc[attr] !== null && doc[attr] !== undefined && norm(doc[attr]) === norm(value)) return true + } + } + return false + } + + async function handleCreateDocument(match, searchParams, body, headers, res) { + if (beforeCreate) await beforeCreate(match[2], body?.data || {}) const map = collectionOf(match[2]) const documentId = body?.documentId || randomUUID() - if (map.has(documentId)) { - return send(res, 409, { message: 'Document with the requested ID already exists.' }) + if (map.has(documentId) || uniqueConflict(match[2], body?.data, documentId)) { + return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' }) } const now = new Date().toISOString() const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) } @@ -135,6 +152,9 @@ export async function startFakeAppwrite(options = {}) { function handlePatchDocument(match, searchParams, body, headers, res) { const doc = collectionOf(match[2]).get(match[3]) if (!doc) return send(res, 404, { message: 'Document not found' }) + if (uniqueConflict(match[2], body?.data, doc.$id)) { + return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' }) + } Object.assign(doc, body?.data || {}) doc.$updatedAt = new Date().toISOString() send(res, 200, doc) @@ -212,11 +232,9 @@ export async function startFakeAppwrite(options = {}) { const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname)) if (!route) return send(res, 404, { message: 'Not Found' }) const match = route.re.exec(url.pathname) - try { - route.handler(match, url.searchParams, parsedBody, req.headers, res) - } catch (err) { - send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' }) - } + Promise.resolve() + .then(() => route.handler(match, url.searchParams, parsedBody, req.headers, res)) + .catch((err) => send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' })) }) }) @@ -245,10 +263,24 @@ export async function startFakeAppwrite(options = {}) { pendingFailures.push({ method, pathIncludes, status }) } + /** fn(collection, data) laeuft vor jeder Anlage (z. B. um einen gleichzeitigen Schreiber nachzustellen). */ + function setBeforeCreate(fn) { + beforeCreate = fn || null + } + + /** Dokument direkt in den Speicher legen (ohne HTTP), z. B. aus einem setBeforeCreate-Haken. */ + function insert(col, doc) { + const now = new Date().toISOString() + const id = doc.$id || randomUUID() + const full = { $createdAt: now, $updatedAt: now, ...doc, $id: id } + collectionOf(col).set(id, full) + return full + } + function close() { for (const socket of sockets) socket.destroy() return new Promise((resolve) => server.close(() => resolve())) } - return { endpoint, all, get, calls, failNext, close } + return { endpoint, all, get, calls, failNext, setBeforeCreate, insert, close } } diff --git a/test/helpers/fakeGitea.js b/test/helpers/fakeGitea.js index 25b9ea7..40b8604 100644 --- a/test/helpers/fakeGitea.js +++ b/test/helpers/fakeGitea.js @@ -63,6 +63,7 @@ export async function startFakeGitea(options = {}) { let transferDelayMs = 0 let responseDelayMs = 0 let transferNoopNext = false + let onGenerate = null const pendingFailures = [] let nextRepoId = 1 @@ -229,6 +230,30 @@ export async function startFakeGitea(options = {}) { send(res, 200, serializeRepo(found.record)) } + /** + * Repo aus Vorlage erzeugen wie Gitea (`POST /repos/{tplOwner}/{tpl}/generate`): 409, wenn es das + * Ziel schon gibt. `setOnGenerate(fn)` ruft fn(repo) nach dem Anlegen auf, BEVOR geantwortet + * wird - so laesst sich der sofort feuernde Webhook `repository/created` nachstellen. + */ + async function handleGenerate(match, searchParams, body, headers, res) { + const owner = body?.owner || '' + const name = body?.name || '' + if (!owner || !name) return send(res, 422, { message: 'owner und name fehlen' }) + if (repos.has(keyFrom(owner, name))) { + return send(res, 409, { message: 'The repository with the same name already exists.' }) + } + const record = addRepoInternal({ full_name: `${owner}/${name}`, description: body.description || '', private: body.private }) + if (onGenerate) await onGenerate(serializeRepo(record)) + send(res, 201, serializeRepo(record)) + } + + function handleGetBranches(match, searchParams, body, headers, res) { + const found = findRepoOrRedirect(match[1], match[2]) + if (!found) return send(res, 404, { message: 'Not Found' }) + if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/branches') + send(res, 200, [{ name: found.record.default_branch || 'main' }]) + } + function handlePatchRepo(match, searchParams, body, headers, res) { const found = findRepoOrRedirect(match[1], match[2]) if (!found) return send(res, 404, { message: 'Not Found' }) @@ -419,6 +444,8 @@ export async function startFakeGitea(options = {}) { { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks }, { method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds }, + { method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/generate$/, handler: handleGenerate }, + { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/branches$/, handler: handleGetBranches }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents }, { method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents }, { method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents }, @@ -542,6 +569,10 @@ export async function startFakeGitea(options = {}) { pendingFailures.push({ method, pathIncludes, status }) } + function setOnGenerate(fn) { + onGenerate = fn || null + } + function close() { for (const socket of sockets) socket.destroy() return new Promise((resolve) => server.close(() => resolve())) @@ -549,6 +580,7 @@ export async function startFakeGitea(options = {}) { return { baseUrl, + setOnGenerate, calls, repo, addRepo, diff --git a/test/projektAblauf.test.js b/test/projektAblauf.test.js new file mode 100644 index 0000000..45b8c6d --- /dev/null +++ b/test/projektAblauf.test.js @@ -0,0 +1,233 @@ +// Ablauf Projekt anlegen / archivieren / Import im Ticketsystem (Fehler aus dem Live-Durchlauf 2026-10-04): +// - "Neues Projekt" ohne Subdomain schrieb subdomain '' -> Unique-Index -> "Document with the requested ID already exists" +// - Wettlauf mit dem Webhook repository/created beim Anlegen +// - Reaktivieren liess status 'archived' stehen +// - archivierte Repos kamen nie ins Ticketsystem, Archiv-Stand aus Gitea nie an +// - "Gitea-Repos synchronisieren" ueberschrieb Projektnamen +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { startFakeGitea } from './helpers/fakeGitea.js' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +let gitea +let aw +let config +let svc +let handleRepositoryCreated +let upsertWebsiteProjectByRepo + +const docs = () => aw.all('websiteProjects') +const docOf = (name) => docs().find((d) => String(d.repoFullName).toLowerCase() === name.toLowerCase()) + +before(async () => { + gitea = await startFakeGitea({ + repos: [ + { full_name: 'knso/webklar-preview-template' }, + { full_name: 'knso/gradient-component' }, + { full_name: 'WEBklar/ohne-site' }, + { full_name: 'WEBklar/mit-site' }, + { full_name: 'WEBklar/D-ner-Nation', description: 'D-ner-Nation' }, + ], + }) + aw = await startFakeAppwrite({ + unique: { websiteProjects: ['repoFullName', 'subdomain'] }, + seed: { + websiteProjects: [ + // wie live: ein altes Projekt traegt subdomain '' (danach scheiterte jedes weitere ohne Website) + { repoFullName: 'knso/gradient-component', projectName: 'gradient component', subdomain: '' }, + { $id: 'p_ohne', repoFullName: 'WEBklar/ohne-site', projectName: 'Ohne Site', status: 'created' }, + { $id: 'p_mit', repoFullName: 'WEBklar/mit-site', projectName: 'Mit Site', subdomain: 'mit-site', status: 'deployed' }, + { $id: 'p_doener', repoFullName: 'WEBklar/D-ner-Nation', projectName: 'Döner Nation', subdomain: 'doener-nation' }, + ], + customers: [{ $id: 'c1', name: 'Kunde' }], + }, + }) + + Object.assign(process.env, { + GITEA_BASE_URL: gitea.baseUrl, + GITEA_API_TOKEN: 'test-token', + GITEA_DEFAULT_OWNER: 'WEBklar', + GITEA_TEMPLATE_OWNER: 'knso', + GITEA_TEMPLATE_REPO: 'webklar-preview-template', + APPWRITE_ENDPOINT: aw.endpoint, + APPWRITE_API_KEY: 'test-key', + PREVIEW_DEPLOY_ROOT: '', + TRAEFIK_DYNAMIC_DIR: '', + }) + ;({ config } = await import('../server/config.js')) + svc = await import('../server/services/giteaAdmin.js') + ;({ handleRepositoryCreated } = await import('../server/services/repoAssignJob.js')) + ;({ upsertWebsiteProjectByRepo } = await import('../server/services/appwriteAdmin.js')) +}) + +after(async () => { + await gitea.close() + await aw.close() +}) + +test('Neues Projekt ohne Subdomain: kein subdomain-Konflikt mehr, Kunde bleibt dran', async () => { + const r = await svc.createProjectFromTemplate({ displayName: 'Zweites ohne Website', customerId: 'c1' }) + assert.equal(r.repoFullName, 'WEBklar/zweites-ohne-website') + assert.equal(r.projectType, 'repo') + const d = docOf('WEBklar/zweites-ohne-website') + assert.equal(d.subdomain, null) + assert.equal(d.customerId, 'c1') + assert.equal(d.projectName, 'Zweites ohne Website') + // Gitea-Beschreibung = Anzeigename (Abgleich/Webhook lesen den Namen von dort) + const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate')) + assert.equal(gen.body.description, 'Zweites ohne Website') +}) + +test('Neues Projekt: Webhook repository/created legt das Dokument zuerst an -> eines, mit Kunde und Ticket', async () => { + gitea.setOnGenerate(async (repo) => { + await handleRepositoryCreated({ action: 'created', repository: repo, sender: { login: 'webklar-system' } }, { + sendMail: async () => ({ sent: false }), + }) + }) + try { + const r = await svc.createProjectFromTemplate({ displayName: 'Webhook zuerst', customerId: 'c1', ticketId: 't1' }) + const all = docs().filter((d) => d.repoFullName === r.repoFullName) + assert.equal(all.length, 1) + assert.equal(all[0].customerId, 'c1') + assert.equal(all[0].ticketId, 't1') + assert.equal(all[0].projectType, 'repo') + assert.equal(all[0].createdByGitea, 'webklar-system') + } finally { + gitea.setOnGenerate(null) + } +}) + +test('Wettlauf zwischen Suche und Anlage: 409 wird zum Update statt zur Fehlermeldung', async () => { + let raced = false + aw.setBeforeCreate((col, data) => { + if (col !== 'websiteProjects' || raced || data.repoFullName !== 'WEBklar/wettlauf') return + raced = true + aw.insert('websiteProjects', { repoFullName: 'WEBklar/wettlauf', projectName: 'wettlauf', status: 'synced' }) + }) + try { + const d = await upsertWebsiteProjectByRepo('WEBklar/wettlauf', { repoFullName: 'WEBklar/wettlauf', customerId: 'c1', projectName: 'Wettlauf' }) + assert.ok(raced) + assert.equal(d.customerId, 'c1') + assert.equal(docs().filter((x) => x.repoFullName === 'WEBklar/wettlauf').length, 1) + } finally { + aw.setBeforeCreate(null) + } +}) + +test('Neues Projekt mit Art (Ticket): kind gesetzt; unbekannte Art -> 400 ohne Repo', async () => { + await svc.createProjectFromTemplate({ displayName: 'Mit Art', customerId: 'c1', ticketId: 't2', kind: 'website', staffName: 'Kenso' }) + const d = docOf('WEBklar/mit-art') + assert.deepEqual([d.kind, d.forCustomer, d.kindSetBy, d.previewMode], ['website', true, 'Kenso', 'auto']) + assert.ok(d.kindSetAt) + + const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length + await assert.rejects(svc.createProjectFromTemplate({ displayName: 'Falsch', kind: 'quatsch' }), (err) => err.status === 400) + assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before) +}) + +test('Typ "Internes Projekt" schreibt null statt leerer Subdomain', async () => { + const r = await svc.updateWebsiteProject('p_mit', { projectType: 'project' }) + assert.equal(r.subdomain, '') + assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, null) + // fuer die folgenden Tests wieder als Website + await svc.updateWebsiteProject('p_mit', { projectType: 'preview', subdomain: 'mit-site' }) + assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, 'mit-site') +}) + +test('Archivieren und Reaktivieren ohne Website: status geht zurueck auf synced', async () => { + await svc.archiveRepo('p_ohne', true) + assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [true, 'archived']) + assert.equal(gitea.repo('WEBklar/ohne-site').archived, true) + + await svc.archiveRepo('p_ohne', false) + assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [false, 'synced']) + assert.equal(gitea.repo('WEBklar/ohne-site').archived, false) +}) + +test('Reaktivieren mit Website: status nicht mehr archived (pending bis der Deploy meldet)', async () => { + await svc.archiveRepo('p_mit', true) + assert.equal(aw.get('websiteProjects', 'p_mit').status, 'archived') + await svc.archiveRepo('p_mit', false) + const d = aw.get('websiteProjects', 'p_mit') + assert.equal(d.archived, false) + assert.equal(d.status, 'pending') // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy, also nicht 'deployed' + // Status vor dem Redeploy geschrieben, nicht danach (der Runner setzt spaeter selbst 'deployed') + const patches = aw.calls.filter((c) => c.method === 'PATCH' && c.path.endsWith('/p_mit')) + assert.equal(patches.at(-1).body.data.status, 'pending') +}) + +test('Abgleich ueberschreibt den Projektnamen nicht mehr', async () => { + await svc.syncReposToAppwrite() + assert.equal(aw.get('websiteProjects', 'p_doener').projectName, 'Döner Nation') +}) + +test('Import: archivierte Repos kommen als archiviert an (ohne Mail-Liste), Gitea-Archivstand wird gespiegelt, ein Fehler bricht nicht ab', async () => { + const g = await startFakeGitea({ + repos: [ + { full_name: 'knso/neu-archiviert', archived: true, description: 'Alt' }, + { full_name: 'knso/kaputt' }, + { full_name: 'knso/neu-aktiv' }, + { full_name: 'knso/in-gitea-archiviert', archived: true }, + { full_name: 'knso/in-gitea-reaktiviert' }, + ], + }) + const a = await startFakeAppwrite({ + seed: { + websiteProjects: [ + { $id: 'x1', repoFullName: 'knso/in-gitea-archiviert', projectName: 'A', status: 'synced', archived: false }, + { $id: 'x2', repoFullName: 'knso/in-gitea-reaktiviert', projectName: 'B', status: 'archived', archived: true }, + ], + }, + }) + const original = { gitea: config.gitea.baseUrl, appwrite: config.appwrite.endpoint } + config.gitea.baseUrl = g.baseUrl + config.appwrite.endpoint = a.endpoint + const originalError = console.error + console.error = () => {} + try { + // erste Anlage (knso/kaputt kommt nach dem archivierten) schlaegt fehl + a.failNext({ method: 'POST', pathIncludes: '/collections/websiteProjects/documents', status: 400 }) + const r = await svc.importNewRepos() + const byName = (n) => a.all('websiteProjects').find((d) => d.repoFullName === n) + // der erzwungene Fehler trifft die erste Anlage: das archivierte neue Repo + assert.deepEqual(r.failed, ['knso/neu-archiviert']) + assert.ok(byName('knso/kaputt')) + assert.ok(byName('knso/neu-aktiv')) + assert.deepEqual(r.createdRepos.map((x) => x.repo.full_name).sort(), ['knso/kaputt', 'knso/neu-aktiv']) + assert.equal(r.archiveSynced, 2) + assert.deepEqual([a.get('websiteProjects', 'x1').archived, a.get('websiteProjects', 'x1').status], [true, 'archived']) + assert.deepEqual([a.get('websiteProjects', 'x2').archived, a.get('websiteProjects', 'x2').status], [false, 'synced']) + + // naechster Lauf holt das archivierte Repo nach - als archiviert, ohne Mail-Liste + const r2 = await svc.importNewRepos() + assert.equal(r2.archivedImported, 1) + assert.deepEqual(r2.createdRepos, []) + const arch = byName('knso/neu-archiviert') + assert.deepEqual([arch.archived, arch.status, arch.projectName], [true, 'archived', 'Alt']) + // dritter Lauf: nichts mehr zu tun + const r3 = await svc.importNewRepos() + assert.deepEqual([r3.created, r3.archivedImported, r3.archiveSynced, r3.failed.length], [0, 0, 0, 0]) + } finally { + config.gitea.baseUrl = original.gitea + config.appwrite.endpoint = original.appwrite + console.error = originalError + await g.close() + await a.close() + } +}) + +test('Vorlage fuer "Neues Projekt" zieht beim Zuweisen nie um', async () => { + const { isInternalRepo } = await import('../server/services/repoMove.js') + assert.equal(await isInternalRepo('knso/webklar-preview-template'), true) +}) + +test('CORS erlaubt PUT (Preview-Variablen speichern)', async () => { + const { corsMiddleware } = await import('../server/middleware/cors.js') + const headers = {} + let status = 0 + const req = { method: 'OPTIONS', get: (h) => (h === 'Origin' ? 'https://ticket.webklar.com' : undefined) } + const res = { setHeader: (k, v) => { headers[k] = v }, sendStatus: (s) => { status = s } } + corsMiddleware(['https://ticket.webklar.com'])(req, res, () => {}) + assert.equal(status, 204) + assert.match(headers['Access-Control-Allow-Methods'], /\bPUT\b/) +}) From d878df96e00d58ae5073be23d403bfba936f9c8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:24:06 +0000 Subject: [PATCH 002/105] Zuordnungs-Lauf: vom System-Konto angelegte Projekte nicht alle 10 Minuten abfragen Co-Authored-By: Claude Opus 5.5 --- server/services/repoAssignJob.js | 3 +++ test/repoAssignDetect.test.js | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/server/services/repoAssignJob.js b/server/services/repoAssignJob.js index 95e5c6b..e513542 100644 --- a/server/services/repoAssignJob.js +++ b/server/services/repoAssignJob.js @@ -150,6 +150,9 @@ export async function runRepoAssignJob({ const docs = await listDocuments(collection(), [Query.limit(5000)]) for (const p of docs) { if (!p.createdByGitea || p.kind || p.archived || handled.has(p.$id)) continue + // Vom System-Konto angelegt (WOMS "Neues Projekt"): kein Mitarbeiter, den man erinnern + // koennte - sonst alle 10 Minuten Gitea-Abfrage und Log "keinem Mitarbeiter zugeordnet". + if (String(p.createdByGitea).toLowerCase() === String(config.gitea.systemUser || '').toLowerCase()) continue // Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h. const attempts = Number(p.assignMailAttempts || 0) if (!p.assignMailSentAt && attempts < 3) { diff --git a/test/repoAssignDetect.test.js b/test/repoAssignDetect.test.js index e930ba5..2f4d692 100644 --- a/test/repoAssignDetect.test.js +++ b/test/repoAssignDetect.test.js @@ -126,6 +126,17 @@ test('Job: Mail ohne vorherigen Versuch wird nachgeholt (createdByGitea, 0 Versu assert.equal(sink.sent.length, 1) }) +test('Job: vom System-Konto angelegtes Projekt ohne Art wird nicht abgefragt und nicht gemailt', async () => { + freshSink() + gitea.addRepo({ full_name: 'WEBklar/aus-womS', created_at: ago(1) }) + aw.insert('websiteProjects', { repoFullName: 'WEBklar/aus-womS', projectName: 'aus WOMS', createdByGitea: 'webklar-system', assignMailAttempts: 0 }) + const before = gitea.calls.filter((c) => c.path === '/api/v1/repos/WEBklar/aus-womS').length + const c = await runRepoAssignJob({ now: NOW }) + assert.equal(c.retries, 0) + assert.equal(sink.sent.length, 0) + assert.equal(gitea.calls.filter((x) => x.path === '/api/v1/repos/WEBklar/aus-womS').length, before) +}) + test('Job: neues Nutzer-Repo → Mail, Org-Repo → keine Mail', async () => { freshSink() gitea.addRepo({ full_name: 'knso/jobneu', created_at: ago(1) }) From 017a9c22f2a7f8ba1ead9a050473bf02a81230c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:32:51 +0000 Subject: [PATCH 003/105] Webhook: System-Commits legen kein Auto-Ticket an Beim Anlegen mit Kunde (ohne Ticket) loeste der Commit der .webklar-preview.json durch webklar-system ein zusaetzliches Ticket "Website " aus. Co-Authored-By: Claude Opus 5.5 --- server/routes/webhook/gitea.js | 7 +++++-- test/giteaRoutes.test.js | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index dee6506..5e23f4a 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -10,7 +10,7 @@ import { buildPreviewUrl, } from '../../services/previewDeploy.js' import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js' -import { createGitPushWorksheet } from '../../services/gitWorksheet.js' +import { createGitPushWorksheet, isSystemPusher } from '../../services/gitWorksheet.js' import { sendPushTimeMail } from '../../services/pushTimeMail.js' import { ensureProjectTicket } from '../../services/ticketAdmin.js' import { invalidateProjectCache } from '../../services/projectInsights.js' @@ -177,7 +177,10 @@ router.post('/gitea', async (req, res) => { // (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht // dieselbe ticketId-Aufloesung doppelt anstoesst. let gitWorksheet = null - if (commitSha && commitSha !== '0000000000000000000000000000000000000000') { + // System-Commits (z. B. .webklar-preview.json beim Anlegen aus dem Ticketsystem) bekommen weder + // Arbeitsblatt noch Auto-Ticket - sonst entstand beim Anlegen mit Kunde ein zusaetzliches + // Ticket "Website " neben dem, das im Zuordnen-Assistenten gewaehlt wird. + if (commitSha && commitSha !== '0000000000000000000000000000000000000000' && !isSystemPusher(pusher)) { gitWorksheet = await withLocks( ['project:' + project.$id], async () => { diff --git a/test/giteaRoutes.test.js b/test/giteaRoutes.test.js index fd491ba..3bd6600 100644 --- a/test/giteaRoutes.test.js +++ b/test/giteaRoutes.test.js @@ -223,6 +223,14 @@ test('Webhook des System-Kontos', async () => { await webhook(pushPayload({ full_name: 'WEBklar/D-ner-Nation', sha: 'cafe0001', pusher: 'webklar-system' })) assert.equal(aw.all('worksheets').length, before) }) +test('System-Commit an Projekt mit Kunde ohne Ticket legt kein Auto-Ticket an', async () => { + gitea.addRepo({ full_name: 'WEBklar/neu-mit-kunde' }) + aw.insert('websiteProjects', { $id: 'p_sys', repoFullName: 'WEBklar/neu-mit-kunde', projectName: 'Neu', customerId: 'c1', ticketId: '' }) + const before = aw.all('workorders').length + await webhook(pushPayload({ full_name: 'WEBklar/neu-mit-kunde', sha: 'cafe0002', pusher: 'webklar-system' })) + assert.equal(aw.all('workorders').length, before) + assert.equal(aw.get('websiteProjects', 'p_sys').ticketId, '') +}) test('Webhook wartet auf laufenden Umzug', async () => { gitea.setTransferDelay(300) const move = post('/api/admin/website-projects/p5/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin') // p5: JUSN/langsam From 800630ea4bbcf530542ad0dd66c9d2b0a78b2963 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:36:26 +0000 Subject: [PATCH 004/105] Projekt anlegen/archivieren: Review-Punkte - Anlegen uebernimmt keinen alten Eintrag gleichen Namens mehr (Repo in Gitea geloescht): Platzhalter werden entfernt, ein verknuepftes altes Projekt blockiert den Namen mit klarer Meldung, bevor ein Repo entsteht; neue Projekte starten mit archived=false - archiveRepo aendert Gitea und Dokument unter der Repo-Sperre; der Import liest den Archiv-Stand vor dem Spiegeln frisch aus Gitea - Reaktivieren: Preview aus -> synced, Deploy-Fehler -> deploy_failed statt dauerhaft 'pending' Co-Authored-By: Claude Opus 5.5 --- server/services/giteaAdmin.js | 130 ++++++++++++++++++++++------------ test/projektAblauf.test.js | 24 ++++++- 2 files changed, 105 insertions(+), 49 deletions(-) diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 073ce05..0506862 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -5,6 +5,7 @@ import { Query, getDocument, updateDocument, + deleteDocument, } from './appwriteAdmin.js' import { runDeploy, @@ -17,7 +18,7 @@ import { import { giteaFetch } from './giteaClient.js' import { slugify } from './slug.js' import { withLocks } from './keyedLock.js' -import { repoLockKey } from './repoMove.js' +import { repoLockKey, isUnlinkedProject } from './repoMove.js' import { isKind } from './projectKinds.js' // Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte @@ -90,17 +91,21 @@ export async function importNewRepos() { if (existing) { if (Boolean(existing.archived) === repoArchived) continue await withLocks([repoLockKey(fullName)], async () => { + // Unter der Sperre frisch lesen (Dokument UND Gitea): die Repo-Liste oben kann aelter + // sein als ein gerade laufendes Archivieren/Reaktivieren im Ticketsystem. const fresh = await getDocument(config.collections.websiteProjects, existing.$id) - if (!fresh || Boolean(fresh.archived) === repoArchived) return + const live = await giteaFetch(`/repos/${fullName}`) + const liveArchived = Boolean(live?.archived) + if (!fresh || Boolean(fresh.archived) === liveArchived) return await updateDocument(config.collections.websiteProjects, fresh.$id, { - archived: repoArchived, - ...(repoArchived + archived: liveArchived, + ...(liveArchived ? { status: 'archived' } : fresh.status === 'archived' ? { status: 'synced' } : {}), updatedAt: new Date().toISOString(), }) archiveSynced += 1 - }) + }, { waitMs: 10000 }) continue } @@ -323,6 +328,37 @@ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') { return previewConfig } +/** + * Vor dem Anlegen: gibt es das Repo in Gitea nicht (mehr), aber noch ein Projekt-Dokument mit dem + * Namen, wuerde das neue Projekt in diesen alten Eintrag uebernommen - samt archived, Art, Preview- + * Stand (und waere z. B. als archiviert unsichtbar). Platzhalter ohne Bezug werden entfernt, ein + * verknuepftes altes Projekt blockiert den Namen mit einer klaren Meldung - bevor ein Repo entsteht. + */ +async function releaseLeftoverProjectName(fullName) { + try { + await giteaFetch(`/repos/${fullName}`) + return // Repo existiert: generate meldet gleich "existiert bereits" + } catch (err) { + if (err.status !== 404) return + } + const leftovers = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', fullName), + Query.limit(10), + ]) + for (const old of leftovers) { + if (isUnlinkedProject(old)) { + await deleteDocument(config.collections.websiteProjects, old.$id) + continue + } + const error = new Error( + `Der Name "${fullName}" gehört noch zu einem alten Projekt ("${old.projectName || fullName}"` + + `${old.archived ? ', archiviert' : ''}). Bitte einen anderen Namen wählen.` + ) + error.status = 409 + throw error + } +} + export async function createProjectFromTemplate({ repoName, displayName, @@ -353,6 +389,8 @@ export async function createProjectFromTemplate({ await ensureSubdomainAvailable(safeSubdomain) } + await releaseLeftoverProjectName(`${targetOwner}/${safeRepoName}`) + const repo = await generateRepoFromTemplate({ owner: targetOwner, repoName: safeRepoName, @@ -402,6 +440,7 @@ export async function createProjectFromTemplate({ previewUrl, // Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem projectType: hasWebsite ? 'preview' : 'repo', + archived: false, // Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf // "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment. ...(kind @@ -628,49 +667,48 @@ export async function archiveRepo(projectId, archived = true) { const repoFullName = doc.repoFullName || '' let repoMissing = false - if (repoFullName.includes('/')) { - try { - await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) - } catch (err) { - // Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem - // nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet. - if (err.status === 404) { - repoMissing = true - console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`) - } else { - const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message) - error.status = err.status || 500 - throw error + // Gitea und Dokument unter der Repo-Sperre aendern - dieselbe nimmt der Import, der den + // Archiv-Stand aus Gitea spiegelt; sonst koennte er einen alten Stand zurueckschreiben. + const locks = repoFullName ? [repoLockKey(repoFullName)] : [] + let updated = await withLocks(locks, async () => { + if (repoFullName.includes('/')) { + try { + await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) + } catch (err) { + // Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem + // nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet. + if (err.status === 404) { + repoMissing = true + console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`) + } else { + const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message) + error.status = err.status || 500 + throw error + } } } - } - if (archived && doc.subdomain) { - try { await removeDeploy(doc.subdomain) } catch (err) { - console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message) - } - } - - if (archived) { - const updated = await updateDocument(config.collections.websiteProjects, projectId, { - archived: true, - status: 'archived', + // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner + // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden. + // Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an. + return updateDocument(config.collections.websiteProjects, projectId, { + archived: Boolean(archived), + status: archived ? 'archived' : (!repoMissing && doc.subdomain ? 'pending' : 'synced'), updatedAt: new Date().toISOString(), }) + }, { waitMs: 10000 }) + + if (archived) { + if (doc.subdomain) { + try { await removeDeploy(doc.subdomain) } catch (err) { + console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message) + } + } return { project: updated, archived: true } } - // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner - // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden. - // Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an. - const redeploy = !repoMissing && Boolean(doc.subdomain) - let updated = await updateDocument(config.collections.websiteProjects, projectId, { - archived: false, - status: redeploy ? 'pending' : 'synced', - updatedAt: new Date().toISOString(), - }) - - if (redeploy) { + if (!repoMissing && doc.subdomain) { + let after = null try { const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main' const cfg = (await fetchPreviewConfig(repoFullName, branch)) || { @@ -681,15 +719,15 @@ export async function archiveRepo(projectId, archived = true) { displayName: doc.projectName, } const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc }) - if (deployResult?.deployed) { - updated = await updateDocument(config.collections.websiteProjects, projectId, { - status: 'deployed', - provisioningStatus: 'ready', - }) - } + if (deployResult?.deployed) after = { status: 'deployed', provisioningStatus: 'ready' } + else if (deployResult?.skipped) after = { status: 'synced' } // Preview ausgeschaltet + else if (!deployResult?.queued) after = { provisioningStatus: 'deploy_failed' } + // queued: der Runner-Verfolger setzt den Status nach dem Bau } catch (err) { console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message) + after = { provisioningStatus: 'deploy_failed' } } + if (after) updated = await updateDocument(config.collections.websiteProjects, projectId, after) } return { project: updated, archived: false } diff --git a/test/projektAblauf.test.js b/test/projektAblauf.test.js index 45b8c6d..a12032e 100644 --- a/test/projektAblauf.test.js +++ b/test/projektAblauf.test.js @@ -150,10 +150,28 @@ test('Reaktivieren mit Website: status nicht mehr archived (pending bis der Depl await svc.archiveRepo('p_mit', false) const d = aw.get('websiteProjects', 'p_mit') assert.equal(d.archived, false) - assert.equal(d.status, 'pending') // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy, also nicht 'deployed' - // Status vor dem Redeploy geschrieben, nicht danach (der Runner setzt spaeter selbst 'deployed') + // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy -> 'pending' und als fehlgeschlagen markiert + assert.deepEqual([d.status, d.provisioningStatus], ['pending', 'deploy_failed']) + // der Status wird vor dem Redeploy geschrieben, danach nicht mehr (der Runner setzt 'deployed') const patches = aw.calls.filter((c) => c.method === 'PATCH' && c.path.endsWith('/p_mit')) - assert.equal(patches.at(-1).body.data.status, 'pending') + assert.equal(patches.at(-1).body.data.status, undefined) +}) + +test('Neues Projekt: alter verknuepfter Eintrag mit demselben Namen blockiert mit klarer Meldung, bevor ein Repo entsteht', async () => { + aw.insert('websiteProjects', { repoFullName: 'WEBklar/alt-belegt', projectName: 'Alt', archived: true, status: 'archived', kind: 'website', customerId: 'cAlt' }) + const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length + await assert.rejects(svc.createProjectFromTemplate({ displayName: 'alt belegt', repoName: 'alt-belegt' }), (err) => + err.status === 409 && /alten Projekt/.test(err.message) && /archiviert/.test(err.message)) + assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before) +}) + +test('Neues Projekt: alter Platzhalter ohne Bezug wird entfernt, das neue Projekt startet sauber', async () => { + aw.insert('websiteProjects', { $id: 'platzhalter', repoFullName: 'WEBklar/frei-machen', projectName: 'alt', archived: true, status: 'archived' }) + const r = await svc.createProjectFromTemplate({ displayName: 'Frei machen', customerId: 'c1' }) + assert.equal(aw.get('websiteProjects', 'platzhalter'), undefined) + const d = docOf(r.repoFullName) + assert.equal(d.archived, false) + assert.equal(d.customerId, 'c1') }) test('Abgleich ueberschreibt den Projektnamen nicht mehr', async () => { From 3156bb790a798d134878466c0ddb958b0d773395 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:02:02 +0000 Subject: [PATCH 005/105] Projekte: alle Mitarbeiter, private Repos, Archiv fuer Kunden ausgeblendet Entscheidungen vom 04.10.2026: - Projekt-Routen (website-projects, preview, gitea, services, analyse) fuer jeden Mitarbeiter (Label mitarbeiter oder admin); Kunden-, Mitarbeiter- und Finanz-Verwaltung bleiben nur fuer Admins - Neue Repos aus der Vorlage werden privat angelegt - Archivierte Projekte sieht der Kunde nicht mehr: Projektliste, Projektdetails, Preview-Zugang, Funktionen (Mitarbeiter-Ansicht schon) - Projektliste des Kunden mit Limit (Appwrite liefert sonst nur 25) Co-Authored-By: Claude Opus 5.5 --- server/middleware/appwriteStaff.js | 20 +++++++-- server/routes/admin/analyse.js | 5 ++- server/routes/admin/funktionenAdmin.js | 2 +- server/routes/admin/gitea.js | 5 ++- server/routes/admin/preview.js | 5 ++- server/routes/admin/services.js | 5 ++- server/routes/admin/websiteProjects.js | 5 ++- server/routes/auth.js | 3 +- server/routes/projects.js | 7 ++- server/services/giteaAdmin.js | 4 +- server/services/siteFeatures.js | 11 +++-- test/previewReadiness.test.js | 11 +++++ test/projektAblauf.test.js | 2 + test/rechtePrivatArchiv.test.js | 60 ++++++++++++++++++++++++++ 14 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 test/rechtePrivatArchiv.test.js diff --git a/server/middleware/appwriteStaff.js b/server/middleware/appwriteStaff.js index d9ca95e..b9382a4 100644 --- a/server/middleware/appwriteStaff.js +++ b/server/middleware/appwriteStaff.js @@ -1,6 +1,20 @@ import { config } from '../config.js' +import { ADMIN_LABEL, STAFF_LABELS } from '../services/staffLabels.js' -export async function requireAppwriteStaff(req, res, next) { +/** + * Prueft das Appwrite-JWT aus dem Ticketsystem und verlangt eines der Labels `allowed`. + * requireAppwriteStaff: nur "admin" (Kunden, Mitarbeiter, Finanzen ...). + * requireAppwriteTeam: jeder Mitarbeiter ("mitarbeiter" oder "admin") - Projekt-Aktionen + * (Anlegen, Zuordnen, Archivieren, Preview, Analyse, Gitea-Import), Entscheidung 2026-10-04. + */ +function requireAppwriteLabels(allowed, deniedMessage) { + return (req, res, next) => checkAppwriteJwt(req, res, next, allowed, deniedMessage) +} + +export const requireAppwriteStaff = requireAppwriteLabels([ADMIN_LABEL], 'Admin-Berechtigung erforderlich') +export const requireAppwriteTeam = requireAppwriteLabels(STAFF_LABELS, 'Nur für Mitarbeiter') + +async function checkAppwriteJwt(req, res, next, allowed, deniedMessage) { const authHeader = req.get('Authorization') || '' const match = authHeader.match(/^Bearer\s+(.+)$/i) if (!match) { @@ -32,8 +46,8 @@ export async function requireAppwriteStaff(req, res, next) { return res.status(401).json({ error: data?.message || 'Ungültiges JWT' }) } - if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) { - return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' }) + if (!Array.isArray(data?.labels) || !allowed.some((label) => data.labels.includes(label))) { + return res.status(403).json({ error: deniedMessage }) } req.appwriteUser = data diff --git a/server/routes/admin/analyse.js b/server/routes/admin/analyse.js index f9f2e47..b1cec8e 100644 --- a/server/routes/admin/analyse.js +++ b/server/routes/admin/analyse.js @@ -2,12 +2,13 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument } from '../../services/appwriteAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' import { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js' import { getFeatureDoc } from '../../services/siteFeatures.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) function sendError(res, err) { const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500 diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 065145e..6125e5d 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -49,7 +49,7 @@ async function ticketExists(ticketId) { // Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet async function adminView(customerId) { const docs = await listCustomerFeatureDocs(customerId) - const projects = await listCustomerFeatures(customerId, { docs }) + const projects = await listCustomerFeatures(customerId, { docs, includeArchived: true }) for (const p of projects) { for (const key of FEATURE_KEYS) { const view = p.features[key] diff --git a/server/routes/admin/gitea.js b/server/routes/admin/gitea.js index efecce8..ad9b83c 100644 --- a/server/routes/admin/gitea.js +++ b/server/routes/admin/gitea.js @@ -1,10 +1,11 @@ import { Router } from 'express' import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) router.post('/sync-repos', async (_req, res) => { try { diff --git a/server/routes/admin/preview.js b/server/routes/admin/preview.js index 3f135f5..ded1461 100644 --- a/server/routes/admin/preview.js +++ b/server/routes/admin/preview.js @@ -2,7 +2,7 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument, updateDocument } from '../../services/appwriteAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js' import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js' import * as runnerClient from '../../services/previewRunnerClient.js' @@ -11,7 +11,8 @@ import { withLocks } from '../../services/keyedLock.js' import { repoHasDevScript } from '../../services/previewDevScript.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) const BUSY = 'Server ausgelastet, versuche es gleich noch mal' const MODES = ['auto', 'build', 'dev', 'off'] diff --git a/server/routes/admin/services.js b/server/routes/admin/services.js index b9dc194..21bdcc7 100644 --- a/server/routes/admin/services.js +++ b/server/routes/admin/services.js @@ -2,11 +2,12 @@ import { Router } from 'express' import { config } from '../../config.js' import { listDocuments, Query } from '../../services/appwriteAdmin.js' import { servicesCatalog } from '../../services/projectKinds.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) const CHUNK = 100 diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index f55aa04..c119c98 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -6,11 +6,12 @@ import { } from '../../services/giteaAdmin.js' import { assignProject, reconcileProject } from '../../services/projectAssignment.js' import { completeAssignment } from '../../services/projectKindAssign.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) router.post('/create-from-template', async (req, res) => { const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {} diff --git a/server/routes/auth.js b/server/routes/auth.js index 578dcf4..6ab0708 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -292,7 +292,8 @@ router.get('/preview-access', async (req, res) => { const projects = await listDocuments(config.collections.websiteProjects, queries) const project = projects[0] - if (!project) { + // Archiviert: fuer Kunden wie kein Projekt (das Portal blendet es aus) + if (!project || (project.archived && !admin)) { return res.json({ allowed: false, reason: 'no_project' }) } if (!isPreviewProjectReady(project)) { diff --git a/server/routes/projects.js b/server/routes/projects.js index 093d24a..70697e6 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -14,7 +14,8 @@ const router = Router() async function loadOwnProject(req, res, next) { const customerId = getSessionCustomerId(req) const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null) - if (!project || !customerId || project.customerId !== customerId) { + // Archivierte Projekte sieht der Kunde nicht mehr (wie in der Liste) + if (!project || !customerId || project.customerId !== customerId || project.archived) { return res.status(404).json({ error: 'Projekt nicht gefunden' }) } req.project = project @@ -31,9 +32,11 @@ router.get('/', requireSession, async (req, res) => { const projects = await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), + Query.limit(500), // ohne Limit liefert Appwrite nur 25 ]) - const sanitized = projects.map((p) => ({ + // Archivierte Projekte (Vorschau entfernt) blendet das Portal aus (Entscheidung 2026-10-04) + const sanitized = projects.filter((p) => !p.archived).map((p) => ({ id: p.$id, projectName: p.projectName || '', subdomain: p.subdomain || '', diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 0506862..c0c75a4 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -218,7 +218,9 @@ export async function generateRepoFromTemplate({ owner, repoName, description }) body: JSON.stringify({ owner: targetOwner, name: repoName, - private: false, + // Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04). Portal und Preview-Runner + // lesen Gitea mit dem System-Schluessel, Mitarbeiter sehen Org-Repos ueber das Team Owners. + private: true, // Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort) description: description || `Website-Projekt ${repoName}`, git_content: true, diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 85350dd..27f4ec9 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -129,13 +129,16 @@ export function listCustomerFeatureDocs(customerId) { return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)]) } -/** docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. */ -export async function listCustomerFeatures(customerId, { docs: geladen } = {}) { - const projects = await listDocuments(config.collections.websiteProjects, [ +/** + * docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. + * includeArchived: nur fuer die Mitarbeiter-Ansicht - der Kunde sieht archivierte Projekte nicht. + */ +export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) { + const projects = (await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000), - ]) + ])).filter((p) => includeArchived || !p.archived) const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) const result = [] for (const p of projects) { diff --git a/test/previewReadiness.test.js b/test/previewReadiness.test.js index 949ad8c..d4414e7 100644 --- a/test/previewReadiness.test.js +++ b/test/previewReadiness.test.js @@ -67,3 +67,14 @@ test('Route: anderer Kunde bleibt ausgesperrt', async () => { assert.equal(r.allowed, false) assert.equal(r.reason, 'no_project') }) +test('Route: archiviertes Projekt ist fuer den Kunden gesperrt (Entscheidung 2026-10-04)', async () => { + const doc = aw.all('websiteProjects').find((d) => d.$id === 'p1') + doc.archived = true + try { + const r = await access(token.c1) + assert.equal(r.allowed, false) + assert.equal(r.reason, 'no_project') + } finally { + doc.archived = false + } +}) diff --git a/test/projektAblauf.test.js b/test/projektAblauf.test.js index a12032e..ad9c7ba 100644 --- a/test/projektAblauf.test.js +++ b/test/projektAblauf.test.js @@ -76,6 +76,8 @@ test('Neues Projekt ohne Subdomain: kein subdomain-Konflikt mehr, Kunde bleibt d // Gitea-Beschreibung = Anzeigename (Abgleich/Webhook lesen den Namen von dort) const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate')) assert.equal(gen.body.description, 'Zweites ohne Website') + // Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04) + assert.equal(gen.body.private, true) }) test('Neues Projekt: Webhook repository/created legt das Dokument zuerst an -> eines, mit Kunde und Ticket', async () => { diff --git a/test/rechtePrivatArchiv.test.js b/test/rechtePrivatArchiv.test.js new file mode 100644 index 0000000..df01400 --- /dev/null +++ b/test/rechtePrivatArchiv.test.js @@ -0,0 +1,60 @@ +// Entscheidungen vom 04.10.2026: Projekt-Aktionen fuer alle Mitarbeiter, archivierte Projekte im +// Kundenportal ausgeblendet. (Private Repos: siehe projektAblauf.test.js, generate-Aufruf.) +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +const aw = await startAppwriteMock() +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { startTestApp, rawGet, get, sessionCookie } = await import('./support/testApp.js') +const { default: projectsRouter } = await import('../server/routes/projects.js') +const { default: servicesRouter } = await import('../server/routes/admin/services.js') +const { default: customersRouter } = await import('../server/routes/admin/customers.js') +const app = await startTestApp({ + '/api/projects': projectsRouter, + '/api/admin/services': servicesRouter, + '/api/admin/customers': customersRouter, +}) +after(() => Promise.all([aw.close(), st.close(), app.close()])) + +aw.seed('websiteProjects', { $id: 'p_aktiv', customerId: 'c1', projectName: 'Aktiv', subdomain: 'aktiv', status: 'deployed' }) +aw.seed('websiteProjects', { $id: 'p_archiv', customerId: 'c1', projectName: 'Alt', subdomain: 'alt', status: 'archived', archived: true }) +const kunde = { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) } +const bearer = (jwt) => ({ headers: { Authorization: `Bearer ${jwt}` } }) + +test('Kundenportal: archivierte Projekte stehen nicht in der Liste', async () => { + const { projects } = await get('/api/projects', kunde) + assert.deepEqual(projects.map((p) => p.id), ['p_aktiv']) +}) + +test('Kundenportal: archiviertes Projekt auch per Direktaufruf nicht erreichbar', async () => { + assert.equal((await rawGet('/api/projects/p_archiv/work', kunde)).status, 404) + assert.equal((await rawGet('/api/projects/p_aktiv/work', kunde)).status, 200) +}) + +test('Projekt-Routen: Mitarbeiter ohne Admin-Label kommen rein, Kunden-Konto nicht', async () => { + assert.equal((await rawGet('/api/admin/services', bearer('jwt-staff'))).status, 200) + assert.equal((await rawGet('/api/admin/services', bearer('jwt-admin'))).status, 200) + const kundeKonto = await rawGet('/api/admin/services', bearer('jwt-kunde')) + assert.equal(kundeKonto.status, 403) + assert.equal(kundeKonto.body.error, 'Nur für Mitarbeiter') + assert.equal((await rawGet('/api/admin/services')).status, 401) +}) + +test('Kunden-Verwaltung bleibt nur fuer Admins', async () => { + const r = await rawGet('/api/admin/customers', bearer('jwt-staff')) + assert.equal(r.status, 403) + assert.equal(r.body.error, 'Admin-Berechtigung erforderlich') +}) + +test('Funktionen: Kunde sieht archivierte Projekte nicht, die Mitarbeiter-Ansicht schon', async () => { + const { listCustomerFeatures } = await import('../server/services/siteFeatures.js') + assert.deepEqual((await listCustomerFeatures('c1', { docs: [] })).map((p) => p.projectId), ['p_aktiv']) + assert.deepEqual( + (await listCustomerFeatures('c1', { docs: [], includeArchived: true })).map((p) => p.projectId).sort(), + ['p_aktiv', 'p_archiv'] + ) +}) From 4fc4c70b7df6a18a3aa7cbd5028d24bffb4d69b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:11:54 +0000 Subject: [PATCH 006/105] Archiv/Rechte: Review-Punkte - archiviertes Projekt bleibt unter Funktionen sichtbar, solange dort eine Funktion bestellt/eingerichtet/aktiv ist (z. B. Terminbuchungen) - auf archivierten Projekten nichts neu bestellen oder einschalten - Projekttyp Vorlage nur fuer Admins; repo-readme nur fuer bekannte Projekte Co-Authored-By: Claude Opus 5.5 --- server/routes/admin/gitea.js | 8 +++++++ server/routes/admin/websiteProjects.js | 4 ++++ server/routes/funktionen.js | 3 ++- server/services/siteFeatures.js | 14 +++++++++---- test/rechtePrivatArchiv.test.js | 29 +++++++++++++++++++++++++- 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/server/routes/admin/gitea.js b/server/routes/admin/gitea.js index ad9b83c..b84bb57 100644 --- a/server/routes/admin/gitea.js +++ b/server/routes/admin/gitea.js @@ -1,4 +1,6 @@ import { Router } from 'express' +import { config } from '../../config.js' +import { listDocuments, Query } from '../../services/appwriteAdmin.js' import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js' import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' @@ -36,6 +38,12 @@ router.get('/repo-readme', async (req, res) => { } try { + // Nur Repos, die ein Projekt sind - der System-Schluessel sieht sonst jedes (auch private) Repo + const known = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + if (!known[0]) return res.status(404).json({ error: 'Projekt nicht gefunden' }) const readme = await fetchRepoReadme(repoFullName) if (!readme) { return res.json({ ok: true, found: false, repoFullName }) diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index c119c98..1930d35 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -41,6 +41,10 @@ router.post('/create-from-template', async (req, res) => { // Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite router.patch('/:id', async (req, res) => { const { projectName, subdomain, projectType } = req.body || {} + // Vorlagen bleiben Admin-Sache (im Ticketsystem nur fuer Admins sichtbar) + if (projectType === 'template' && !req.appwriteUser?.labels?.includes('admin')) { + return res.status(403).json({ error: 'Nur Admins können Vorlagen anlegen.' }) + } try { const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType }) return res.json({ ok: true, ...result }) diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 604cd06..7c0694a 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -147,7 +147,7 @@ router.post('/news/checkout', requireSession, async (req, res) => { return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) } const customerId = getSessionCustomerId(req) - await assertOwnProject(customerId, projectId) + await assertOwnProject(customerId, projectId, { forNewOrder: true }) // Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine // zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { @@ -188,6 +188,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => { return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' }) } const { projectId, accentColor, consent } = req.body || {} + await assertOwnProject(getSessionCustomerId(req), analyseProjectId(projectId), { forNewOrder: true }) const { doc } = await activateAnalyse({ customerId: getSessionCustomerId(req), projectId: analyseProjectId(projectId), diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 27f4ec9..b8108c8 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -64,7 +64,8 @@ export async function getFeatureDoc(customerId, projectId, key) { } } -export async function assertOwnProject(customerId, projectId) { +/** forNewOrder: Neu bestellen/einschalten geht auf archivierten Projekten nicht mehr (Kunde sieht sie nicht). */ +export async function assertOwnProject(customerId, projectId, { forNewOrder = false } = {}) { let project = null try { project = await getDocument(config.collections.websiteProjects, projectId) @@ -72,6 +73,9 @@ export async function assertOwnProject(customerId, projectId) { if (err.status !== 404) throw err } if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt') + if (forNewOrder && project.archived) { + throw httpError(409, 'Dieses Projekt ist archiviert - dafür kann nichts mehr bestellt oder eingeschaltet werden.') + } return project } @@ -131,15 +135,17 @@ export function listCustomerFeatureDocs(customerId) { /** * docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. - * includeArchived: nur fuer die Mitarbeiter-Ansicht - der Kunde sieht archivierte Projekte nicht. + * includeArchived: nur fuer die Mitarbeiter-Ansicht. Der Kunde sieht archivierte Projekte nur noch, solange dort + * eine Funktion bestellt/eingerichtet/aktiv ist - sonst kaeme er z. B. nicht mehr an seine Terminbuchungen. */ export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) { + const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) + const laufend = new Set(docs.filter((d) => ['ordered', 'setup', 'active'].includes(d.state)).map((d) => d.projectId)) const projects = (await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000), - ])).filter((p) => includeArchived || !p.archived) - const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) + ])).filter((p) => includeArchived || !p.archived || laufend.has(p.$id)) const result = [] for (const p of projects) { const features = {} diff --git a/test/rechtePrivatArchiv.test.js b/test/rechtePrivatArchiv.test.js index df01400..2d7b030 100644 --- a/test/rechtePrivatArchiv.test.js +++ b/test/rechtePrivatArchiv.test.js @@ -9,14 +9,20 @@ import { useTestEnv } from './support/testEnv.js' const aw = await startAppwriteMock() const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) -const { startTestApp, rawGet, get, sessionCookie } = await import('./support/testApp.js') +const { startTestApp, rawGet, get, send, sessionCookie } = await import('./support/testApp.js') const { default: projectsRouter } = await import('../server/routes/projects.js') const { default: servicesRouter } = await import('../server/routes/admin/services.js') const { default: customersRouter } = await import('../server/routes/admin/customers.js') +const { default: websiteProjectsRouter } = await import('../server/routes/admin/websiteProjects.js') +const { default: giteaRouter } = await import('../server/routes/admin/gitea.js') +const { default: funktionenRouter } = await import('../server/routes/funktionen.js') const app = await startTestApp({ '/api/projects': projectsRouter, '/api/admin/services': servicesRouter, '/api/admin/customers': customersRouter, + '/api/admin/website-projects': websiteProjectsRouter, + '/api/admin/gitea': giteaRouter, + '/api/funktionen': funktionenRouter, }) after(() => Promise.all([aw.close(), st.close(), app.close()])) @@ -58,3 +64,24 @@ test('Funktionen: Kunde sieht archivierte Projekte nicht, die Mitarbeiter-Ansich ['p_aktiv', 'p_archiv'] ) }) + +test('Funktionen: archiviertes Projekt mit laufender Funktion bleibt fuer den Kunden sichtbar (Buchungen verwalten)', async () => { + const { listCustomerFeatures } = await import('../server/services/siteFeatures.js') + const docs = [{ customerId: 'c1', projectId: 'p_archiv', featureKey: 'termine', state: 'active' }] + assert.deepEqual((await listCustomerFeatures('c1', { docs })).map((p) => p.projectId).sort(), ['p_aktiv', 'p_archiv']) + const aus = [{ customerId: 'c1', projectId: 'p_archiv', featureKey: 'termine', state: 'off' }] + assert.deepEqual((await listCustomerFeatures('c1', { docs: aus })).map((p) => p.projectId), ['p_aktiv']) +}) + +test('Neu bestellen auf archiviertem Projekt geht nicht', async () => { + const r = await send('POST', '/api/funktionen/news/checkout', { ...kunde, body: { projectId: 'p_archiv', consent: true } }) + assert.equal(r.status, 409) + assert.match(r.body.error, /archiviert/) +}) + +test('Vorlage bleibt Admin-Sache; README nur fuer bekannte Projekte', async () => { + const t = await send('PATCH', '/api/admin/website-projects/p_aktiv', { ...bearer('jwt-staff'), body: { projectType: 'template' } }) + assert.equal(t.status, 403) + const fremd = await rawGet('/api/admin/gitea/repo-readme?repoFullName=JUSN/tickte-system', bearer('jwt-staff')) + assert.equal(fremd.status, 404) +}) From 75794f14d5ed52906eff3f3ba0a242ceff2e1a31 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:46:27 +0000 Subject: [PATCH 007/105] Preview: Deploy auf fremde Subdomain sperren Ein kopiertes Repo (git clone + push, Vorlage) bringt die .webklar-preview.json des Originals mit. Sein erster Push ueberschrieb die Preview des Originals, danach scheiterte der Webhook mit 500 (Unique-Index auf subdomain). Fast passiert bei Olzinger -> HSK-Vorlage, 04.10.2026. - foreignSubdomainOwner: Subdomain gehoert einem anderen Repo oder einem Projekt ohne Repo; ein in Gitea umgezogenes Repo (Weiterleitung) zaehlt nicht als fremd - Webhook: fremde Subdomain -> kein Deploy, Projekt ohne Preview (neu: synced, Repo-Name statt fremdem Anzeigenamen); eine schon vergebene eigene Preview bleibt, WOMS zeigt den Grund als Preview-Fehler - runDeploy prueft fuer alle anderen Wege (WOMS, Reaktivieren), vor dem Runner, damit ein gesperrter Aufruf keinen laufenden Bau verwaist; Appwrite-Fehler bei der Pruefung blockieren dort nicht (Webhook streng) Co-Authored-By: Claude Opus 5.5 --- server/routes/webhook/gitea.js | 32 +++- server/services/previewDeploy.js | 68 +++++++++ test/previewRunner.test.js | 26 ++++ test/subdomainSperre.test.js | 249 +++++++++++++++++++++++++++++++ 4 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 test/subdomainSperre.test.js diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index 5e23f4a..fa9a251 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -1,11 +1,12 @@ import crypto from 'node:crypto' import { Router } from 'express' import { config } from '../../config.js' -import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js' +import { upsertWebsiteProjectByRepo, getDocument, listDocuments, Query } from '../../services/appwriteAdmin.js' import { fetchPreviewConfig, defaultPreviewConfig, runDeploy, + foreignSubdomainBlock, deployKeepsStatus, buildPreviewUrl, } from '../../services/previewDeploy.js' @@ -107,18 +108,24 @@ router.post('/gitea', async (req, res) => { } } const previewConfig = previewConfigFile || defaultPreviewConfig(name) - const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile) + const wantsPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile) + // Kopiertes Repo mit der .webklar-preview.json eines anderen Projekts: dessen Subdomain weder + // beliefern noch uebernehmen - das Projekt bleibt ohne Preview, bis WOMS ihm eine eigene gibt. + const blocked = wantsPreview + ? await foreignSubdomainBlock(name, previewConfig.subdomain || name.split('/').pop()) + : null + const hasPreview = wantsPreview && !blocked // Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen - // Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website. const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : '' const previewUrl = subdomain ? buildPreviewUrl(subdomain) : '' - let deployResult = { deployed: false, skipped: true } + let deployResult = blocked || { deployed: false, skipped: true } const configBranch = previewConfig.branch || 'main' if (hasPreview && previewConfig.enabled !== false && branch === configBranch) { try { - deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha }) + deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha, subdomainChecked: true }) } catch (deployErr) { console.error('[webhook] deploy failed:', deployErr.message) deployResult = { deployed: false, error: deployErr.message } @@ -141,7 +148,8 @@ router.post('/gitea', async (req, res) => { const payloadNameStillCurrent = payloadName.toLowerCase() === current.toLowerCase() const projectPayload = { - projectName: previewConfig.displayName || (payloadNameStillCurrent ? repo.name : '') || subdomain, + // der Anzeigename einer fremden Config gehoert ebenfalls dem Original + projectName: (blocked ? '' : previewConfig.displayName) || (payloadNameStillCurrent ? repo.name : '') || subdomain, templateName: previewConfig.templateName || config.gitea.templateRepo, giteaRepoUrl: payloadNameStillCurrent ? repo.html_url || `${config.gitea.baseUrl}/${current}` @@ -161,6 +169,20 @@ router.post('/gitea', async (req, res) => { } else if (hasPreview) { projectPayload.status = 'pending' projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending' + } else if (blocked) { + // eine schon vergebene eigene Preview bleibt, wie sie ist - sonst Projekt ohne Preview; + // ein vorhandener Projektname auch (der Repo-Name waere nur der Ersatz fuer neue Projekte) + const existing = (await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', current), + Query.limit(1), + ]))[0] + if (!existing?.subdomain) projectPayload.status = 'synced' + else { + // sonst stuende im WOMS weiter der alte Bau als aktuell da (Fehlertext nur bei 'failed') + projectPayload.previewState = 'failed' + projectPayload.previewError = `Neuer Stand nicht veröffentlicht: In .webklar-preview.json steht die Subdomain „${blocked.subdomain}“, die gehört ${blocked.owner}. Projekt im WOMS bearbeiten und speichern, dann stimmt die Datei wieder.`.slice(0, 500) + } + if (existing?.projectName) delete projectPayload.projectName } else { projectPayload.status = 'synced' } diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 2d83d5f..fff127a 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -802,7 +802,75 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project } } +/** + * Gehoert `subdomain` schon einem ANDEREN Projekt? Ein kopiertes Repo (git clone + push, Vorlage) + * bringt die .webklar-preview.json des Originals mit - ohne diese Pruefung ueberschrieb sein erster + * Push die Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026). Ein in Gitea umbenanntes + * oder umgezogenes Repo ist nicht fremd: Gitea leitet den alten Namen auf `repoFullName` weiter. + * Ein Projekt ohne Repo (z. B. die Relaunch-Vorschau) haelt seine Subdomain ebenfalls. + * @returns {Promise} Repo-Name bzw. Projekt des fremden Besitzers, sonst '' + */ +export async function foreignSubdomainOwner(repoFullName, subdomain) { + const own = String(repoFullName || '').toLowerCase() + // ohne Repo gibt es nichts zu klonen - und ein Projekt ohne Repo sperrt sich nicht selbst + if (!subdomain || !own) return '' + const docs = await listDocuments(config.collections.websiteProjects, [ + Query.equal('subdomain', subdomain), + Query.limit(5), + ]) + for (const doc of docs) { + const other = String(doc.repoFullName || '') + if (!other) return `Projekt „${doc.projectName || doc.$id}“ (ohne Repo)` + if (other.toLowerCase() === own) continue + let resolved = '' + try { + resolved = String((await giteaFetch(`/repos/${other}`))?.full_name || '') + } catch { + // geloescht oder Gitea nicht erreichbar: im Zweifel fremd + } + if (resolved.toLowerCase() !== own) return other + } + return '' +} + +/** + * Ergebnis fuer einen gesperrten Deploy oder null. `failOpen`: ist die Pruefung selbst nicht + * moeglich (Appwrite weg), trotzdem deployen - so nutzt es runDeploy; der Webhook prueft vorher + * streng, und die WOMS-Wege lesen das Projekt ohnehin zuerst aus Appwrite. + */ +export async function foreignSubdomainBlock(repoFullName, subdomain, { failOpen = false } = {}) { + let owner + try { + owner = await foreignSubdomainOwner(repoFullName, subdomain) + } catch (err) { + if (!failOpen) throw err + console.error(`[previewDeploy] ${repoFullName}: Besitzer der Subdomain "${subdomain}" nicht pruefbar, deploye trotzdem:`, err.message) + return null + } + if (!owner) return null + console.warn(`[previewDeploy] ${repoFullName}: Subdomain "${subdomain}" gehoert ${owner} - kein Deploy`) + return { deployed: false, blocked: true, reason: 'subdomain_foreign', subdomain, owner } +} + +/** + * Muss runDeploy den Besitzer der Subdomain pruefen? Nicht, wenn der Aufrufer es schon getan hat + * (`subdomainChecked`, der Webhook) oder wenn es die schon vergebene Subdomain des uebergebenen + * Projekts selbst ist. + */ +function needsOwnerCheck(repoFullName, subdomain, { project, subdomainChecked } = {}) { + if (subdomainChecked) return false + const same = (a, b) => String(a || '').toLowerCase() === String(b || '').toLowerCase() + return !(project?.subdomain && same(project.subdomain, subdomain) && same(project.repoFullName, repoFullName)) +} + export async function runDeploy(repoFullName, branch, previewConfig, options = {}) { + // Vor dem Runner pruefen: ein gesperrter Aufruf belegt dort keinen Platz und laesst so den + // Verfolger eines laufenden Baus desselben Repos in Ruhe. + const target = previewConfig.subdomain || repoFullName.split('/').pop() + if (needsOwnerCheck(repoFullName, target, options)) { + const blocked = await foreignSubdomainBlock(repoFullName, target, { failOpen: true }) + if (blocked) return blocked + } if (config.previewRunner.enabled) { return runDeployViaRunner(repoFullName, branch, previewConfig, options) } diff --git a/test/previewRunner.test.js b/test/previewRunner.test.js index 29db6bd..ba02f47 100644 --- a/test/previewRunner.test.js +++ b/test/previewRunner.test.js @@ -103,6 +103,32 @@ test('Schalter aus: kein Runner-Aufruf, alter Pfad', async () => { assert.equal(rs.calls.length, 0) }) +test('fremde Subdomain (kopierte Config): kein Runner-Auftrag, Projekt unverändert', async () => { + const original = await project({ subdomain: 'original-seite' }) + const kopie = await project() + const r = await pd.runDeploy(kopie.repo, 'main', { ...cfg(kopie.repo), subdomain: 'original-seite' }, { project: kopie.doc, sha: SHA }) + assert.equal(r.blocked, true) + assert.equal(r.owner, original.repo) + assert.equal(submits().length, 0) + assert.equal(aw.get('websiteProjects', kopie.id).previewState, undefined) + // danach laeuft ein normaler Auftrag fuer dasselbe Repo wieder an + const ok = await pd.runDeploy(kopie.repo, 'main', cfg(kopie.repo), { project: kopie.doc, sha: SHA }) + assert.equal(ok.queued, true) + await pd.waitForRunFollowers() +}) + +test('gesperrter Aufruf waehrend eines laufenden Baus: der Verfolger bringt den Bau trotzdem online', async () => { + await project({ subdomain: 'andere-seite' }) + const { id, repo, doc } = await project() + const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + assert.equal(r.queued, true) + const blocked = await pd.runDeploy(repo, 'main', { ...cfg(repo), subdomain: 'andere-seite' }, { project: doc, sha: SHA }) + assert.equal(blocked.blocked, true) + await pd.waitForRunFollowers() + assert.equal(aw.get('websiteProjects', id).previewState, 'online') + assert.equal(submits().length, 1) +}) + test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis online', async () => { const { id, repo, doc } = await project({ projectName: 'Mein Projekt', previewEnv: envLib.encryptEnv({ API_URL: 'https://x.test' }, key) }) const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) diff --git a/test/subdomainSperre.test.js b/test/subdomainSperre.test.js new file mode 100644 index 0000000..1e2135a --- /dev/null +++ b/test/subdomainSperre.test.js @@ -0,0 +1,249 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import express from 'express' +import { startFakeGitea } from './helpers/fakeGitea.js' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +// Sperre gegen fremde Subdomains: Ein kopiertes Repo (git clone + push, Vorlage) bringt die +// .webklar-preview.json des Originals mit. Ohne Sperre ueberschrieb sein erster Push die +// Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026). + +const WEBHOOK_TOKEN = 's3cret' +const ORIGINAL = 'WEBklar/Olzinger' +const KOPIE = 'WEBklar/HSK-Kopie' + +const previewJson = (subdomain) => + JSON.stringify({ enabled: true, type: 'static', branch: 'main', displayName: 'Olzinger', subdomain }) + +let gitea +let aw +let tmp +let originalCwd +let server +let baseUrl +let foreignSubdomainOwner +let runDeploy +let foreignSubdomainBlock + +function pushPayload(repo, { sha, branch = 'main', pusher = 'knso' }) { + return { + ref: `refs/heads/${branch}`, + repository: { full_name: repo, name: repo.split('/')[1], html_url: `${gitea.baseUrl}/${repo}` }, + commits: [{ id: sha, message: `Commit ${sha}`, author: { name: pusher } }], + pusher: { username: pusher }, + after: sha, + } +} + +async function webhook(payload) { + const bodyText = JSON.stringify(payload) + const signature = createHmac('sha256', WEBHOOK_TOKEN).update(bodyText).digest('hex') + const res = await fetch(`${baseUrl}/webhook/gitea`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': signature }, + body: bodyText, + }) + return { status: res.status, body: await res.json().catch(() => null) } +} + +const docOf = (repo) => aw.all('websiteProjects').find((d) => d.repoFullName === repo) + +before(async () => { + gitea = await startFakeGitea({ + repos: [ + { full_name: ORIGINAL }, + { full_name: KOPIE }, + { full_name: 'WEBklar/neu-name' }, + { full_name: 'WEBklar/relaunch-kopie' }, + { full_name: 'WEBklar/eigene-seite' }, + ], + redirects: { 'knso/alt-name': 'WEBklar/neu-name' }, + contents: { + [`${ORIGINAL}:.webklar-preview.json`]: previewJson('olzinger'), + [`${KOPIE}:.webklar-preview.json`]: previewJson('olzinger'), + 'WEBklar/relaunch-kopie:.webklar-preview.json': previewJson('relaunch'), + // hat schon eine eigene Preview, Config von Hand auf eine fremde Subdomain gestellt + 'WEBklar/eigene-seite:.webklar-preview.json': previewJson('olzinger'), + }, + }) + aw = await startFakeAppwrite({ + unique: { websiteProjects: ['repoFullName', 'subdomain'] }, + seed: { + websiteProjects: [ + { $id: 'olz', repoFullName: ORIGINAL, projectName: 'Olzinger', subdomain: 'olzinger', customerId: 'c1', ticketId: 't1', status: 'deployed' }, + // per Gitea umgezogen (alter Name leitet weiter), Dokument noch unter dem alten Namen + { $id: 'umzug', repoFullName: 'knso/alt-name', projectName: 'Umzug', subdomain: 'umzug-seite' }, + // Repo in Gitea geloescht, Dokument haelt die Subdomain noch + { $id: 'weg', repoFullName: 'knso/geloescht', projectName: 'Weg', subdomain: 'weg-seite' }, + // gehostet ohne Repo (wie die Relaunch-Vorschau) + { $id: 'rel', repoFullName: '', projectName: 'WEBklar Relaunch', subdomain: 'relaunch' }, + { $id: 'eigen', repoFullName: 'WEBklar/eigene-seite', projectName: 'Eigene Seite', subdomain: 'eigene-seite', status: 'deployed', provisioningStatus: 'ready' }, + ], + workorders: [{ $id: 't1', woid: '100130', customerName: 'Olzinger', topic: 'Website', status: 'Open' }], + customers: [{ $id: 'c1', name: 'Olzinger GmbH' }], + employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }], + }, + }) + + originalCwd = process.cwd() + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'subdomain-sperre-')) + process.chdir(tmp) + + Object.assign(process.env, { + GITEA_BASE_URL: gitea.baseUrl, + GITEA_API_TOKEN: 'test-token', + GITEA_WEBHOOK_TOKEN: WEBHOOK_TOKEN, + APPWRITE_ENDPOINT: aw.endpoint, + APPWRITE_API_KEY: 'test-key', + PREVIEW_DEPLOY_ROOT: '', + TRAEFIK_DYNAMIC_DIR: '', + PUSH_TIME_SECRET: 'w'.repeat(40), + }) + + const [{ default: giteaWebhookRoutes }, previewDeploy, mailer] = await Promise.all([ + import('../server/routes/webhook/gitea.js'), + import('../server/services/previewDeploy.js'), + import('../server/services/mailer.js'), + ]) + foreignSubdomainOwner = previewDeploy.foreignSubdomainOwner + runDeploy = previewDeploy.runDeploy + foreignSubdomainBlock = previewDeploy.foreignSubdomainBlock + mailer.setMailTransportForTests({ sendMail: async () => ({}) }) + + const app = express() + app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } })) + app.use('/webhook', giteaWebhookRoutes) + server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)) + }) + baseUrl = `http://127.0.0.1:${server.address().port}` +}) + +after(async () => { + if (server) await new Promise((resolve) => server.close(resolve)) + if (originalCwd) process.chdir(originalCwd) + await gitea?.close() + await aw?.close() + if (tmp) await fs.rm(tmp, { recursive: true, force: true }) +}) + +test('foreignSubdomainOwner: freie Subdomain und eigenes Repo sind nicht fremd', async () => { + assert.equal(await foreignSubdomainOwner(KOPIE, 'noch-frei'), '') + assert.equal(await foreignSubdomainOwner(ORIGINAL, 'olzinger'), '') + assert.equal(await foreignSubdomainOwner('webklar/OLZINGER', 'olzinger'), '', 'Schreibweise egal') + assert.equal(await foreignSubdomainOwner(KOPIE, ''), '') +}) + +test('foreignSubdomainOwner: Subdomain eines anderen Repos ist fremd', async () => { + assert.equal(await foreignSubdomainOwner(KOPIE, 'olzinger'), ORIGINAL) +}) + +test('foreignSubdomainOwner: in Gitea umgezogenes Repo ist nicht fremd, geloeschtes schon', async () => { + assert.equal(await foreignSubdomainOwner('WEBklar/neu-name', 'umzug-seite'), '') + assert.equal(await foreignSubdomainOwner(KOPIE, 'weg-seite'), 'knso/geloescht') +}) + +test('foreignSubdomainOwner: Projekt ohne Repo haelt seine Subdomain, sperrt sich aber nicht selbst', async () => { + assert.equal(await foreignSubdomainOwner(KOPIE, 'relaunch'), 'Projekt „WEBklar Relaunch“ (ohne Repo)') + assert.equal(await foreignSubdomainOwner('', 'relaunch'), '') +}) + +test('runDeploy verweigert eine fremde Subdomain, bevor irgendetwas geklont wird', async () => { + const before = gitea.calls.length + const r = await runDeploy(KOPIE, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' }) + assert.equal(r.deployed, false) + assert.equal(r.blocked, true) + assert.equal(r.reason, 'subdomain_foreign') + assert.equal(r.subdomain, 'olzinger') + assert.equal(r.owner, ORIGINAL) + // nur die Besitzer-Pruefung fragt Gitea (nach dem Original), fuer die Kopie wird nichts geholt + assert.deepEqual(gitea.calls.slice(before).map((c) => `${c.method} ${c.path}`), [`GET /api/v1/repos/${ORIGINAL}`]) +}) + +test('runDeploy: Pruefung nicht moeglich (Appwrite-Fehler) → Deploy laeuft trotzdem', async () => { + aw.failNext({ method: 'GET', pathIncludes: 'websiteProjects', status: 500 }) + const r = await runDeploy(KOPIE, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' }) + assert.equal(r.blocked, undefined) + assert.equal(r.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt') +}) + +test('foreignSubdomainBlock ohne failOpen (Webhook): Appwrite-Fehler wird weitergereicht', async () => { + aw.failNext({ method: 'GET', pathIncludes: 'websiteProjects', status: 500 }) + await assert.rejects(foreignSubdomainBlock(KOPIE, 'olzinger')) + assert.equal((await foreignSubdomainBlock(KOPIE, 'olzinger')).owner, ORIGINAL) +}) + +test('runDeploy: eigene Subdomain des uebergebenen Projekts und subdomainChecked brauchen keine Abfrage', async () => { + const calls = aw.calls.length + const own = await runDeploy(ORIGINAL, 'main', { subdomain: 'olzinger' }, { project: aw.get('websiteProjects', 'olz') }) + assert.equal(own.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt') + const checked = await runDeploy(KOPIE, 'main', { subdomain: 'olzinger' }, { subdomainChecked: true }) + assert.equal(checked.blocked, undefined, 'der Aufrufer hat schon geprueft') + assert.equal(aw.calls.length, calls) +}) + +test('runDeploy: eigene Subdomain laeuft weiter wie bisher', async () => { + const r = await runDeploy(ORIGINAL, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' }) + assert.equal(r.blocked, undefined) + assert.equal(r.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt') +}) + +test('Webhook: Push einer Kopie mit fremder Subdomain → kein Deploy, Projekt ohne Preview', async () => { + const r = await webhook(pushPayload(KOPIE, { sha: 'aaaa1111' })) + assert.equal(r.status, 200) + assert.equal(r.body.subdomain, '') + assert.equal(r.body.previewUrl, '') + assert.equal(r.body.deploy.blocked, true) + assert.equal(r.body.deploy.owner, ORIGINAL) + + const kopie = docOf(KOPIE) + assert.ok(kopie, 'Dokument der Kopie angelegt') + assert.ok(!kopie.subdomain, 'Kopie bekommt die fremde Subdomain nicht') + assert.equal(kopie.status, 'synced') + assert.equal(kopie.projectName, 'HSK-Kopie', 'nicht der Anzeigename aus der fremden Config') + + const original = aw.get('websiteProjects', 'olz') + assert.equal(original.subdomain, 'olzinger') + assert.equal(original.status, 'deployed') +}) + +test('Webhook: Kopie auf anderem Branch bekommt die fremde Subdomain auch nicht', async () => { + const r = await webhook(pushPayload(KOPIE, { sha: 'bbbb2222', branch: 'entwurf' })) + assert.equal(r.status, 200) + assert.equal(r.body.subdomain, '') + assert.ok(!docOf(KOPIE).subdomain) +}) + +test('Webhook: Kopie mit der Subdomain eines Projekts ohne Repo wird gesperrt', async () => { + const r = await webhook(pushPayload('WEBklar/relaunch-kopie', { sha: 'cccc3333' })) + assert.equal(r.status, 200) + assert.equal(r.body.deploy.blocked, true) + assert.equal(aw.get('websiteProjects', 'rel').subdomain, 'relaunch') +}) + +test('Webhook: Push des Originals deployt wie bisher', async () => { + const r = await webhook(pushPayload(ORIGINAL, { sha: 'dddd4444' })) + assert.equal(r.status, 200) + assert.equal(r.body.subdomain, 'olzinger') + assert.equal(r.body.deploy.blocked, undefined) + assert.equal(r.body.deploy.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt') + assert.equal(docOf(ORIGINAL).subdomain, 'olzinger') +}) + +test('Webhook: Projekt mit eigener Preview und fremder Subdomain in der Config bleibt, wie es ist', async () => { + const r = await webhook(pushPayload('WEBklar/eigene-seite', { sha: 'ffff6666' })) + assert.equal(r.status, 200) + assert.equal(r.body.deploy.blocked, true) + const doc = aw.get('websiteProjects', 'eigen') + assert.equal(doc.subdomain, 'eigene-seite') + assert.equal(doc.status, 'deployed') + assert.equal(doc.provisioningStatus, 'ready') + assert.equal(doc.projectName, 'Eigene Seite') + assert.equal(doc.previewState, 'failed', 'WOMS zeigt den Fehlertext nur bei failed') + assert.match(doc.previewError, /„olzinger“, die gehört WEBklar\/Olzinger/) + assert.equal(aw.get('websiteProjects', 'olz').subdomain, 'olzinger') +}) From 2f9dfd8ad5da536902fa4188abef332c6c32f51c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:37:19 +0000 Subject: [PATCH 008/105] =?UTF-8?q?Spec:=20Analyse-=C3=9Cbersicht=20neu=20?= =?UTF-8?q?(Cockpit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-10-04-analyse-uebersicht-design.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md diff --git a/docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md b/docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md new file mode 100644 index 0000000..e6866e0 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md @@ -0,0 +1,239 @@ +# Analyse-Übersicht neu („Cockpit“) – Design + +Stand: 2026-10-04 · Status: Aufbau, Design-Richtung und Abschnitte 1–3 vom Nutzer im Chat freigegeben, Spec zur Durchsicht +Baut auf der Website-Analyse auf (Spec `2026-10-01-website-analyse-design.md`, live seit 01.10.2026). +Recherche: `/root/reports/analyse-ueberarbeitung-2026-10-04/recherche.md` (30 Tools, 25 Features, UI-Muster). +Entwürfe: `/root/reports/analyse-ueberarbeitung-2026-10-04/entwurf-a-cockpit.png` (gewählt), `entwurf-b-bericht.png`. +Regel des Nutzers: bauen → live (Nutzer pusht `main`) → Nutzer testet live. + +## Ziel + +Die Analyse-Seite im Kundenportal (Funktionen → Analyse) soll für kleine Betriebe ohne Analytics-Kenntnisse +**auf einen Blick** sagen, wie die Website läuft: ein Satz in Klartext, Vergleich zum Vorzeitraum, +Kontakt-Aktionen als Hauptzahl. Das Interface darf sich vom restlichen Portal abheben, muss aber +aufgeräumt und einheitlich sein. + +## Entscheidungen (vom Nutzer, 04.10.2026) + +| # | Entscheidung | +|---|---| +| U1 | Umfang: nur das Paket **„Übersicht neu“** (Kennzahlen mit Vorzeitraum-Vergleich und Mini-Verlauf, Klartext-Zusammenfassung, Kontakt-Aktionen als Hauptzahl, Datenbasis-Hinweis). **Nicht** in diesem Projekt: Besucher-Details (Wochentag × Uhrzeit, Kanäle, Zeit je Seite), Website-Check (Erreichbarkeit, SSL, Links, SEO), Bericht per Mail. | +| U2 | Design-Richtung **A „Cockpit“**: dunkel wie das Portal, eigene Akzentfarbe Türkis nur innerhalb der Analyse, leuchtender Block „Das Wichtigste“. | +| U3 | Aufbau mit drei Reitern **Übersicht · Heatmap · Einstellungen**; Heatmap und „Ausschalten“ hängen nicht mehr unten an der Übersicht. | +| U4 | Nur **vorhandene Daten**. Keine Änderung an Messskript (`w.js`, `h.js`), Banner, AVV, Leistungsbeschreibung, Datenschutz-Zusatzseite oder Abo-Bedingungen. | +| U5 | Klartext entsteht durch **feste Regeln im Browser**, keine KI, kein externer Dienst. | + +Rechtliche Einordnung (keine Rechtsberatung): Der Vergleich nutzt die Tageswerte des Vorzeitraums (bis zu 180 +Tage zurück; Tageswerte werden 25 Monate aufbewahrt). Durchschnitte und Tageswerte für Besucher, Kontakte und +Verweildauer stammen aus bereits erhobenen und verdichteten Daten (`daily`: `visitors`, `ct_kind`, +`en_count`, `en_sec_sum`, `scroll_sum`). Die Leistungsbeschreibung (§ 5: „Kennzahlen und Diagramme für die +letzten 7, 30 oder 90 Tage“) deckt das ab; kein neuer Text, keine neue Einwilligung. + +## 1. Daten: Statistik-Antwort des Collectors + +`GET /a/admin/stats/:siteKey?range=7|30|90` (interner Listener, `analytics/src/stats.js`) liefert **zusätzlich** +zu den bisherigen Feldern (alle bisherigen Felder bleiben unverändert, damit der Wechsel abwärtskompatibel ist): + +```js +{ + range: 30, + from: '2026-09-05', to: '2026-10-04', // Berliner Kalendertage des Zeitraums (to = heute) + firstDay: '2026-08-20', // frühester Berliner Tag mit Daten dieser Website ('' = keine) + totals: { + pv, visitors, contacts, consentRate, // wie bisher + engaged, // Zahl der en-Ereignisse (Seitenbesuche mit Zustimmung und Abschluss) + avgSec, // Ø Sekunden pro Seitenbesuch = en_sec_sum / en_count, gerundet; null wenn engaged = 0 + avgScroll, // Ø Scrolltiefe in % = scroll_sum / en_count, gerundet; null wenn engaged = 0 + }, + series: [{ day, pv, visitors, contacts, avgSec }], // bisher nur {day, pv}; avgSec je Tag null ohne en + pages, refs, countries, devices, contacts, banner, // unverändert + previous: { + from: '2026-08-06', to: '2026-09-04', // die `range` Kalendertage direkt vor `from` + complete: true, // firstDay !== '' && firstDay <= previous.from + totals: { pv, visitors, contacts, consentRate, engaged, avgSec, avgScroll }, + series: [{ day, pv, visitors, contacts, avgSec }], + contacts: [{ key, count }], // je Art, für die Veränderung pro Zeile + }, +} +``` + +Regeln: +- Tageslisten werden **kalendarisch** gebildet (Berliner Tage, wie `dayList`), nicht durch Abziehen von + `range × 24 h` – sonst verrutscht der Vorzeitraum bei der Zeitumstellung um einen Tag. +- Gemeinsame Hilfsfunktion sammelt die Dimensionen für eine beliebige Tagesliste: verdichtete Tage aus `daily`, + nicht verdichtete Tage live aus `events_raw` (wie heute). Kein Tag wird doppelt gezählt. +- `firstDay` = kleinster Tag aus `daily` und `events_raw` dieser Website (`MIN(day)` bzw. `dayOf(MIN(ts))`). +- `visitors` bleibt die Summe der Tages-Besucher (Salz wechselt täglich, kein tagesübergreifender Abgleich). +- Der Portal-Server reicht die Antwort unverändert durch (`GET /api/funktionen/analyse/stats`). + +Portal-Server, einzige weitere Änderung: `analyseView` (Route `GET /api/funktionen`) gibt zusätzlich +`activatedAt` aus dem Analyse-Dokument zurück (`null`, wenn nicht aktiv). + +## 2. Oberfläche + +### 2.1 Aufbau der Seite (Zustand „aktiv“) + +``` +[ Kopfleiste: ● Website-Name · „Analyse aktiv · Stand: heute, 19:42 Uhr“ (Website-Auswahl) [7 | 30 | 90 Tage] ] +[ Reiter: Übersicht | Heatmap | Einstellungen ] +Übersicht: + [ Das Wichtigste · 5. Sep. – 4. Okt. — Klartext-Satz, darunter bis zu 4 Hinweise mit Symbol ] + [ Seitenaufrufe ] [ Besucher* ] [ Kontakt-Aktionen* ] [ Ø Zeit pro Seite* ] je: Zahl, ▲/▼-Chip, „vs. “, Mini-Verlauf + [ Seitenaufrufe pro Tag (Linie + gestrichelter Vorzeitraum) ] [ Kontakt-Aktionen: Anrufe, E-Mails, WhatsApp, Links nach außen + Quote ] + [ Was angesehen wird: Seiten | Herkunft ] [ Wer zu Besuch ist: Geräte | Länder ] + [ Datenbasis: Ring mit Zustimmungsquote + Erklärung ] [ Heatmap: Vorschaubild + „Öffnen“ → Reiter Heatmap ] + Fußzeile: „Länderdaten: IP-Geolocation by DB-IP“ +``` + +- **Kopfleiste**: Statuspunkt (türkis = aktiv). Bei `devMode` statt „Analyse aktiv“ gelb: „Im Dev-Modus wird + nicht gemessen.“ „Stand“ = Ladezeitpunkt der Daten. Bei mehreren Websites ein Auswahlfeld in der Leiste + (ersetzt das bisherige `fn-project`-Panel). Der Zeitraum-Schalter erscheint nur im Reiter Übersicht + (die Heatmap zeigt fest 30 Tage). +- **Reiter**: `role="tablist"`, Zustand bleibt beim Wechsel von Zeitraum oder Website erhalten + (`analyseSel.tab`), der URL-Hash bleibt `#analyse`. Auf dem Handy horizontal wischbar. +- **Das Wichtigste**: siehe 2.3. Unter 30 Seitenaufrufen im Zeitraum: „Sobald ein paar mehr Besuche da sind, + steht hier das Wichtigste über deine Website in zwei, drei Sätzen.“ +- **Kennzahlen-Kacheln** (4): Seitenaufrufe, Besucher*, Kontakt-Aktionen*, Ø Zeit pro Seite* (Format `m:ss`, + darunter klein „Ø 64 % gescrollt“). Chip mit Veränderung (2.4). Mini-Verlauf aus `series` (Fläche + Linie, + ohne Achsen). Ohne Vergleich steht statt Chip „Vergleich ab TT.MM.“ (2.4). +- **Diagramm**: Seitenaufrufe pro Tag, aktuelle Linie türkis mit Fläche, Vorzeitraum gestrichelt grau (nach + Tagesindex ausgerichtet; nur wenn `previous.complete`). Legende „30 Tage“ / „Zeitraum davor“. Antippen oder + Überfahren eines Tages zeigt eine Blase „Sa., 19.09.: 371 Aufrufe (davor 210)“. SVG behält ``. +- **Kontakt-Aktionen**: Summe im Kopf, Zeilen Anrufe (`tel`), E-Mails (`mail`), WhatsApp (`wa`), Links nach außen + (`out`) mit Symbol, Zahl und Veränderungs-Chip; Arten mit 0 in beiden Zeiträumen entfallen. Darunter die + Quote: „Auf 100 Besucher kommen 7,8 Kontakt-Aktionen (davor 6,5).“ (erst ab 30 Besuchern; Kontakt-Aktionen sind + Klicks, keine Personen – daher „pro 100 Besucher“, nicht „x % der Besucher“). Ohne Kontakte: „Noch keine + Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder + WhatsApp tippt.“ +- **Ranglisten** in zwei Karten mit kleinem Umschalter: „Was angesehen wird“ (Seiten | Herkunft), „Wer zu Besuch + ist“ (Geräte | Länder). Zeile = Balken als Hintergrund des Namens, Zahl, Anteil in %. Erst 6 Zeilen, „Alle + anzeigen“ klappt bis 20 auf. `/` heißt „Startseite“. Herkunft mit lesbaren Namen für bekannte Hosts + (google.* → Google, bing.com → Bing, instagram.com → Instagram, facebook.com/m.facebook.com/l.facebook.com → + Facebook, duckduckgo.com → DuckDuckGo, ecosia.org → Ecosia), sonst der Host; `''` → „Direkt oder unbekannt“. +- **Datenbasis** (ersetzt Banner-Liste und Sternchen-Fußnote): Ring mit `consentRate`, Text „61 % haben dem + Banner zugestimmt (2.410 von 3.950 Entscheidungen). Seitenaufrufe zählen wir vollständig; Besucher, + Kontakt-Aktionen und Zeit pro Seite (*) nur bei Zustimmung.“ Ohne Entscheidungen: „Noch hat niemand über das + Banner entschieden.“ +- **Heatmap-Karte**: kleines statisches Vorschaubild (CSS-Verlauf, keine echte Website), Satz, Knopf „Öffnen“ + wechselt in den Reiter Heatmap. + +### 2.2 Reiter Heatmap und Einstellungen, andere Zustände + +- **Heatmap**: die bestehende Heatmap in Geräterahmen (`renderAnalyseHeat`, `geraete.js/.css`, iframes, + postMessage-Zähler, 20-s-Rückfall „In neuem Tab öffnen“) unverändert im Verhalten, optisch im Cockpit-Stil + (Seitenauswahl und Computer/Tablet-Schalter in einer Leiste). Die iframes laden erst, wenn der Reiter + geöffnet wird. +- **Einstellungen**: Karte „Status“ (aktiv seit `activatedAt`, Banner-Farbe als Farbfeld, Fassung + `avvVersion`), Karte „Unterlagen“ (Links AVV und Leistungsbeschreibung aus `legal`), Karte „Analyse + ausschalten“ (`renderAnalyseOff`, Texte und Ablauf unverändert). +- **Einschalten** (`renderAnalyseActivation`), **gesperrt**, **nicht verfügbar**, **unbekannt**, **noch nicht + freigeschaltet**: gleicher Cockpit-Rahmen (Kopfleiste ohne Zeitraum und Reiter). Alle Texte, Links, Häkchen, + Farbwahl und Banner-Vorschau bleiben **wörtlich** wie heute (Rechtsrunde 01./02.10.). +- **Laden**: Platzhalter-Kacheln in der Form der echten Kacheln statt „Lade…“. **Fehler 503**: Karte + „Auswertung gerade nicht erreichbar“ mit Knopf „Neu laden“. + +### 2.3 Klartext-Regeln („Das Wichtigste“) + +Reine Funktion `summarize(stats)` → `{ lead, items: [{ icon, text }] }` in `public/analyse-view.js` +(ohne DOM, Node-testbar). Zahlen deutsch formatiert. Nur bei `totals.pv >= 30`, sonst Leerzustand (2.1). + +**Satz** (`lead`): +- Vergleich möglich (2.4): „Deine Website wurde in den letzten N Tagen **X-mal** aufgerufen – **Y % mehr** / + **Y % weniger** als in den N Tagen davor.“ Bei |Veränderung| < 5 %: „… – etwa so oft wie in den N Tagen + davor.“ Bei kleinen Zahlen (2.4) absolut: „… – **12 Aufrufe mehr** als in den N Tagen davor.“ +- Kein Vergleich: „Deine Website wurde in den letzten N Tagen **X-mal** aufgerufen.“ Liegt `firstDay` im + Zeitraum: „Seit dem Start der Analyse am TT.MM. wurde deine Website **X-mal** aufgerufen.“ + +**Hinweise** (`items`, in dieser Reihenfolge, nur wenn die Bedingung erfüllt ist, höchstens 4): +1. Kontakte (`contacts >= 1`): „N-mal hat dich jemand direkt kontaktiert, meist per Telefon (96).“ + bei + Vergleich und Vorwert ≥ 1: „Das sind 45 mehr als zuvor.“ / „… weniger …“ / „Genauso oft wie zuvor.“ + Bei 0 Kontakten und `visitors >= 30`: „Von den Besuchern mit Zustimmung hat noch niemand über die Website + angerufen oder geschrieben.“ (Kontakt-Aktionen zählen nur bei Zustimmung.) +2. Wochentag (mindestens 7 Tage mit Daten im Zeitraum und `pv >= 70`): Ø Aufrufe je Wochentag über die Tage + ab `firstDay`; stärkster Wochentag, wenn sein Schnitt ≥ 1,2 × Gesamtschnitt: „Samstag ist im Schnitt dein + stärkster Tag.“ Zusätzlich Spitzentag, wenn ≥ 1,8 × Tagesschnitt: „Spitzentag war Sa., 19.09. mit 371 + Aufrufen.“ (beides in einem Hinweis, wenn beides zutrifft). +3. Herkunft (erster Eintrag in `refs` mit Host, Anteil ≥ 20 % der Herkunfts-Summe): „Die meisten, die über + einen Link kamen, kommen von Google (880).“ +4. Gerät (`m` oder `d` ≥ 55 % der Geräte-Summe): „66 % der Aufrufe kommen vom Handy.“ / „… vom Computer.“ +5. Seite (`pages[0]`): „Am meisten angesehen: die Startseite, danach /preise (1.310).“ bzw. „Am meisten + angesehen: /preise (1.310 Aufrufe).“ + +### 2.4 Vergleich und Veränderungs-Chips + +- Vergleich möglich, wenn `previous.complete` und `previous.totals.pv >= 20`. Sonst kein Chip; Kacheln zeigen + „Vergleich ab TT.MM.“ mit TT.MM. = `firstDay` + (2 × range − 1) Tage; ohne `firstDay` gar nichts. Fehlt + `previous` ganz (alter Collector), wie „kein Vergleich“ ohne Datum. +- Funktion `delta(cur, prev, { kind })` → `{ dir: 'up'|'down'|'flat', text, aria }`: + - Zählwerte: Prozent (ganzzahlig, „▲ 14 %“), wenn `prev >= 100` und `cur >= 100`; sonst absolut („▲ 12“, + „▼ 3“). Gleich → „± 0“ (`flat`). |Prozent| < 1 → `flat` „± 0 %“. + - Sekunden: absolut, „▲ 6 s“ / „▼ 1:05“ (ab 60 s als m:ss). + - `aria`: „plus 14 Prozent gegenüber dem Zeitraum davor“ usw. Farbe nie alleiniger Träger (Pfeile ▲▼). +- Farben: mehr = türkis, weniger = rosé, gleich = grau. Bewusst **keine** Wertung „gut/schlecht“ (weniger + Zeit pro Seite kann gut sein). + +### 2.5 Optik („Cockpit“) + +- Alles unter einem Wurzelelement `.an` mit eigenen CSS-Variablen; außerhalb ändert sich nichts. + Akzent `--an-acc: #2dd4bf`, stark `#5eead4`, weich `rgba(45,212,191,.1)`; weniger `#fda4af`; + Karten `rgba(14,16,20,.92)`, Rand `rgba(255,255,255,.09)`, Radius 16 px, Abstände 14 px. +- Kartentitel einheitlich: 13 px, fett, Großbuchstaben mit Sperrung, gedämpft; Zahlen `tabular-nums`. +- „Das Wichtigste“: Verlauf türkis → violett, türkiser Rand, Satz 21 px. +- Desktop: Raster wie Entwurf A (4 Kacheln; Diagramm 1,65 : 1 Kontakte; 2 × 2 Karten). Diagramm und + Kontaktkarte gleich hoch, das Diagramm füllt die Höhe. +- Handy (≤ 880 px, App-Ansicht): Kopfleiste zweizeilig, Zeitraum-Schalter volle Breite, Kacheln 2 × 2, Hinweise + einspaltig, alle Karten untereinander. Keine waagrechte Seiten-Scrollleiste. +- `prefers-reduced-motion`: keine Übergänge. + +## 3. Code-Aufteilung + +`public/funktionen.js` (980 Zeilen) wird entlastet: +- **`public/analyse.js`** (neu): gesamte Analyse-Oberfläche – `initAnalyse`, `renderAnalyseActivation`, + `renderAnalyseOff`, Übersicht, Heatmap, Einstellungen. `app.js` lädt `/analyse.js` für die Ansicht `analyse`. +- **`public/fn-util.js`** (neu): `esc`, `safeUrl`, `request` – bisher lokal in `funktionen.js`, jetzt von + beiden Modulen importiert. +- **`public/analyse-view.js`**: bekommt die reinen Funktionen `summarize`, `delta`, `fmtDuration`, + `contactRate`, `compareFrom`, `refName`, `weekdayStats` sowie `analyseOf`, `analyseState`, + `pickAnalyseProject` (bisher in `funktionen.js`, von der Kachel und der Analyse gebraucht). +- **`public/analyse.css`** (neu, in `dashboard.html` eingebunden): alle `.an`-Regeln; die alten + `.an-*`-Regeln in `funktionen.css` entfallen, soweit sie nur die Analyse betreffen. +- Kachel „Analyse“ auf der Funktionen-Seite bleibt in `funktionen.js` (unverändert im Verhalten). +- Bestehende Element-IDs, die Tests und Abläufe nutzen, bleiben: `#analyse-body`, `#an-stage`, `#an-preview`, + `#an-avv`, `#an-on`, `#an-error`, `#an-off-*`, `#an-heat-page`. + +## 4. Tests + +- **Collector** (`analytics/test/stats.test.js`): `previous` mit richtigen Tagen und Summen; `complete` + false/true je nach `firstDay`; `firstDay` aus `daily` und Rohdaten; `series` mit visitors/contacts/avgSec; + `avgSec`/`avgScroll` null ohne en; Vorzeitraum über eine Zeitumstellung (Ende Oktober) lückenlos und ohne + Überlappung; nichts doppelt (verdichtet + live). Vertragstest `test/analyticsContract.test.js` um die neuen + Felder ergänzen. +- **Portal, rein** (`test/analyseInsights.test.js`, neu): `summarize` für Wachstum, Rückgang, gleich, kleine + Zahlen, ohne Vergleich, neu gestartet, zu wenig Daten, ohne Kontakte, Wochentag/Spitzentag, Herkunft, Gerät, + höchstens 4 Hinweise; `delta` (Prozent/absolut/flat/Sekunden/aria); `compareFrom`; `contactRate`; + `refName`; HTML-Maskierung aller Werte, die aus Daten kommen (Seitenpfade, Hosts). +- **Portal, bestehend**: `funktionenTiles.test.js` und `analyseView.test.js` auf `analyse.js`/`fn-util.js` + umstellen; alle bisherigen Text-Prüfungen (Einschalten, Ausschalten) laufen unverändert weiter. + `analyseRoutes.test.js`: `activatedAt` in der Ansicht. +- **Browser** (`test/ui/analyse.ui.mjs`, erweitert; Playwright mit gemockter API): Desktop 1440 und Handy 402 × + Zustände aktiv (volle Daten), aktiv neu (ohne Vergleich), wenig Daten, Dev-Modus, Einschalten, fehlende + Angaben, 503; Reiterwechsel; Heatmap-iframes erst nach Reiterwechsel; keine `pageerror`; keine waagrechte + Scrollleiste. Screenshots nach `/root/reports/analyse-ueberarbeitung-2026-10-04/screens/`. +- Gesamte Suiten: `npm test` (Portal) und `analytics: npm test` grün; Node 20 (Portal) und Node 24 (Collector). + +## 5. Livegang + +1. Nutzer pusht `feature/analyse-uebersicht` nach `main` (mein Push nach `main` ist im Auto-Modus gesperrt). +2. `deploy.sh` startet Portal und – weil `analytics/` geändert – den Collector neu. Keine Schema-Änderung, + keine `.env`-Änderung. +3. Prüfung durch mich: Collector `/a/health`, Stats-Abruf einer echten aktiven Website im Container + (neue Felder vorhanden, alte unverändert), Portal-Bundle enthält `analyse.js`, Ansicht lädt ohne Fehler. +4. Nutzer testet live mit einer Website mit aktiver Analyse. + +Rückweg: Portal und Collector sind gegenseitig abwärtskompatibel (alte Oberfläche ignoriert neue Felder, neue +Oberfläche kommt ohne `previous` aus); Zurückrollen = Revert-Commit auf `main`. + +## Nicht in diesem Projekt + +Besucher-Details, Website-Check, Bericht per Mail (U1); Änderungen an Messskript, Banner und Rechtstexten (U4); +WOMS; Anpassen der Banner-Farbe nach dem Einschalten; Export. From c1dada31895624d0971a61008198aea77ebd6baa Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Sun, 4 Oct 2026 19:44:10 +0000 Subject: [PATCH 009/105] =?UTF-8?q?Plan:=20Analyse-=C3=9Cbersicht=20neu=20?= =?UTF-8?q?(Cockpit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../plans/2026-10-04-analyse-uebersicht.md | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-04-analyse-uebersicht.md diff --git a/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md b/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md new file mode 100644 index 0000000..707f69b --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md @@ -0,0 +1,456 @@ +# Analyse-Übersicht neu („Cockpit“) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Analyse-Seite im Kundenportal zeigt eine Übersicht im „Cockpit“-Stil: Klartext-Zusammenfassung, Kennzahlen mit Vorzeitraum-Vergleich und Mini-Verlauf, Kontakt-Aktionen, Datenbasis; Heatmap und Einstellungen in eigenen Reitern. + +**Architecture:** Der Collector (`analytics/src/stats.js`) liefert zusätzlich Vorzeitraum, `firstDay`, Tageswerte und Durchschnitte; der Portal-Server reicht das durch. Im Browser: reine Funktionen (`analyse-view.js`), reine HTML-Bausteine (`analyse-cards.js`), DOM-Verdrahtung (`analyse.js`), Stil (`analyse.css`). Die Analyse-Oberfläche zieht aus `funktionen.js` aus. + +**Tech Stack:** Node 24 + `node:sqlite` (Collector), Node 20 + Express (Portal), Vanilla-JS-ES-Module im Browser, `node --test`, Playwright (`playwright-core` aus `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core`). + +**Spec:** `docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md` + +## Global Constraints + +- Arbeitsort: Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu`, Branch `feature/analyse-uebersicht`. `node_modules` ist ein Symlink auf `../build/node_modules` und bleibt untracked: **nie `git add -A`**, immer Dateien einzeln adden. +- Keine neuen Abhängigkeiten. Keine Änderung an `analytics/client/*`, `public/rechtliches/*`, Banner, AVV, Leistungsbeschreibung, Datenschutztexten, WOMS, Schema, `.env`. +- Nur vorhandene Daten (Spec U4). Klartext nur durch feste Regeln im Browser (U5). +- Texte der Zustände Einschalten und Ausschalten bleiben **wörtlich** wie heute (bestehende Tests in `test/funktionenTiles.test.js` prüfen sie). +- Neue Texte: Deutsch, Du-Form klein („du“, „deine“), Zahlen `de-DE` („6.930“, „7,8“). +- Element-IDs bleiben: `#analyse-body`, `#an-stage`, `#an-preview`, `#an-avv`, `#an-on`, `#an-error`, `#an-color`, `#an-hint`, `#an-off`, `#an-off-*`, `#an-heat-page`. +- Alle Werte aus Daten (Seitenpfade, Hosts, Projektnamen) werden in HTML maskiert (`esc`). +- Akzent nur unter `.an`: `--an-acc: #2dd4bf`, `--an-acc-strong: #5eead4`, `--an-acc-soft: rgba(45,212,191,.1)`, weniger `#fda4af`, Karte `rgba(14,16,20,.92)`, Rand `rgba(255,255,255,.09)`, Radius 16 px, Abstand 14 px. +- Reiter-Schlüssel: `'uebersicht' | 'heatmap' | 'einstellungen'`. Zeiträume: 7, 30, 90. +- Portal-Tests laufen auf Node 20 (Produktion) und 24; Collector-Tests auf Node 24. +- Commit-Nachrichten enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. + +## Review Focus + +1. Antwort eines **alten Collectors** ohne `previous`, `firstDay`, `from/to` und ohne `series[].visitors` (Deploy-Reihenfolge): Seite rendert ohne Chips und ohne Fehler, Mini-Verläufe nur, wo Werte da sind → Test in Task 4. +2. **Website ohne einen einzigen Aufruf** (alles 0, `consentRate` 0, `avgSec` null): keine `NaN`/`Infinity`/`undefined` im HTML, Leerzustand im Block „Das Wichtigste“, Ring 0 % → Test in Task 4. +3. **Seitenpfad oder Host mit HTML** (`/<img src=x onerror=alert(1)>`, Host `a"b.de`): überall maskiert (Hinweise, Ranglisten, Heatmap-Auswahl) → Tests in Task 3 und Task 4. +4. **Schnelles Umschalten** von Zeitraum/Website: eine langsame ältere Antwort überschreibt die neuere nicht → Playwright-Check mit verzögerter Antwort in Task 5. +5. **Sehr langer Pfad** auf dem Handy (402 px): keine waagrechte Scrollleiste, Text gekürzt → Playwright-Check in Task 5. + +--- + +### Task 1: Collector liefert Vorzeitraum, firstDay, Tageswerte, Durchschnitte; Portal liefert activatedAt + +**Files:** +- Modify: `analytics/src/stats.js` (gesamte Datei, 69 Zeilen) +- Modify: `analytics/test/stats.test.js` (Test „Stats-Endpunkt: Form …“ ab Zeile 118 anpassen, neue Tests anhängen) +- Modify: `test/analyticsContract.test.js:42-50` +- Modify: `server/routes/funktionen.js:51-75` (`analyseView`) +- Modify: `test/analyseRoutes.test.js:115` (+ neuer Test) + +**Interfaces:** +- Produces: Antwort von `computeStats(db, site, range, now)` (und damit `GET /a/admin/stats/:key` und `GET /api/funktionen/analyse/stats`) mit den Feldern aus Spec Abschnitt 1: + `range`, `from`, `to`, `firstDay`, `totals { pv, visitors, contacts, consentRate, engaged, avgSec, avgScroll }`, `series [{ day, pv, visitors, contacts, avgSec }]`, `pages`, `refs`, `countries`, `devices`, `contacts`, `banner`, `previous { from, to, complete, totals {…gleich…}, series […gleich…], contacts [{key,count}] }`. +- Produces: `p.analyse.activatedAt` (ISO-String oder `null`) in `GET /api/funktionen`. + +- [ ] **Step 1: Failing Tests im Collector schreiben** (`analytics/test/stats.test.js`, Helfer `mk`, `admin`, `KEY`, `NOW` = 10.10.2026 12:00 Berlin, `D`, `H` existieren) + +Bestehenden Test „Stats-Endpunkt: Form …“ anpassen: +```js +assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'totals']); +assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4, engaged: 1, avgSec: 5, avgScroll: 5 }); +assert.deepEqual(j.series[5], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 0, avgSec: 5 }); +assert.deepEqual(j.series[6], { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1, avgSec: null }); +assert.equal(j.from, '2026-10-04'); assert.equal(j.to, '2026-10-10'); assert.equal(j.range, 7); +``` +Neue Tests: +```js +test('Stats: Vorzeitraum = die range Kalendertage davor, complete nach firstDay', async () => { + // pv am 30.09. (im Vorzeitraum 27.09.–03.10.), en am 01.10., ct am 02.10., pv am 26.09. (vor dem Vorzeitraum) + // erst ohne 26.09.: firstDay '2026-09-30', complete false; dann mit 26.09.: firstDay '2026-09-26', complete true + assert.equal(j.previous.from, '2026-09-27'); assert.equal(j.previous.to, '2026-10-03'); + assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[3].day, '2026-09-30'); + assert.equal(j.previous.totals.pv, 1); // 26.09. zählt nicht mit + assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]); + assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer +}); +test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => { /* '' ohne Ereignisse; nach runNightly weiterhin gleich */ }); +test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => { + // zwei en am selben Tag: sec 30/scroll 50 und sec 91/scroll 100 → avgSec 61 (60,5 gerundet), avgScroll 75, engaged 2 +}); +test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => { + const NOV = Date.UTC(2026, 10, 10, 10); // range 30 + // series[0].day '2026-10-12', series[29].day '2026-11-10', previous.series[29].day '2026-10-11', previous.series[0].day '2026-09-12' + // alle 60 Tage verschieden +}); +test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { /* Ereignis im Vorzeitraum vor und nach runNightly: previous.totals.pv bleibt 1 */ }); +``` +Die Ereignisse der Tests mit `ev({ ts, type, … })` einfügen; `runNightly(db, { now: NOW })` verdichtet Tage < heute. + +- [ ] **Step 2: Tests laufen lassen, sie schlagen fehl** + +Run: `cd analytics && node --test test/stats.test.js` +Expected: FAIL (fehlende Felder `previous`, `firstDay` …) + +- [ ] **Step 3: `computeStats(db, site, range, now = Date.now())` in `analytics/src/stats.js` umbauen** + +Aufteilen in eine Sammel-Funktion für eine beliebige Tagesliste und eine Formungs-Funktion: +- `dayList(endDay, range)` bildet Kalendertage per `Date.UTC(y, m-1, d-i)` (wie heute). Aktueller Zeitraum: `dayList(dayOf(now), range)`. Vorzeitraum: `dayList(<Tag vor days[0]>, range)` – kalendarisch, nie `now - range*DAY_MS`. +- `collect(db, site, days, now)`: `daily` für `day IN days` und noch nicht in `agg_state` stehende Tage live aus `events_raw` (Abfrage begrenzt auf Mitternacht UTC des ersten Tags − 2 Tage bis letzter Tag + 2 Tage und `ts <= now`, dann nach `dayOf(ts)` gefiltert; `aggregateRows` aus `jobs.js` wie heute). Ergebnis je Tag: `pv_total`, `visitors`, `ct_kind`-Summe, `en_count`, `en_sec_sum`, `scroll_sum` sowie die Top-Dimensionen über alle Tage. +- Durchschnitte: `avgSec = Math.round(en_sec_sum / en_count)`, `avgScroll = Math.round(scroll_sum / en_count)`, beide `null` bei `en_count = 0`; `engaged = en_count`. +- `firstDay`: Minimum aus `SELECT MIN(day) FROM daily WHERE site = ?` und `dayOf(SELECT MIN(ts) FROM events_raw WHERE site = ?)`, `''` wenn beides fehlt. `previous.complete = firstDay !== '' && firstDay <= previous.from`. +- `pages`, `refs`, `countries`, `devices`, `contacts`, `banner`, `consentRate` bleiben exakt wie bisher (nur für den aktuellen Zeitraum); `previous.contacts` = `top(ct_kind)` des Vorzeitraums. + +- [ ] **Step 4: Collector-Tests grün** + +Run: `cd analytics && npm test` +Expected: PASS (bisher 137 Tests + neue), 0 fail + +- [ ] **Step 5: Vertragstest ergänzen** (`test/analyticsContract.test.js` nach Zeile 50) + +```js +assert.equal(stats.range, 7) +assert.equal(stats.previous.series.length, 7) +assert.ok('firstDay' in stats && 'avgSec' in stats.totals) +assert.ok(stats.series.every((d) => 'visitors' in d && 'contacts' in d && 'avgSec' in d)) +``` +Run: `node --test test/analyticsContract.test.js` → PASS + +- [ ] **Step 6: Failing Test für activatedAt** (`test/analyseRoutes.test.js`) + +Zeile 115: erwartetes Objekt um `activatedAt: null` ergänzen. Im Test nach erfolgreichem `activate` (Zeile ~128–135): `assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/)`. +Run: `node --test test/analyseRoutes.test.js` → FAIL + +- [ ] **Step 7: `analyseView` in `server/routes/funktionen.js` gibt `activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null` zurück** + +Run: `node --test test/analyseRoutes.test.js` → PASS + +- [ ] **Step 8: Gesamte Portal-Suite** + +Run: `npm test` +Expected: PASS, 0 fail (Ausgangslage 2259 pass) + +- [ ] **Step 9: Commit** + +```bash +git add analytics/src/stats.js analytics/test/stats.test.js test/analyticsContract.test.js server/routes/funktionen.js test/analyseRoutes.test.js +git commit -m "Analyse: Vorzeitraum, firstDay, Tageswerte und Durchschnitte in der Statistik; activatedAt in der Ansicht" +``` + +--- + +### Task 2: Analyse-Oberfläche zieht aus funktionen.js aus (ohne sichtbare Änderung) + +**Files:** +- Create: `public/fn-util.js` +- Create: `public/analyse.js` +- Modify: `public/funktionen.js` (Zeilen 6–7 Importe, 28–61 Helfer, 101–110 Analyse-Zustand, 611–980 Analyse-Abschnitt) +- Modify: `public/analyse-view.js` (Zustand und Auswahl-Helfer dazu) +- Modify: `public/app.js:1920` +- Modify: `test/funktionenTiles.test.js:8-16`, `test/analyseView.test.js:50-59` + +**Interfaces:** +- Produces: `public/fn-util.js` exportiert `esc(value): string`, `safeUrl(url): string`, `request(path, { method, body, raw }): Promise<object>` (unverändert aus `funktionen.js` verschoben). +- Produces: `public/analyse-view.js` exportiert zusätzlich `analyseOf(project)`, `analyseState(a)`, `pickAnalyseProject(projects)` (verschoben) und `export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }` (gemeinsamer Zustand für Kachel und Analyse; ersetzt das bisherige `analyseSel` in `funktionen.js`). +- Produces: `public/analyse.js` exportiert `initAnalyse(root, { projectId })`, `renderAnalyseActivation(stage, project, reload)`, `renderAnalyseOff(box, projectId, reload)` (Verhalten unverändert). +- Importstil: `funktionen.js` importiert absolut (`'/fn-util.js'`, `'/analyse-view.js'`), `analyse.js` und `analyse-view.js` relativ (`'./fn-util.js'`, `'./analyse-view.js'`, `'./geraete.js'`) – so lassen sie sich in Node direkt importieren und sind im Browser dieselben Module. + +- [ ] **Step 1: Tests auf die neue Aufteilung umstellen (schlagen fehl)** + +`test/funktionenTiles.test.js`: zusätzlich `.replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)`; `initAnalyse`, `renderAnalyseOff` aus `await import(fileUrl('analyse.js'))` statt aus `funktionen.mjs`. +`test/analyseView.test.js` („verdrahtet“): +```js +assert.match(app, /analyse: \(\) => import\('\/analyse\.js'\)/) +const an = readFileSync(new URL('../public/analyse.js', import.meta.url), 'utf8') +assert.doesNotMatch(an, /\bconfirm\(/) +const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') +assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/) +``` +Run: `node --test test/funktionenTiles.test.js test/analyseView.test.js` → FAIL + +- [ ] **Step 2: Verschieben** + +Abschnitt `/* --- Analyse --- */` (Zeile 611 bis Dateiende) und die Analyse-Importe aus `funktionen.js` nach `public/analyse.js`; `esc`/`safeUrl`/`request` nach `fn-util.js`; `analyseOf`/`analyseState`/`pickAnalyseProject` und `analyseSel` nach `analyse-view.js`. `funktionen.js` behält Kacheln und News und setzt beim Klick auf die Analyse-Kachel `analyseSel.projectId` (importiert). Vorher prüfen, wer die Funktionen sonst nutzt: `grep -a -rn "initAnalyse\|renderAnalyse\|analyseSel" public server test`. + +- [ ] **Step 3: `app.js:1920` lädt `/analyse.js`** + +`analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),` + +- [ ] **Step 4: Tests grün, Suite grün** + +Run: `npm test` → PASS, 0 fail + +- [ ] **Step 5: Sichtprüfung, dass nichts anders aussieht** + +Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task2` +Expected: alle Zeilen `OK`, keine `FAIL`. + +- [ ] **Step 6: Commit** + +```bash +git add public/fn-util.js public/analyse.js public/funktionen.js public/analyse-view.js public/app.js test/funktionenTiles.test.js test/analyseView.test.js +git commit -m "Analyse: Oberfläche in eigenes Modul analyse.js, gemeinsame Helfer in fn-util.js" +``` + +--- + +### Task 3: Reine Funktionen für Vergleich und Klartext + +**Files:** +- Modify: `public/analyse-view.js` +- Create: `test/analyseInsights.test.js` + +**Interfaces:** +- Consumes: Statistik-Form aus Task 1; `esc` aus `./fn-util.js`. +- Produces (alle in `public/analyse-view.js`, ohne DOM): + - `fmtNum(n): string` – `de-DE`, ganzzahlig. + - `fmtDuration(sec): string` – `'1:48'`, `'0:07'`; `null`/ungültig → `'–'`. + - `dayLabel(day): string` – `'2026-09-19'` → `'Sa., 19.09.'`. + - `periodLabel(from, to): string` – `'5. Sep. – 4. Okt.'` (Monatskürzel Jan., Feb., März, Apr., Mai, Juni, Juli, Aug., Sep., Okt., Nov., Dez.); leere Eingabe → `''`. + - `compareFrom(stats): { ok: boolean, from: string }` – `ok` wenn `stats.previous?.complete && stats.previous.totals.pv >= 20`; `from` = `'TT.MM.'` von `firstDay + (2 × range − 1)` Tagen nur wenn `previous` vorhanden, `!complete` und `firstDay`; sonst `''`. + - `delta(cur, prev, { kind = 'count' } = {}): { dir: 'up'|'down'|'flat', text: string, aria: string } | null` – Regeln Spec 2.4; `null`, wenn `cur` oder `prev` keine Zahl ist. + - `contactRate(contacts, visitors): number | null` – Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; `null` wenn `visitors < 30`. + - `refName(host): string` – Spec 2.1 (Google, Bing, Instagram, Facebook, DuckDuckGo, Ecosia, sonst Host; `''` → `'Direkt oder unbekannt'`). + - `weekdayStats(series, firstDay): { best: { weekday, name, avg } | null, avg: number, peak: { day, pv } | null } | null` – über Tage ≥ `firstDay` (alle, wenn `''`); `null` bei < 7 solchen Tagen; `best` nur wenn Schnitt ≥ 1,2 × Gesamtschnitt; `peak` nur wenn ≥ 1,8 × Gesamtschnitt. + - `summarize(stats): { lead: string, items: [{ icon: 'contact'|'day'|'source'|'device'|'page', html: string }] } | null` – Spec 2.3; `null` bei `totals.pv < 30`; `lead` und `html` sind fertiges, maskiertes HTML mit `<b>`. + - `chartModel(series, opts)` bekommt die Option `compare` (Array `{ pv }`): gemeinsame Skala über beide Reihen, zusätzliches Feld `prevLine` (Pfad, `''` ohne `compare`), `points[i].prev` (Zahl oder `null`). + +- [ ] **Step 1: Failing Tests schreiben** (`test/analyseInsights.test.js`, Testdaten-Helfer `stats(over)` baut eine vollständige Antwort mit 30 Tagen) + +```js +test('delta: Prozent ab 100, sonst absolut, flat, Sekunden, null', () => { + assert.deepEqual(delta(6930, 6080), { dir: 'up', text: '▲ 14 %', aria: 'plus 14 Prozent gegenüber dem Zeitraum davor' }) + assert.equal(delta(188, 143).text, '▲ 31 %') // beide ≥ 100 → Prozent + assert.equal(delta(45, 33).text, '▲ 12') + assert.equal(delta(30, 33).text, '▼ 3') + assert.deepEqual(delta(7, 7), { dir: 'flat', text: '± 0', aria: 'unverändert gegenüber dem Zeitraum davor' }) + assert.equal(delta(1004, 1000).text, '± 0 %') // |0,4 %| < 1 + assert.equal(delta(108, 114, { kind: 'sec' }).text, '▼ 6 s') + assert.equal(delta(200, 135, { kind: 'sec' }).text, '▲ 1:05') + assert.equal(delta(null, 3), null) +}) +test('fmtDuration, dayLabel, periodLabel, fmtNum', () => { + assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–') + assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.') + assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.') + assert.equal(fmtNum(6930), '6.930') +}) +test('compareFrom: ok, Datum bei neuer Website, nichts ohne previous', () => { + assert.deepEqual(compareFrom(stats()), { ok: true, from: '' }) + assert.deepEqual(compareFrom(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })), { ok: false, from: '18.11.' }) // range 30: +59 Tage + assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' }) + assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false) +}) +test('contactRate und refName', () => { + assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null) + assert.equal(refName('www.google.de'), 'Google'); assert.equal(refName('l.facebook.com'), 'Facebook'); assert.equal(refName(''), 'Direkt oder unbekannt'); assert.equal(refName('blog.example'), 'blog.example') +}) +test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => { /* Samstage 2× Schnitt → best.name 'Samstag'; ein Tag 3× Schnitt → peak */ }) +test('summarize: Wachstum mit Prozent', () => { + const s = summarize(stats()) + assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – <b>14 % mehr</b> als in den 30 Tagen davor.') + assert.equal(s.items[0].icon, 'contact') + assert.equal(s.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 45 mehr als zuvor.') + assert.ok(s.items.length <= 4) +}) +test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => { /* '– <b>9 % weniger</b> als', '– etwa so oft wie in den 30 Tagen davor.', '– <b>12 Aufrufe mehr</b> als' */ }) +test('summarize: ohne Vergleich und neu gestartet', () => { + // previous fehlt: '…in den letzten 30 Tagen <b>X-mal</b> aufgerufen.' + // firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website <b>X-mal</b> aufgerufen.' +}) +test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => { + assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null) + // contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' +}) +test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => { + // refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).' + // devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.' + // pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).' + // alle 5 Bedingungen wahr → Seite fällt weg +}) +test('summarize: Pfade und Hosts werden maskiert', () => { + const s = summarize(stats({ pages: [{ key: '/<img src=x>', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] })) + assert.ok(!JSON.stringify(s).includes('<img')) + assert.ok(JSON.stringify(s).includes('<img')) +}) +test('chartModel mit compare: gemeinsame Skala, prevLine', () => { /* top über beide Reihen; ohne compare prevLine '' und bestehender Test unverändert */ }) +``` + +- [ ] **Step 2: Tests laufen, schlagen fehl** + +Run: `node --test test/analyseInsights.test.js` → FAIL (Exporte fehlen) + +- [ ] **Step 3: Funktionen in `public/analyse-view.js` implementieren** (Signaturen oben; `esc` aus `'./fn-util.js'`; Wochentagsnamen Sonntag…Samstag; Spitzentag-Satz „Spitzentag war Sa., 19.09. mit 371 Aufrufen.“ hängt am Wochentag-Hinweis, wenn beides zutrifft) + +- [ ] **Step 4: Tests grün, Suite grün** + +Run: `node --test test/analyseInsights.test.js test/analyseView.test.js && npm test` → PASS + +- [ ] **Step 5: Commit** + +```bash +git add public/analyse-view.js test/analyseInsights.test.js +git commit -m "Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen" +``` + +--- + +### Task 4: HTML-Bausteine der Cockpit-Ansicht + +**Files:** +- Create: `public/analyse-cards.js` +- Create: `test/analyseCards.test.js` + +**Interfaces:** +- Consumes: Task 3 (`summarize`, `delta`, `compareFrom`, `contactRate`, `refName`, `fmtDuration`, `fmtNum`, `periodLabel`, `dayLabel`, `chartModel`, `barRows`, `countryName`, `flagEmoji`, `DEVICE_LABELS`), `esc`. +- Produces (alle geben HTML-Strings zurück, ohne DOM; relative Importe): + - `headerHtml({ project, usable, range, showRange, devMode, stamp })` – Kopfleiste `.an-top`; Auswahl `<select id="an-project">` nur bei `usable.length > 1`; Zeitraum-Knöpfe `.an-seg button[data-range]` nur bei `showRange`; Status „Analyse aktiv · Stand: heute, 19:42 Uhr“ (`stamp` = `'19:42'`) bzw. gelb „Im Dev-Modus wird nicht gemessen.“ + - `tabsHtml(tab)` – `role="tablist"`, Knöpfe `[data-tab="uebersicht|heatmap|einstellungen"]`, `aria-selected`. + - `overviewHtml(stats)` – setzt zusammen: `summaryHtml`, `kpisHtml`, Zeile `.an-row2` (`chartHtml` + `contactsHtml`), `rankCardHtml('seen', …)` + `rankCardHtml('who', …)`, `basisHtml` + `heatTeaserHtml`, Fußzeile „Länderdaten: IP-Geolocation by DB-IP“ (Link wie heute). + - `summaryHtml(stats)`, `kpisHtml(stats)`, `sparkHtml(values)`, `chartHtml(stats)`, `contactsHtml(stats)`, `rankCardHtml(card, stats, { tab, expanded } = {})` mit `card` `'seen'` (Reiter `pages` | `refs`) oder `'who'` (`devices` | `countries`), `basisHtml(stats)`, `heatTeaserHtml()` (Knopf `[data-tab="heatmap"]`), `skeletonHtml()`, `errorHtml(message)` (Knopf `#an-retry`), `settingsHtml(project)` (Status, Unterlagen, leere `<section class="an-card" id="an-off">`). + - Texte exakt aus Spec 2.1–2.4 (Leerzustand „Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.“, Quote „Auf 100 Besucher kommen 7,8 Kontakt-Aktionen (davor 6,5).“, „Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.“, Datenbasis-Sätze, „Noch hat niemand über das Banner entschieden.“, „Vergleich ab TT.MM.“). + +- [ ] **Step 1: Failing Tests schreiben** (`test/analyseCards.test.js`; Datenhelfer wie in Task 3) + +```js +test('overviewHtml: volle Daten – Kacheln mit Chips, Vorzeitraum-Linie, Kontakte, Quote', () => { + const h = overviewHtml(full) + assert.match(h, /▲ 14 %/); assert.match(h, /vs\. 6\.080/); assert.match(h, /class="an-prev"/) + assert.match(h, /Ø Zeit pro Seite/); assert.match(h, /1:48/); assert.match(h, /Ø 64 % gescrollt/) + assert.match(h, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\./) + assert.match(h, /61 % haben dem Banner zugestimmt \(2\.410 von 3\.950 Entscheidungen\)/) +}) +test('overviewHtml: alter Collector ohne previous/firstDay – keine Chips, kein Fehler', () => { + const h = overviewHtml({ totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: [{ day: '2026-09-17', pv: 20 }], pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 1, accept: 1, reject: 0 } }) + assert.doesNotMatch(h, /▲|▼|undefined|NaN/); assert.doesNotMatch(h, /class="an-prev"/) +}) +test('overviewHtml: Website ohne Aufrufe – Leerzustand, keine NaN/Infinity', () => { + const h = overviewHtml(zero) + assert.match(h, /Sobald ein paar mehr Besuche da sind/); assert.match(h, /Noch hat niemand über das Banner entschieden\./) + assert.doesNotMatch(h, /NaN|Infinity|undefined|null/) +}) +test('kpisHtml: neue Website zeigt "Vergleich ab 18.11."', () => {}) +test('contactsHtml: Arten mit 0 in beiden Zeiträumen entfallen, Leertext ohne Kontakte', () => {}) +test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20, Herkunft mit refName, Pfad maskiert', () => { + const h = rankCardHtml('seen', stats({ pages: [{ key: '/<img src=x>', count: 9 }] })) + assert.match(h, /<img src=x>/); assert.doesNotMatch(h, /<img src=x>/) +}) +test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => {}) +test('settingsHtml: aktiv seit TT.MM.JJJJ, Farbfeld, Fassung, Links AVV und Leistungsbeschreibung, #an-off', () => {}) +test('errorHtml und skeletonHtml', () => { assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /id="an-retry"/) }) +``` + +- [ ] **Step 2: Tests laufen, schlagen fehl** + +Run: `node --test test/analyseCards.test.js` → FAIL + +- [ ] **Step 3: `public/analyse-cards.js` implementieren** + +Klassen-Präfix `an-` (Karten `.an-card`, Kacheln `.an-kpi`, Chips `.an-delta.up|down|flat` mit `aria-label`, Mini-Verlauf `.an-spark`, Diagramm `.an-chart` mit `.an-area`, `.an-line`, gestrichelt `.an-prev` (nur wenn `compareFrom(stats).ok`), Legende „<range> Tage“ / „Zeitraum davor“ (zweiter Eintrag nur mit Vergleich), Punkte mit `data-i`, Daten für die Blase als `data-day`/`data-pv`/`data-prev` am Punkt). Mini-Verläufe: Seitenaufrufe `series[].pv`, Besucher `series[].visitors`, Kontakte `series[].contacts`, Zeit `series[].avgSec` (fehlende Werte als 0, ganz fehlende Reihe → kein Verlauf). Symbole als Inline-SVG (gleicher Stil wie `ICONS` in `funktionen.js`, `stroke`-basiert), keine Emoji. + +- [ ] **Step 4: Tests grün, Suite grün** + +Run: `node --test test/analyseCards.test.js && npm test` → PASS + +- [ ] **Step 5: Commit** + +```bash +git add public/analyse-cards.js test/analyseCards.test.js +git commit -m "Analyse: HTML-Bausteine der Cockpit-Übersicht" +``` + +--- + +### Task 5: Cockpit verdrahten – Rahmen, Reiter, Übersicht, Heatmap, Einstellungen, Stil + +**Files:** +- Modify: `public/analyse.js` +- Create: `public/analyse.css` +- Modify: `public/dashboard.html` (Zeile 24: `<link rel="stylesheet" href="/analyse.css">` nach `funktionen.css`) +- Modify: `public/funktionen.css:140-218` (Analyse-Abschnitt entfernen; was die Einschalten-Ansicht und Heatmap brauchen, zieht nach `analyse.css`) +- Modify: `test/ui/analyse.ui.mjs` + +**Interfaces:** +- Consumes: Task 2 (`analyseSel`, `request`, `esc`, `safeUrl`, bestehende Heatmap-Funktionen in `analyse.js`), Task 3, Task 4 (alle Bausteine). +- Produces: `initAnalyse(root, { projectId })` rendert `<div class="an">` mit `headerHtml` + (nur Zustand `active`) `tabsHtml` + `<div id="an-stage">`. Bestehende Exporte bleiben. + +Verhalten: +- Zustand `active`: Reiter aus `analyseSel.tab`. Übersicht: erst `skeletonHtml()`, dann `overviewHtml(stats)`; Fehler → `errorHtml(…)`, `#an-retry` lädt neu; Zähler `statsRequestId` verwirft veraltete Antworten (wie heute). Zeitraum-Knöpfe setzen `analyseSel.range` und laden nur die Übersicht neu. `#an-project` wechselt die Website (Reiter bleibt). Ranglisten-Umschalter und „Alle anzeigen“ rendern nur ihre Karte neu (`rankCardHtml`). `[data-tab="heatmap"]` im Teaser wechselt den Reiter. +- Diagramm-Blase: Zeigen/Tippen auf `.an-chart` wählt den nächsten Punkt (`data-i`) und zeigt `.an-tip` mit „Sa., 19.09.: 371 Aufrufe (davor 210)“ (ohne Vorwert ohne Klammer); verlassen → ausblenden. +- Heatmap-Reiter: `renderAnalyseHeat` wie heute; die Seitenliste kommt aus der zuletzt geladenen Statistik (sonst einmal `range=30` laden). iframes entstehen erst beim Öffnen des Reiters. +- Einstellungen-Reiter: `settingsHtml(project)`, danach `renderAnalyseOff(stage.querySelector('#an-off'), projectId, reload)`. +- Andere Zustände (Einschalten, gesperrt, nicht verfügbar, unbekannt, keine nutzbare Website): Kopfleiste ohne Zeitraum und ohne Reiter, Inhalt im Cockpit-Rahmen, Texte unverändert. `root.querySelector` kann in Node-Tests für andere Selektoren als `#an-stage` `null` liefern – überall `?.` verwenden. +- `analyse.css`: Werte aus Global Constraints und Spec 2.5; Raster Desktop wie Entwurf A (`/root/reports/analyse-ueberarbeitung-2026-10-04/entwurf-a-cockpit.png`); `.an-row2` mit `align-items: stretch`, Diagramm füllt die Höhe; ≤ 880 px: Kacheln 2 × 2, alles einspaltig, Reiter `overflow-x: auto`, Zeitraum volle Breite, lange Namen mit `text-overflow: ellipsis`; `@media (prefers-reduced-motion: reduce)` ohne Übergänge. + +- [ ] **Step 1: UI-Test erweitern (schlägt fehl)** – `test/ui/analyse.ui.mjs` + +Neue Szenarien in `SCENARIOS`/Statistik: `full` (30 Tage, Werte wie Fixture „Boulderhalle Überhang“: pv 6.930/Vorzeitraum 6.080, visitors 2.410/2.210, contacts 188/143, avgSec 108/114, avgScroll 64, consentRate 0,61, banner 3.950/2.410/1.540, `firstDay` vor dem Vorzeitraum), `neu` (`firstDay` im Zeitraum, `previous.complete` false), `wenig` (pv 12), `dev` (`devMode: true`), `alt` (ohne `previous`), `langerPfad` (Seite `/` + 120 Zeichen), `fehler` (Statistik 503). Prüfungen je Viewport 1440 × 1000 und 402 × 874: +``` +OK aktiv: Block "Das Wichtigste" mit "14 % mehr" +OK aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼ +OK Reiter Heatmap: vorher 0 iframes, nach Klick 2 iframes +OK Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit" +OK Zeitraum 7 → Anfrage range=7, Reiter bleibt Übersicht +OK verzögerte erste Antwort (range=30, 1,5 s) überschreibt die spätere (range=7) nicht +OK neu: "Vergleich ab" statt Chips, Satz "Seit dem Start der Analyse" +OK wenig: Leerzustand +OK dev: "Im Dev-Modus wird nicht gemessen." +OK alt: keine Chips, keine pageerror +OK fehler: "Auswertung gerade nicht erreichbar" + Neu laden +OK langer Pfad, 402 px: document.scrollingElement.scrollWidth <= innerWidth +OK Einschalten: Banner-Vorschau, Häkchen schaltet Knopf frei (wie bisher) +OK keine pageerror in allen Zuständen +``` +Screenshots je Zustand und Viewport nach dem Ordner aus `argv[2]`. +Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task5` → FAIL + +- [ ] **Step 2: `analyse.js` verdrahten, `analyse.css` schreiben, `dashboard.html` und `funktionen.css` anpassen** + +- [ ] **Step 3: UI-Test grün** + +Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task5` +Expected: nur `OK`-Zeilen. + +- [ ] **Step 4: Screenshots ansehen und mit Entwurf A vergleichen** + +Desktop- und Handy-Bilder von `full`, `neu`, Einschalten und Einstellungen öffnen (Read-Tool). Erwartet: Aufbau wie Entwurf A, Diagramm und Kontaktkarte gleich hoch, keine abgeschnittenen Texte, keine Überlappungen, Kacheln auf dem Handy 2 × 2. Abweichungen beheben und Step 3 wiederholen. + +- [ ] **Step 5: Suite grün** + +Run: `npm test` → PASS, 0 fail + +- [ ] **Step 6: Commit** + +```bash +git add public/analyse.js public/analyse.css public/dashboard.html public/funktionen.css test/ui/analyse.ui.mjs +git commit -m "Analyse: Cockpit-Übersicht mit Reitern Übersicht, Heatmap, Einstellungen" +``` + +--- + +### Task 6: Abschlussprüfung auf Node 20 und Bilder für den Nutzer + +**Files:** +- keine Code-Dateien (nur Prüfung); Bilder nach `/root/reports/analyse-ueberarbeitung-2026-10-04/screens/` + +- [ ] **Step 1: Portal-Suite auf Node 20** + +Run: `docker run --rm -v /home/webklar/apps/kundenbereich.webklar.com:/home/webklar/apps/kundenbereich.webklar.com:ro -w /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu node:20-alpine node --test test/*.test.js 2>&1 | tail -8` +Expected: `fail 0` (bekannte Ausnahme laut Notizen: nur `sharp`-abhängige Tests, falls `sharp` für musl fehlt – dann diese benennen, nicht ignorieren). + +- [ ] **Step 2: Collector-Suite auf Node 24** + +Run: `cd analytics && npm test` → `fail 0` + +- [ ] **Step 3: Bilder für den Nutzer** + +Run: `node test/ui/analyse.ui.mjs /root/reports/analyse-ueberarbeitung-2026-10-04/screens` +Expected: nur `OK`; Bilder `full`, `neu`, Heatmap, Einstellungen, Einschalten je Desktop/Handy liegen im Ordner. + +- [ ] **Step 4: Branch-Zustand festhalten** + +Run: `git status --short` (nur `?? node_modules`) und `git log --oneline origin/main..HEAD` (Spec, Plan, Tasks 1–5). + +--- + +## Livegang (nur mit OK des Nutzers, nicht Teil der Tasks) + +- **L1** Nutzer pusht: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu push origin feature/analyse-uebersicht:main` (vorher ggf. auf `origin/main` rebasen und `npm test` erneut). +- **L2** Prüfen: `build/` auf dem neuen Stand; `docker ps` zeigt `webklar-kundenbereich-server` und `webklar-analytics` frisch gestartet; `curl -s https://project.webklar.com/a/health`; im Container Stats einer echten aktiven Website abrufen (über `analyticsClient.getStats(siteKey, 30)` in `docker exec -w /app webklar-kundenbereich-server node -e …`) → Felder `previous`, `firstDay`, `totals.avgSec` vorhanden; `https://project.webklar.com/analyse.js` und `/analyse.css` 200. +- **L3** Nutzer öffnet Funktionen → Analyse einer Website mit aktiver Analyse und testet Reiter, Zeitraum, Handy. +- **L4** Notizen aktualisieren (Memory `analyse-uebersicht-neu.md`). From 37bec17d73cf4f868c80c9c2bfaf397b13b2114d Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Sun, 4 Oct 2026 19:49:55 +0000 Subject: [PATCH 010/105] Analyse: Vorzeitraum, firstDay, Tageswerte und Durchschnitte in der Statistik; activatedAt in der Ansicht Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- analytics/src/stats.js | 114 +++++++++++++++++++++++++-------- analytics/test/stats.test.js | 109 ++++++++++++++++++++++++++++++- server/routes/funktionen.js | 1 + test/analyseRoutes.test.js | 3 +- test/analyticsContract.test.js | 4 ++ 5 files changed, 202 insertions(+), 29 deletions(-) diff --git a/analytics/src/stats.js b/analytics/src/stats.js index e2033a9..1e72c90 100644 --- a/analytics/src/stats.js +++ b/analytics/src/stats.js @@ -1,69 +1,131 @@ // Statistik für das Portal: Vergangenheit aus `daily`, noch nicht verdichtete Tage live aus den Rohdaten. import { dayOf, aggregateRows } from './jobs.js'; -const DAY_MS = 86400 * 1000; const TOP = 20; -// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Berlin-Tag von `now`. -function dayList(now, range) { - const [y, m, d] = dayOf(now).split('-').map(Number); +// Kalendertag (YYYY-MM-DD) um `n` Tage verschoben, rein kalendarisch (UTC-Datumsrechnung, keine Zeitumstellung). +function shiftDay(day, n) { + const [y, m, d] = day.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10); +} + +// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Tag `endDay`. +function dayList(endDay, range) { const days = []; - for (let i = range - 1; i >= 0; i--) days.push(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10)); + for (let i = range - 1; i >= 0; i--) days.push(shiftDay(endDay, -i)); return days; } const top = (map) => [...map].map(([key, count]) => ({ key, count })).filter((e) => e.count > 0) .sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1)).slice(0, TOP); -export function computeStats(db, site, range, now = Date.now()) { - const days = dayList(now, range); +// Sammelt die Zähler für eine zusammenhängende Tagesliste: verdichtete Tage aus `daily`, +// Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten. Kein Tag zählt doppelt. +function collect(db, site, days, now) { const first = days[0]; + const last = days[days.length - 1]; const inRange = new Set(days); - const pvByDay = new Map(days.map((d) => [d, 0])); + const perDay = new Map(days.map((d) => [d, { pv: 0, visitors: 0, contacts: 0, en: 0, sec: 0, scroll: 0 }])); const dims = {}; - const bump = (dim, key, n) => { + const apply = (day, dim, key, n) => { + const t = perDay.get(day); + if (dim === 'pv_total') t.pv += n; + else if (dim === 'visitors') t.visitors += n; + else if (dim === 'ct_kind') t.contacts += n; + else if (dim === 'en_count') t.en += n; + else if (dim === 'en_sec_sum') t.sec += n; + else if (dim === 'scroll_sum') t.scroll += n; if (!dims[dim]) dims[dim] = new Map(); dims[dim].set(key, (dims[dim].get(key) || 0) + n); }; - const apply = (day, dim, key, n) => { - if (dim === 'pv_total') pvByDay.set(day, pvByDay.get(day) + n); - bump(dim, key, n); - }; - const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ?').all(site, first).map((r) => r.day)); - for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ?').all(site, first)) { + const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ? AND day <= ?').all(site, first, last).map((r) => r.day)); + for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ? AND day <= ?').all(site, first, last)) { if (inRange.has(r.day)) apply(r.day, r.dim, r.key, r.count); } - // Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten + // Ein Berliner Tag beginnt 22:00/23:00 UTC des Vortags: zwei Tage Rand reichen auf beiden Seiten, danach filtert dayOf + const [fy, fm, fd] = first.split('-').map(Number); + const [ly, lm, ld] = last.split('-').map(Number); + const lo = Date.UTC(fy, fm - 1, fd - 2); + const hi = Math.min(now, Date.UTC(ly, lm - 1, ld + 2)); const live = new Map(); - const from = now - (range + 1) * DAY_MS; for (const r of db.prepare('SELECT ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts >= ? AND ts <= ? ORDER BY ts') - .all(site, from, now)) { + .all(site, lo, hi)) { const d = dayOf(r.ts); if (!inRange.has(d) || done.has(d)) continue; if (!live.has(d)) live.set(d, []); live.get(d).push(r); } for (const [day, rows] of live) for (const [dim, key, n] of aggregateRows(rows)) apply(day, dim, key, n); + return { days, perDay, dims }; +} +const avg = (sum, n) => (n ? Math.round(sum / n) : null); + +// Formt gesammelte Zähler zu banner + totals + series (gleiche Form für aktuellen Zeitraum und Vorzeitraum). +function shape({ days, perDay, dims }) { const sum = (dim) => [...(dims[dim] || new Map()).values()].reduce((a, b) => a + b, 0); const banner = { shown: sum('bn_shown'), accept: sum('bn_accept'), reject: sum('bn_reject') }; const decided = banner.accept + banner.reject; - const contacts = top(dims.ct_kind || new Map()); + const en = sum('en_count'); return { + banner, totals: { pv: sum('pv_total'), // Summe der Tages-Besucher (kein tagesübergreifender Abgleich möglich: Salz wechselt täglich) visitors: sum('visitors'), contacts: sum('ct_kind'), consentRate: decided ? banner.accept / decided : 0, + engaged: en, + avgSec: avg(sum('en_sec_sum'), en), + avgScroll: avg(sum('scroll_sum'), en), + }, + series: days.map((day) => { + const t = perDay.get(day); + return { day, pv: t.pv, visitors: t.visitors, contacts: t.contacts, avgSec: avg(t.sec, t.en) }; + }), + }; +} + +// Frühester Berliner Tag mit Daten dieser Website ('' = keine): kleinster Tag aus daily und events_raw. +function firstDayOf(db, site) { + const fromDaily = db.prepare('SELECT MIN(day) AS d FROM daily WHERE site = ?').get(site)?.d || ''; + const ts = db.prepare('SELECT MIN(ts) AS t FROM events_raw WHERE site = ?').get(site)?.t; + const fromRaw = ts == null ? '' : dayOf(ts); + if (!fromDaily) return fromRaw; + if (!fromRaw) return fromDaily; + return fromDaily < fromRaw ? fromDaily : fromRaw; +} + +export function computeStats(db, site, range, now = Date.now()) { + const days = dayList(dayOf(now), range); + const cur = collect(db, site, days, now); + const { banner, totals, series } = shape(cur); + // Vorzeitraum: die `range` Kalendertage direkt vor dem aktuellen Zeitraum (kalendarisch, nie now - range * 24 h) + const prevDays = dayList(shiftDay(days[0], -1), range); + const prevCollected = collect(db, site, prevDays, now); + const prev = shape(prevCollected); + const firstDay = firstDayOf(db, site); + return { + range, + from: days[0], + to: days[days.length - 1], + firstDay, + totals, + series, + pages: top(cur.dims.pv_path || new Map()), + refs: top(cur.dims.pv_ref || new Map()), + countries: top(cur.dims.pv_country || new Map()), + devices: top(cur.dims.pv_device || new Map()), + contacts: top(cur.dims.ct_kind || new Map()), + banner, + previous: { + from: prevDays[0], + to: prevDays[prevDays.length - 1], + complete: firstDay !== '' && firstDay <= prevDays[0], + totals: prev.totals, + series: prev.series, + contacts: top(prevCollected.dims.ct_kind || new Map()), }, - series: days.map((day) => ({ day, pv: pvByDay.get(day) })), - pages: top(dims.pv_path || new Map()), - refs: top(dims.pv_ref || new Map()), - countries: top(dims.pv_country || new Map()), - devices: top(dims.pv_device || new Map()), - contacts, - banner, }; } diff --git a/analytics/test/stats.test.js b/analytics/test/stats.test.js index a9e07c2..d24d832 100644 --- a/analytics/test/stats.test.js +++ b/analytics/test/stats.test.js @@ -132,12 +132,15 @@ test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus dai const r = await a.get(`/a/admin/stats/${KEY}?range=7`); assert.equal(r.status, 200); const j = await r.json(); - assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'pages', 'refs', 'series', 'totals']); + assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'totals']); assert.equal(j.series.length, 7); assert.equal(j.series[0].day, '2026-10-04'); assert.equal(j.series[6].day, '2026-10-10'); assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 3, 1]); - assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4 }); + assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4, engaged: 1, avgSec: 5, avgScroll: 5 }); + assert.deepEqual(j.series[5], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 0, avgSec: 5 }); + assert.deepEqual(j.series[6], { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1, avgSec: null }); + assert.equal(j.from, '2026-10-04'); assert.equal(j.to, '2026-10-10'); assert.equal(j.range, 7); assert.deepEqual(j.pages, [{ key: '/a', count: 3 }, { key: '/b', count: 1 }]); assert.deepEqual(j.refs[0], { key: 'g.de', count: 3 }); assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }, { key: 'AT', count: 1 }]); @@ -161,6 +164,108 @@ test('Stats: gestern noch nicht verdichtet wird live gezählt, nie doppelt', asy a.close(); }); +test('Stats: Vorzeitraum = die range Kalendertage davor, complete nach firstDay', async () => { + const { db, ev } = mk(); + // Vorzeitraum zu range 7 am 10.10.: 27.09.–03.10. + ev({ ts: Date.UTC(2026, 8, 30, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 20, scroll: 40 }); + ev({ ts: Date.UTC(2026, 9, 2, 10), type: 'ct', path: '/', visitor: 'v2', kind: 'tel' }); + const a = await admin(db); + let j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json(); + assert.equal(j.firstDay, '2026-09-30'); + assert.equal(j.previous.complete, false); + // 26.09. liegt vor dem Vorzeitraum und darf nicht mitzählen + ev({ ts: Date.UTC(2026, 8, 26, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json(); + assert.equal(j.firstDay, '2026-09-26'); + assert.equal(j.previous.complete, true); + assert.equal(j.previous.from, '2026-09-27'); assert.equal(j.previous.to, '2026-10-03'); + assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[3].day, '2026-09-30'); + assert.equal(j.previous.totals.pv, 1); + assert.equal(j.previous.totals.engaged, 1); + assert.equal(j.previous.totals.avgSec, 20); + assert.equal(j.previous.totals.contacts, 1); + assert.deepEqual(j.previous.series[4], { day: '2026-10-01', pv: 0, visitors: 1, contacts: 0, avgSec: 20 }); + assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]); + assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer + assert.equal(j.totals.avgSec, null); + a.close(); +}); + +test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => { + const { db, ev } = mk(); + const a = await admin(db); + const first = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()); + let j = await first(); + assert.equal(j.firstDay, ''); + assert.equal(j.previous.complete, false); + // nur Rohdaten (heute) + ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + assert.equal((await first()).firstDay, '2026-10-10'); + // 40 Tage alt: nach dem Nachtlauf nur noch in daily (Rohdaten > 30 Tage gelöscht) + ev({ ts: NOW - 40 * D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + assert.equal((await first()).firstDay, '2026-08-31'); + runNightly(db, { now: NOW }); + assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw WHERE ts < ?').get(NOW - 31 * D).c, 0); + j = await first(); + assert.equal(j.firstDay, '2026-08-31'); + assert.equal(j.previous.complete, true); + a.close(); +}); + +test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => { + const { db, ev } = mk(); + const t = NOW - D; // 9.10. + ev({ ts: t, type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 30, scroll: 50 }); + ev({ ts: t + 1, type: 'en', path: '/a', vp: 'd', visitor: 'v2', sec: 91, scroll: 100 }); + const a = await admin(db); + const j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json(); + assert.equal(j.totals.engaged, 2); + assert.equal(j.totals.avgSec, 61); // 60,5 gerundet + assert.equal(j.totals.avgScroll, 75); + assert.equal(j.series[5].avgSec, 61); + assert.equal(j.series[4].avgSec, null); + // ohne en: beide null, engaged 0 + const leer = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); + assert.equal(leer.previous.totals.engaged, 0); + assert.equal(leer.previous.totals.avgSec, null); + assert.equal(leer.previous.totals.avgScroll, null); + a.close(); +}); + +test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => { + const NOV = Date.UTC(2026, 10, 10, 10); // range 30, Zeitumstellung am 25.10. liegt im Zeitraum + const { db } = mk(); + const a = await admin(db, () => NOV); + const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); + assert.equal(j.series[0].day, '2026-10-12'); + assert.equal(j.series[29].day, '2026-11-10'); + assert.equal(j.previous.series[29].day, '2026-10-11'); + assert.equal(j.previous.series[0].day, '2026-09-12'); + assert.equal(j.previous.from, '2026-09-12'); assert.equal(j.previous.to, '2026-10-11'); + const all = [...j.series, ...j.previous.series].map((s) => s.day); + assert.equal(all.length, 60); + assert.equal(new Set(all).size, 60); + a.close(); +}); + +test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { + const { db, ev } = mk(); + ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 1, 11), type: 'ct', path: '/', visitor: 'v1', kind: 'mail' }); + const a = await admin(db); + const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).previous; + const live = await get(); // noch nicht verdichtet: live aus den Rohdaten + assert.equal(live.totals.pv, 1); + runNightly(db, { now: NOW }); // jetzt aus daily + const verdichtet = await get(); + assert.equal(verdichtet.totals.pv, 1); + assert.deepEqual(verdichtet, live); + runNightly(db, { now: NOW + H }); + assert.deepEqual(await get(), live); + a.close(); +}); + test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle', async () => { const { db, ev } = mk(); for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' }); diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 7c0694a..35299fd 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -70,6 +70,7 @@ async function analyseView(customerId, projectId) { avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller, + activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null, } } diff --git a/test/analyseRoutes.test.js b/test/analyseRoutes.test.js index 2fabb7b..454f78d 100644 --- a/test/analyseRoutes.test.js +++ b/test/analyseRoutes.test.js @@ -112,7 +112,7 @@ test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', a const body = await get('/api/funktionen', { cookie }) const p = (id) => body.projects.find((x) => x.projectId === id) assert.equal(p('p1').features.news.state, 'off') - assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] } }) + assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] }, activatedAt: null }) assert.equal(p('p2').analyse.eligible, false) assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich') assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich) @@ -133,6 +133,7 @@ test('Aktivieren und Deaktivieren (Kunde)', async () => { const view = (await get('/api/funktionen', { cookie })).projects.find((x) => x.projectId === 'p1').analyse assert.equal(view.state, 'active') assert.equal(view.accentColor, '#112233') + assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/) const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }) assert.deepEqual(off.body, { ok: true, state: 'off' }) assert.equal(stops.length, 1) diff --git a/test/analyticsContract.test.js b/test/analyticsContract.test.js index 9d11b23..4df3a50 100644 --- a/test/analyticsContract.test.js +++ b/test/analyticsContract.test.js @@ -44,6 +44,10 @@ test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', as assert.equal(stats.totals.contacts, 1) assert.equal(stats.series.length, 7) assert.equal(stats.series.reduce((n, d) => n + d.pv, 0), 3) + assert.equal(stats.range, 7) + assert.equal(stats.previous.series.length, 7) + assert.ok('firstDay' in stats && 'avgSec' in stats.totals) + assert.ok(stats.series.every((d) => 'visitors' in d && 'contacts' in d && 'avgSec' in d)) assert.deepEqual(stats.pages, [{ key: '/', count: 2 }, { key: '/kontakt', count: 1 }]) assert.deepEqual(stats.refs, [{ key: '', count: 2 }, { key: 'google.com', count: 1 }]) assert.equal(stats.banner.accept, 1) From 97bff91a9994324dbf86f480231ab5815e231f18 Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Sun, 4 Oct 2026 19:53:33 +0000 Subject: [PATCH 011/105] Analyse: Zeitumstellungs-Test mit Abenduhr und Ereignissen, erkennt naives Vorzeitraum-Fenster Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- analytics/test/stats.test.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/analytics/test/stats.test.js b/analytics/test/stats.test.js index d24d832..3083502 100644 --- a/analytics/test/stats.test.js +++ b/analytics/test/stats.test.js @@ -234,8 +234,12 @@ test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async ( }); test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => { - const NOV = Date.UTC(2026, 10, 10, 10); // range 30, Zeitumstellung am 25.10. liegt im Zeitraum - const { db } = mk(); + // range 30 um 23:30 Berlin: Zeitumstellung am 25.10. liegt im Zeitraum. Spätabends wäre now - 30 * 24 h + // schon der 12.10. (00:30 Berlin) und ein naives Fenster würde sich überlappen; mittags fiele das nicht auf. + const NOV = Date.UTC(2026, 10, 10, 22, 30); + const { db, ev } = mk(); + ev({ ts: Date.UTC(2026, 9, 11, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 11.10. mittags: Vorzeitraum + ev({ ts: Date.UTC(2026, 9, 12, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 12.10. mittags: aktueller Zeitraum const a = await admin(db, () => NOV); const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); assert.equal(j.series[0].day, '2026-10-12'); @@ -246,6 +250,8 @@ test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappu const all = [...j.series, ...j.previous.series].map((s) => s.day); assert.equal(all.length, 60); assert.equal(new Set(all).size, 60); + assert.equal(j.previous.totals.pv, 1); assert.equal(j.previous.series[29].pv, 1); + assert.equal(j.totals.pv, 1); assert.equal(j.series[0].pv, 1); a.close(); }); From 9d1691cf6b04999e3356953a3f9b4fa7e4a525d9 Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Sun, 4 Oct 2026 19:57:27 +0000 Subject: [PATCH 012/105] =?UTF-8?q?Analyse:=20Oberfl=C3=A4che=20in=20eigen?= =?UTF-8?q?es=20Modul=20analyse.js,=20gemeinsame=20Helfer=20in=20fn-util.j?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- public/analyse-view.js | 13 +- public/analyse.js | 377 ++++++++++++++++++++++++++++++++ public/app.js | 2 +- public/fn-util.js | 22 ++ public/funktionen.js | 402 +---------------------------------- test/analyseView.test.js | 5 +- test/avvPage.test.js | 2 +- test/funktionenTiles.test.js | 8 +- test/termineView.test.js | 4 +- 9 files changed, 426 insertions(+), 409 deletions(-) create mode 100644 public/analyse.js create mode 100644 public/fn-util.js diff --git a/public/analyse-view.js b/public/analyse-view.js index bc6f612..7ea000c 100644 --- a/public/analyse-view.js +++ b/public/analyse-view.js @@ -1,4 +1,4 @@ -/* Reine Hilfsfunktionen der Analyse-Ansicht (funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */ +/* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */ // Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1 export function contrastOk(hex) { @@ -90,3 +90,14 @@ export function controllerLines(controller) { const c = controller || {} return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean) } + +/* Analyse-Zustand je Website (comingSoon = Schalter aus) und Auswahl */ +export const analyseOf = (project) => project.analyse || { comingSoon: true } +export const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked') + +export function pickAnalyseProject(projects) { + return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0] +} + +// Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js) +export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' } diff --git a/public/analyse.js b/public/analyse.js new file mode 100644 index 0000000..b18d7ca --- /dev/null +++ b/public/analyse.js @@ -0,0 +1,377 @@ +/* Analyse-Ansicht im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen). + initAnalyse(rootEl, { projectId }): je nach Zustand der Analyse Einschalten (off), Hinweis (disabled/blocked) oder Auswertung (active). + Reine Hilfsfunktionen und der gemeinsame Zustand: analyse-view.js. Styles: funktionen.css. + Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */ + +import { esc, safeUrl, request } from './fn-util.js' +import { analyseOf, pickAnalyseProject, analyseSel, bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from './analyse-view.js' +import { HEAT_DEVICES, deviceHtml, heatCaption } from './geraete.js' + +const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c'] +const HEX_RE = /^#[0-9a-f]{6}$/i +const nf = new Intl.NumberFormat('de-DE') +const KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.' + +export async function initAnalyse(root, { projectId } = {}) { + if (!root) return + if (projectId) analyseSel.projectId = projectId + const { projects = [] } = await request('/api/funktionen') + const usable = projects.filter((p) => !analyseOf(p).comingSoon) + if (!usable.length) { + root.innerHTML = '<div class="kb-panel kb-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</div>' + return + } + const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable) + analyseSel.projectId = project.projectId + root.innerHTML = ` + ${usable.length > 1 ? ` + <div class="fn-project kb-panel"> + <label for="an-project">Website</label> + <select id="an-project">${usable.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select> + </div>` : ''} + <div id="an-stage"></div>` + root.querySelector('#an-project')?.addEventListener('change', (ev) => { + analyseSel.projectId = ev.target.value + initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` }) + }) + const stage = root.querySelector('#an-stage') + const a = analyseOf(project) + const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` }) + if (a.state === 'active') renderAnalyseStats(stage, project, reload) + else if (a.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>' + else if (a.state === 'unknown') stage.innerHTML = '<div class="kb-panel"><p>Die Daten konnten gerade nicht geladen werden.</p></div>' + else if (!a.eligible) stage.innerHTML = `<div class="kb-panel"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button>' : ''}</p></div>` + else renderAnalyseActivation(stage, project, reload) +} + +function bannerPreview(color) { + const c = bannerColor(color) + const btn = `style="background:${c};border-color:${c};color:#fff"` + return ` + <div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website"> + <p class="an-bn-text">${esc(BANNER_PREVIEW_TEXT)}<span class="an-bn-link">Datenschutz</span></p> + <div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div> + </div>` +} + +const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.' + +// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt +function controllerBlock(controller) { + if (controller.ok !== true) return `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>` + return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>` +} + +export function renderAnalyseActivation(stage, project, reload) { + const a = analyseOf(project) + const legal = a.legal || {} + const controller = a.controller || { ok: false } + let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' + stage.innerHTML = ` + <div class="kb-panel an-panel"> + <h3>Analyse für ${esc(project.projectName)} einschalten</h3> + <p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.</p> + <p>Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.</p> + <h4 class="an-h4">Farbe des Banners</h4> + <div class="an-colors"> + <input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen"> + <div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div> + </div> + <p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p> + <div id="an-preview"></div> + ${controllerBlock(controller)} + <label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label> + <p class="error hidden" id="an-error" role="alert"></p> + <div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div> + </div>` + const input = stage.querySelector('#an-color') + const hint = stage.querySelector('#an-hint') + const paint = () => { + stage.querySelector('#an-preview').innerHTML = bannerPreview(color) + hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase()) + } + paint() + input.addEventListener('input', () => { color = input.value; paint() }) + stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() })) + const box = stage.querySelector('#an-avv') + const button = stage.querySelector('#an-on') + const error = stage.querySelector('#an-error') + box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true }) + button.addEventListener('click', async () => { + if (!box.checked || controller.ok !== true) return + button.disabled = true + error.classList.add('hidden') + try { + await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } }) + reload() + } catch (err) { + error.textContent = err.message + error.classList.remove('hidden') + button.disabled = !box.checked || controller.ok !== true + } + }) +} + +function lineChart(series) { + const m = chartModel(series) + if (!m.points.length) return '<p class="nw-empty">Noch keine Daten.</p>' + const total = m.points.reduce((sum, p) => sum + p.pv, 0) + const first = m.points[0].day + const last = m.points[m.points.length - 1].day + return ` + <svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title"> + <title id="an-chart-title">Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))} + ${m.yTicks.map((t) => `${esc(t.label)}`).join('')} + ${m.xLabels.map((l) => `${esc(l.label)}`).join('')} + + + ${m.points.length <= 31 ? m.points.map((p) => `${esc(p.day)}: ${esc(nf.format(p.pv))}`).join('') : ''} + ` +} + +function barList(title, items, labelOf, { html = false } = {}) { + const rows = barRows(items, labelOf) + return ` +
+

${esc(title)}

+ ${rows.length ? `
    ${rows.map((r) => ` +
  • ${html ? r.label : esc(r.label)}${esc(nf.format(r.count))}
  • `).join('')}
` : '

Noch keine Daten.

'} +
` +} + +const COUNTRY_ROW = (key) => `${flagEmoji(key) ? ` ` : ''}${esc(countryName(key))}` + +let statsRequestId = 0 +async function renderAnalyseStats(stage, project, reload) { + const myRequest = ++statsRequestId + const range = [7, 30, 90].includes(analyseSel.range) ? analyseSel.range : 30 + const pid = project.projectId + stage.innerHTML = ` +
${[7, 30, 90].map((r) => ``).join('')}
+

Lade…

+
+
+

Länderdaten: IP-Geolocation by DB-IP

` + stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) })) + renderAnalyseOff(stage.querySelector('#an-off'), pid, reload) + + const data = stage.querySelector('#an-data') + let stats + try { + stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`) + } catch (err) { + if (!stage.isConnected || myRequest !== statsRequestId) return + data.innerHTML = `` + return + } + if (!stage.isConnected || myRequest !== statsRequestId) return + const t = stats.totals || {} + const rate = Math.round((Number(t.consentRate) || 0) * 100) + const kpi = (label, value) => `
${label}${esc(value)}
` + const b = stats.banner || {} + data.innerHTML = ` +
${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}
+

Seitenaufrufe pro Tag

${lineChart(stats.series)}
+
+ ${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')} + ${barList('Herkunft', stats.refs, refLabel)} + ${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })} + ${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')} + ${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)} + ${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))} +
+

${esc(KPI_NOTE)}

` + renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || []) +} + +/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks. + Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl + per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */ +const heatSel = { big: 'd' } +const HEAT_TIMEOUT_MS = 20000 + +let geraeteCss = null +function loadGeraeteCss() { + if (!geraeteCss) { + geraeteCss = new Promise((resolve) => { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = '/geraete.css' + link.onload = link.onerror = () => resolve() + document.head.appendChild(link) + }) + } + return geraeteCss +} + +// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler +let heatListening = false +function onHeatMessage(ev) { + const d = ev.data + if (!d || d.wk !== 'heat') return + for (const fig of document.querySelectorAll('.hs-dev')) { + const frame = fig.querySelector('iframe') + if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue + clearTimeout(fig.heatTimer) + fig.dataset.state = d.state === 'ready' ? 'ready' : 'error' + setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n))) + } +} + +function setHeatCaption(fig, text) { + const cap = fig.querySelector('.hs-cap') + if (cap) cap.textContent = text +} + +// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website +function fitHeatDevice(fig) { + fig.heatResize?.disconnect() + const canvas = fig.querySelector('.dm-canvas') + const host = canvas?.parentElement + if (!host) return + const fit = (width) => { + if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6)) + } + fit(parseFloat(getComputedStyle(host).width) || host.clientWidth) + if (typeof ResizeObserver === 'function') { + fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width)) + fig.heatResize.observe(host) + } +} + +async function loadHeatDevice(fig, projectId, vp, path) { + const run = (fig.heatRun = (fig.heatRun || 0) + 1) + clearTimeout(fig.heatTimer) + fig.heatResize?.disconnect() + fig.dataset.state = 'loading' + fig.innerHTML = `
${esc(heatCaption('loading'))}
` + let url + try { + ;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } })) + } catch (err) { + if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) + return + } + await loadGeraeteCss() + if (run !== fig.heatRun || !fig.isConnected) return + if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error')) + fig.dataset.origin = new URL(url).origin + fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` }) + fitHeatDevice(fig) + // Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden + let loads = 0 + fig.querySelector('iframe').addEventListener('load', () => { + if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path) + }) + fig.heatTimer = setTimeout(() => { + if (run === fig.heatRun && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path) + }, HEAT_TIMEOUT_MS) +} + +// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher +async function showHeatFallback(fig, projectId, vp, path) { + fig.dataset.state = 'timeout' + const cap = fig.querySelector('.hs-cap') + if (!cap) return + cap.textContent = heatCaption('timeout') + try { + const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } }) + // Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt + if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` In neuem Tab öffnen`) + } catch { + /* Text reicht */ + } +} + +function renderAnalyseHeat(box, projectId, pages) { + if (!box) return + if (!pages.length) { + box.innerHTML = '

Heatmap

Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.

' + return + } + if (!heatListening) { + window.addEventListener('message', onHeatMessage) + heatListening = true + } + const big = heatSel.big === 't' ? 't' : 'd' + box.innerHTML = ` +
+

Heatmap

+
${['d', 't'].map((vp) => ``).join('')}
+
+

So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.

+
+ +
+
+
+
+
` + const stage = box.querySelector('.hs-stage') + const bigFig = box.querySelector('.hs-dev--big') + const phoneFig = box.querySelector('.hs-dev--phone') + const page = () => box.querySelector('#an-heat-page').value || '/' + const show = (fig, vp) => { + fig.heatReloads = 0 + loadHeatDevice(fig, projectId, vp, page()) + } + box.querySelector('#an-heat-page').addEventListener('change', () => { + show(bigFig, heatSel.big) + show(phoneFig, 'm') + }) + box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => { + if (b.dataset.vp === heatSel.big) return + heatSel.big = b.dataset.vp + box.querySelectorAll('.hs-switch button').forEach((x) => { + const on = x.dataset.vp === heatSel.big + x.classList.toggle('active', on) + x.setAttribute('aria-pressed', String(on)) + }) + stage.dataset.big = heatSel.big + show(bigFig, heatSel.big) + })) + heatSel.big = big + // Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite) + const start = () => { + show(bigFig, heatSel.big) + show(phoneFig, 'm') + } + if (typeof IntersectionObserver !== 'function') return start() + const io = new IntersectionObserver((entries) => { + if (!entries.some((e) => e.isIntersecting)) return + io.disconnect() + start() + }, { rootMargin: '300px 0px' }) + io.observe(stage) +} + +// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog +export function renderAnalyseOff(box, projectId, reload) { + box.innerHTML = ` +

Analyse ausschalten

+

Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.

+ +
` + const actions = box.querySelector('#an-off-actions') + const error = box.querySelector('#an-off-error') + box.querySelector('#an-off-ask').addEventListener('click', () => { + actions.innerHTML = 'Wirklich ausschalten? ' + actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload)) + actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => { + ev.currentTarget.disabled = true + try { + await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } }) + box.innerHTML = ` +

Analyse ausgeschaltet

+

Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

+
` + box.querySelector('#an-off-ok').addEventListener('click', () => reload()) + } catch (err) { + renderAnalyseOff(box, projectId, reload) + const e2 = box.querySelector('#an-off-error') + e2.textContent = err.message + e2.classList.remove('hidden') + } + }) + }) +} diff --git a/public/app.js b/public/app.js index ce23af1..02c9075 100644 --- a/public/app.js +++ b/public/app.js @@ -1917,7 +1917,7 @@ const viewLoaders = { chat: initChat } const viewRefreshers = { funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))), news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))), - analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))), + analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))), termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))), } const loadedViews = new Set() diff --git a/public/fn-util.js b/public/fn-util.js new file mode 100644 index 0000000..5b1ef11 --- /dev/null +++ b/public/fn-util.js @@ -0,0 +1,22 @@ +/* Gemeinsame Hilfen der Funktions-Module (funktionen.js, analyse.js): Escaping fuer Kundentexte, URL-Pruefung, API-Aufruf. */ + +export const esc = (value) => String(value ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +export const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '') + +export async function request(path, { method = 'GET', body, raw } = {}) { + const response = await fetch(path, { + method, + credentials: 'same-origin', + headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {}, + body: raw || (body ? JSON.stringify(body) : undefined), + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`)) + err.status = response.status + if (data.field) err.field = data.field + throw err + } + return data +} diff --git a/public/funktionen.js b/public/funktionen.js index 8ef3dbb..7a9e5c7 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -3,8 +3,8 @@ Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css. Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */ -import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js' -import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js' +import { esc, safeUrl, request } from '/fn-util.js' +import { analyseOf, analyseState, pickAnalyseProject, analyseSel } from '/analyse-view.js' const PRICE = '30 €' const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' @@ -25,10 +25,6 @@ const TYPES = [ ['combo', 'Foto + Text', 'beides', ''], ] -const esc = (value) => String(value ?? '') - .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') -const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '') - function fmtDay(value) { const d = new Date(value) if (!value || Number.isNaN(d.getTime())) return '' @@ -41,23 +37,6 @@ function berlinIsoDay(offsetDays = 0) { return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) } -async function request(path, { method = 'GET', body, raw } = {}) { - const response = await fetch(path, { - method, - credentials: 'same-origin', - headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {}, - body: raw || (body ? JSON.stringify(body) : undefined), - }) - const data = await response.json().catch(() => ({})) - if (!response.ok) { - const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`)) - err.status = response.status - if (data.field) err.field = data.field - throw err - } - return data -} - /* ------------------------------------------------------------ Zustand --- */ // Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten const selection = { projectId: '' } @@ -99,15 +78,9 @@ function newsTile(projects) { } /* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */ -const analyseOf = (project) => project.analyse || { comingSoon: true } -const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked') const DEV_HINT = 'Im Dev-Modus wird nicht gemessen.' const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.' -function pickAnalyseProject(projects) { - return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0] -} - function analyseTile(projects) { const target = pickAnalyseProject(projects) const a = target && analyseOf(target) @@ -607,374 +580,3 @@ function renderEditor(stage, project) { sync() return loadList() } - -/* -------------------------------------------------------------- Analyse --- */ -const analyseSel = { projectId: '', range: 30 } -const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c'] -const HEX_RE = /^#[0-9a-f]{6}$/i -const nf = new Intl.NumberFormat('de-DE') -const KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.' - -export async function initAnalyse(root, { projectId } = {}) { - if (!root) return - if (projectId) analyseSel.projectId = projectId - const { projects = [] } = await request('/api/funktionen') - const usable = projects.filter((p) => !analyseOf(p).comingSoon) - if (!usable.length) { - root.innerHTML = '
Die Analyse ist für deine Websites noch nicht freigeschaltet.
' - return - } - const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable) - analyseSel.projectId = project.projectId - root.innerHTML = ` - ${usable.length > 1 ? ` -
- - -
` : ''} -
` - root.querySelector('#an-project')?.addEventListener('change', (ev) => { - analyseSel.projectId = ev.target.value - initAnalyse(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

` }) - }) - const stage = root.querySelector('#an-stage') - const a = analyseOf(project) - const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

` }) - if (a.state === 'active') renderAnalyseStats(stage, project, reload) - else if (a.state === 'disabled') stage.innerHTML = '

Analyse ist abgeschaltet

Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

' - else if (a.state === 'unknown') stage.innerHTML = '

Die Daten konnten gerade nicht geladen werden.

' - else if (!a.eligible) stage.innerHTML = `

Analyse nicht verfügbar

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}

` - else renderAnalyseActivation(stage, project, reload) -} - -function bannerPreview(color) { - const c = bannerColor(color) - const btn = `style="background:${c};border-color:${c};color:#fff"` - return ` - ` -} - -const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.' - -// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt -function controllerBlock(controller) { - if (controller.ok !== true) return `` - return `

Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:

${controllerLines(controller).map(esc).join(' · ')}

Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.

` -} - -export function renderAnalyseActivation(stage, project, reload) { - const a = analyseOf(project) - const legal = a.legal || {} - const controller = a.controller || { ok: false } - let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' - stage.innerHTML = ` -
-

Analyse für ${esc(project.projectName)} einschalten

-

Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.

-

Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.

-

Farbe des Banners

-
- -
${PRESET_COLORS.map((c) => ``).join('')}
-
- -
- ${controllerBlock(controller)} - - -
-
` - const input = stage.querySelector('#an-color') - const hint = stage.querySelector('#an-hint') - const paint = () => { - stage.querySelector('#an-preview').innerHTML = bannerPreview(color) - hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase()) - } - paint() - input.addEventListener('input', () => { color = input.value; paint() }) - stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() })) - const box = stage.querySelector('#an-avv') - const button = stage.querySelector('#an-on') - const error = stage.querySelector('#an-error') - box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true }) - button.addEventListener('click', async () => { - if (!box.checked || controller.ok !== true) return - button.disabled = true - error.classList.add('hidden') - try { - await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } }) - reload() - } catch (err) { - error.textContent = err.message - error.classList.remove('hidden') - button.disabled = !box.checked || controller.ok !== true - } - }) -} - -function lineChart(series) { - const m = chartModel(series) - if (!m.points.length) return '

Noch keine Daten.

' - const total = m.points.reduce((sum, p) => sum + p.pv, 0) - const first = m.points[0].day - const last = m.points[m.points.length - 1].day - return ` - - Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))} - ${m.yTicks.map((t) => `${esc(t.label)}`).join('')} - ${m.xLabels.map((l) => `${esc(l.label)}`).join('')} - - - ${m.points.length <= 31 ? m.points.map((p) => `${esc(p.day)}: ${esc(nf.format(p.pv))}`).join('') : ''} - ` -} - -function barList(title, items, labelOf, { html = false } = {}) { - const rows = barRows(items, labelOf) - return ` -
-

${esc(title)}

- ${rows.length ? `
    ${rows.map((r) => ` -
  • ${html ? r.label : esc(r.label)}${esc(nf.format(r.count))}
  • `).join('')}
` : '

Noch keine Daten.

'} -
` -} - -const COUNTRY_ROW = (key) => `${flagEmoji(key) ? ` ` : ''}${esc(countryName(key))}` - -let statsRequestId = 0 -async function renderAnalyseStats(stage, project, reload) { - const myRequest = ++statsRequestId - const range = [7, 30, 90].includes(analyseSel.range) ? analyseSel.range : 30 - const pid = project.projectId - stage.innerHTML = ` -
${[7, 30, 90].map((r) => ``).join('')}
-

Lade…

-
-
-

Länderdaten: IP-Geolocation by DB-IP

` - stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) })) - renderAnalyseOff(stage.querySelector('#an-off'), pid, reload) - - const data = stage.querySelector('#an-data') - let stats - try { - stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`) - } catch (err) { - if (!stage.isConnected || myRequest !== statsRequestId) return - data.innerHTML = `` - return - } - if (!stage.isConnected || myRequest !== statsRequestId) return - const t = stats.totals || {} - const rate = Math.round((Number(t.consentRate) || 0) * 100) - const kpi = (label, value) => `
${label}${esc(value)}
` - const b = stats.banner || {} - data.innerHTML = ` -
${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}
-

Seitenaufrufe pro Tag

${lineChart(stats.series)}
-
- ${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')} - ${barList('Herkunft', stats.refs, refLabel)} - ${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })} - ${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')} - ${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)} - ${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))} -
-

${esc(KPI_NOTE)}

` - renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || []) -} - -/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks. - Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl - per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */ -const heatSel = { big: 'd' } -const HEAT_TIMEOUT_MS = 20000 - -let geraeteCss = null -function loadGeraeteCss() { - if (!geraeteCss) { - geraeteCss = new Promise((resolve) => { - const link = document.createElement('link') - link.rel = 'stylesheet' - link.href = '/geraete.css' - link.onload = link.onerror = () => resolve() - document.head.appendChild(link) - }) - } - return geraeteCss -} - -// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler -let heatListening = false -function onHeatMessage(ev) { - const d = ev.data - if (!d || d.wk !== 'heat') return - for (const fig of document.querySelectorAll('.hs-dev')) { - const frame = fig.querySelector('iframe') - if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue - clearTimeout(fig.heatTimer) - fig.dataset.state = d.state === 'ready' ? 'ready' : 'error' - setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n))) - } -} - -function setHeatCaption(fig, text) { - const cap = fig.querySelector('.hs-cap') - if (cap) cap.textContent = text -} - -// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website -function fitHeatDevice(fig) { - fig.heatResize?.disconnect() - const canvas = fig.querySelector('.dm-canvas') - const host = canvas?.parentElement - if (!host) return - const fit = (width) => { - if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6)) - } - fit(parseFloat(getComputedStyle(host).width) || host.clientWidth) - if (typeof ResizeObserver === 'function') { - fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width)) - fig.heatResize.observe(host) - } -} - -async function loadHeatDevice(fig, projectId, vp, path) { - const run = (fig.heatRun = (fig.heatRun || 0) + 1) - clearTimeout(fig.heatTimer) - fig.heatResize?.disconnect() - fig.dataset.state = 'loading' - fig.innerHTML = `
${esc(heatCaption('loading'))}
` - let url - try { - ;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } })) - } catch (err) { - if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) - return - } - await loadGeraeteCss() - if (run !== fig.heatRun || !fig.isConnected) return - if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error')) - fig.dataset.origin = new URL(url).origin - fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` }) - fitHeatDevice(fig) - // Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden - let loads = 0 - fig.querySelector('iframe').addEventListener('load', () => { - if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path) - }) - fig.heatTimer = setTimeout(() => { - if (run === fig.heatRun && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path) - }, HEAT_TIMEOUT_MS) -} - -// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher -async function showHeatFallback(fig, projectId, vp, path) { - fig.dataset.state = 'timeout' - const cap = fig.querySelector('.hs-cap') - if (!cap) return - cap.textContent = heatCaption('timeout') - try { - const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } }) - // Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt - if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` In neuem Tab öffnen`) - } catch { - /* Text reicht */ - } -} - -function renderAnalyseHeat(box, projectId, pages) { - if (!box) return - if (!pages.length) { - box.innerHTML = '

Heatmap

Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.

' - return - } - if (!heatListening) { - window.addEventListener('message', onHeatMessage) - heatListening = true - } - const big = heatSel.big === 't' ? 't' : 'd' - box.innerHTML = ` -
-

Heatmap

-
${['d', 't'].map((vp) => ``).join('')}
-
-

So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.

-
- -
-
-
-
-
` - const stage = box.querySelector('.hs-stage') - const bigFig = box.querySelector('.hs-dev--big') - const phoneFig = box.querySelector('.hs-dev--phone') - const page = () => box.querySelector('#an-heat-page').value || '/' - const show = (fig, vp) => { - fig.heatReloads = 0 - loadHeatDevice(fig, projectId, vp, page()) - } - box.querySelector('#an-heat-page').addEventListener('change', () => { - show(bigFig, heatSel.big) - show(phoneFig, 'm') - }) - box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => { - if (b.dataset.vp === heatSel.big) return - heatSel.big = b.dataset.vp - box.querySelectorAll('.hs-switch button').forEach((x) => { - const on = x.dataset.vp === heatSel.big - x.classList.toggle('active', on) - x.setAttribute('aria-pressed', String(on)) - }) - stage.dataset.big = heatSel.big - show(bigFig, heatSel.big) - })) - heatSel.big = big - // Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite) - const start = () => { - show(bigFig, heatSel.big) - show(phoneFig, 'm') - } - if (typeof IntersectionObserver !== 'function') return start() - const io = new IntersectionObserver((entries) => { - if (!entries.some((e) => e.isIntersecting)) return - io.disconnect() - start() - }, { rootMargin: '300px 0px' }) - io.observe(stage) -} - -// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog -export function renderAnalyseOff(box, projectId, reload) { - box.innerHTML = ` -

Analyse ausschalten

-

Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.

- -
` - const actions = box.querySelector('#an-off-actions') - const error = box.querySelector('#an-off-error') - box.querySelector('#an-off-ask').addEventListener('click', () => { - actions.innerHTML = 'Wirklich ausschalten? ' - actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload)) - actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => { - ev.currentTarget.disabled = true - try { - await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } }) - box.innerHTML = ` -

Analyse ausgeschaltet

-

Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

-
` - box.querySelector('#an-off-ok').addEventListener('click', () => reload()) - } catch (err) { - renderAnalyseOff(box, projectId, reload) - const e2 = box.querySelector('#an-off-error') - e2.textContent = err.message - e2.classList.remove('hidden') - } - }) - }) -} diff --git a/test/analyseView.test.js b/test/analyseView.test.js index 662e7bf..8628c0e 100644 --- a/test/analyseView.test.js +++ b/test/analyseView.test.js @@ -54,8 +54,11 @@ test('Portal: Analyse-Ansicht ist verdrahtet', () => { assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen'/) assert.match(html, /id="view-analyse"/) assert.match(html, /id="analyse-body"/) + assert.match(app, /analyse: \(\) => import\('\/analyse\.js'\)/) + const an = readFileSync(new URL('../public/analyse.js', import.meta.url), 'utf8') + assert.doesNotMatch(an, /\bconfirm\(/) const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') - assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/) + assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/) }) test('Portal-Vorschau: Banner-Text ist der des Clients', () => { diff --git a/test/avvPage.test.js b/test/avvPage.test.js index dcf9972..31c8763 100644 --- a/test/avvPage.test.js +++ b/test/avvPage.test.js @@ -25,7 +25,7 @@ test('Entwurfsseite ohne Fassung ist nicht mehr als Datei vorhanden', () => { }) test('Portal: keine alten Formulierungen, AVV-Adresse kommt aus a.legal', () => { - const fn = readFileSync(root('public/funktionen.js'), 'utf8') + const fn = readFileSync(root('public/analyse.js'), 'utf8') for (const old of ['anonym', 'Cookie-Einstellungen', 'Die bisherigen Daten bleiben noch 60 Tage erhalten', 'Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag', "AVV_URL = '/rechtliches/avv-website-analyse.html'"]) { assert.ok(!fn.includes(old), old) } diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index df39ae5..f3a8e34 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -5,15 +5,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet) +// public/funktionen.js wirklich laden (die absoluten Importe /fn-util.js und /analyse-view.js werden auf die Dateien umgeleitet); +// analyse.js importiert relativ und wird direkt geladen const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-')) const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') + .replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`) .replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`) - .replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`) const file = join(dir, 'funktionen.mjs') writeFileSync(file, src) -const { initFunktionen, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href) +const { initFunktionen } = await import(pathToFileURL(file).href) +const { initAnalyse, renderAnalyseOff } = await import(fileUrl('analyse.js')) const news = { state: 'off' } const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse }) diff --git a/test/termineView.test.js b/test/termineView.test.js index 8ab2d0f..a25b50b 100644 --- a/test/termineView.test.js +++ b/test/termineView.test.js @@ -264,9 +264,9 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp assert.match(html, /
[\s\S]*

Terminplaner<\/h1>[\s\S]*
/) assert.match(html, //) // R4: funktionen.js lädt keine Terminplaner-Module (die Kachel steht inline); seine absoluten Importe biegt - // test/funktionenTiles.test.js um – genau diese beiden (/geraete.js kam mit der Heatmap im Gerätrahmen auf main) + // test/funktionenTiles.test.js um – genau diese beiden (die Analyse-Oberfläche liegt in analyse.js, mit relativen Importen) const fn = lies('public/funktionen.js') - assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"]) + assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"]) assert.doesNotMatch(fn, /from '\/termine/) assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import') }) From 69c94d83d7b94a2417c0f8840ddc668c1c6abdbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:04:59 +0000 Subject: [PATCH 013/105] Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen Co-Authored-By: Claude Opus 5.5 --- public/analyse-view.js | 256 +++++++++++++++++++++++++++++++++- test/analyseInsights.test.js | 258 +++++++++++++++++++++++++++++++++++ 2 files changed, 509 insertions(+), 5 deletions(-) create mode 100644 test/analyseInsights.test.js diff --git a/public/analyse-view.js b/public/analyse-view.js index 7ea000c..ef3e5b2 100644 --- a/public/analyse-view.js +++ b/public/analyse-view.js @@ -1,5 +1,7 @@ /* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */ +import { esc } from './fn-util.js' + // Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1 export function contrastOk(hex) { if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false @@ -53,22 +55,33 @@ export function shortDay(day) { return m ? `${m[2]}.${m[1]}.` : String(day || '') } -/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. */ -export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6 } = {}) { +/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. Mit compare ([{pv}], Zeitraum davor) gemeinsame Skala und gestrichelte Vergleichslinie (nach Tagesindex ausgerichtet). */ +export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6, compare } = {}) { const data = Array.isArray(series) ? series : [] + const before = Array.isArray(compare) ? compare : [] + const prevOf = (i) => { + const v = before[i]?.pv + return v == null || !Number.isFinite(Number(v)) ? null : Number(v) + } const innerW = width - pad.l - pad.r const innerH = height - pad.t - pad.b - const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0))) + const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0), ...data.map((_, i) => prevOf(i) ?? 0))) const x = (i) => pad.l + (data.length > 1 ? (innerW * i) / (data.length - 1) : innerW / 2) const y = (v) => pad.t + innerH - (innerH * v) / top - const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0 })) + const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0, prev: prevOf(i) })) const step = Math.max(1, Math.ceil(data.length / maxLabels)) const xLabels = points.filter((_, i) => i % step === 0 || i === data.length - 1).filter((_, i, a) => i === a.length - 1 || a[a.length - 1].x - a[i].x > 36).map((p) => ({ x: p.x, label: shortDay(p.day) })) const yTicks = [0, 0.5, 1].map((f) => ({ y: round(y(top * f)), label: String(Math.round(top * f)) })) const line = points.map((p, i) => `${i ? 'L' : 'M'}${p.x} ${p.y}`).join(' ') const baseY = round(pad.t + innerH) const area = points.length ? `${line} L${points[points.length - 1].x} ${baseY} L${points[0].x} ${baseY} Z` : '' - return { width, height, pad, top, points, xLabels, yTicks, line, area } + // Vergleichslinie: Luecken (fehlende Tage) beginnen einen neuen Abschnitt + const prevLine = points.reduce((path, p, i) => { + if (p.prev === null) return path + const move = i > 0 && points[i - 1].prev !== null ? 'L' : 'M' + return `${path}${path ? ' ' : ''}${move}${p.x} ${round(y(p.prev))}` + }, '') + return { width, height, pad, top, points, xLabels, yTicks, line, area, prevLine } } function round(n) { @@ -101,3 +114,236 @@ export function pickAnalyseProject(projects) { // Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js) export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' } + +/* --- Vergleich zum Vorzeitraum und Klartext ("Das Wichtigste"), Spec 2.3 und 2.4 --- */ + +const MONTHS = ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.'] +const WEEKDAYS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] // wie Date#getUTCDay() +const WEEKDAYS_SHORT = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'] +const AGAINST = 'gegenüber dem Zeitraum davor' + +const NUMBER_DE = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 }) +const isNum = (v) => typeof v === 'number' && Number.isFinite(v) + +/** Ganzzahl auf Deutsch: 6930 => "6.930". */ +export const fmtNum = (n) => NUMBER_DE.format(Number(n) || 0) + +/** Sekunden als m:ss: 108 => "1:48", 7 => "0:07"; null oder ungueltig => "–". */ +export function fmtDuration(sec) { + if (!isNum(sec) || sec < 0) return '–' + const s = Math.round(sec) + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` +} + +// "YYYY-MM-DD" => { y, m, d } oder null; Wochentage und Tagesrechnung laufen nur ueber Date.UTC (keine Ortszeit) +function parseDay(day) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day || '') + if (!m) return null + const [y, mo, d] = [+m[1], +m[2], +m[3]] + const t = new Date(Date.UTC(y, mo - 1, d)) + return t.getUTCMonth() === mo - 1 && t.getUTCDate() === d ? { y, m: mo, d } : null +} +const weekdayOf = (day) => { + const p = parseDay(day) + return p ? new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay() : -1 +} +function addDays(day, n) { + const p = parseDay(day) + return p ? new Date(Date.UTC(p.y, p.m - 1, p.d + n)).toISOString().slice(0, 10) : '' +} + +/** "2026-09-19" => "Sa., 19.09." */ +export function dayLabel(day) { + const w = weekdayOf(day) + return w < 0 ? String(day || '') : `${WEEKDAYS_SHORT[w]}, ${shortDay(day)}` +} + +/** ("2026-09-05", "2026-10-04") => "5. Sep. – 4. Okt."; leere oder ungueltige Eingabe => "". */ +export function periodLabel(from, to) { + const a = parseDay(from) + const b = parseDay(to) + return a && b ? `${a.d}. ${MONTHS[a.m - 1]} – ${b.d}. ${MONTHS[b.m - 1]}` : '' +} + +/** Vergleich moeglich, wenn der Vorzeitraum vollstaendig ist und mindestens 20 Aufrufe hat. Sonst from = "TT.MM." ab dem der Vergleich geht (nur bei Vorzeitraum, der noch nicht voll ist). */ +export function compareFrom(stats) { + const prev = stats?.previous + if (prev?.complete && Number(prev.totals?.pv) >= 20) return { ok: true, from: '' } + const range = Number(stats?.range) + if (prev && !prev.complete && stats.firstDay && range > 0) { + const from = addDays(stats.firstDay, 2 * range - 1) + return { ok: false, from: from ? shortDay(from) : '' } + } + return { ok: false, from: '' } +} + +// Sekunden in Worten fuer Screenreader: 65 => "1 Minute 5 Sekunden" +function secWords(sec) { + const m = Math.floor(sec / 60) + const s = sec % 60 + return [m ? `${m} ${m === 1 ? 'Minute' : 'Minuten'}` : '', s || !m ? `${s} ${s === 1 ? 'Sekunde' : 'Sekunden'}` : ''].filter(Boolean).join(' ') +} + +/** Veraenderung gegenueber dem Vorwert fuer den Chip: { dir, text, aria } oder null, wenn ein Wert keine Zahl ist. kind 'count': Prozent ab 100/100, sonst absolut; kind 'sec': absolut in Sekunden. */ +export function delta(cur, prev, { kind = 'count' } = {}) { + if (!isNum(cur) || !isNum(prev)) return null + const flat = (text) => ({ dir: 'flat', text, aria: `unverändert ${AGAINST}` }) + const diff = cur - prev + if (diff === 0) return flat('± 0') + const dir = diff > 0 ? 'up' : 'down' + const arrow = diff > 0 ? '▲' : '▼' + const sign = diff > 0 ? 'plus' : 'minus' + if (kind === 'sec') { + const n = Math.round(Math.abs(diff)) + if (n === 0) return flat('± 0') + return { dir, text: `${arrow} ${n < 60 ? `${n} s` : fmtDuration(n)}`, aria: `${sign} ${secWords(n)} ${AGAINST}` } + } + if (cur >= 100 && prev >= 100) { + const pct = (Math.abs(diff) / prev) * 100 + if (pct < 1) return flat('± 0 %') + const n = Math.round(pct) + return { dir, text: `${arrow} ${n} %`, aria: `${sign} ${n} Prozent ${AGAINST}` } + } + const n = fmtNum(Math.abs(diff)) + return { dir, text: `${arrow} ${n}`, aria: `${sign} ${n} ${AGAINST}` } +} + +/** Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; null unter 30 Besuchern (zu unsicher). */ +export function contactRate(contacts, visitors) { + if (!isNum(contacts) || !isNum(visitors) || visitors < 30) return null + return Math.round((contacts / visitors) * 1000) / 10 +} + +// Bekannte Herkunfts-Hosts (Teilstring am Label-Rand: "l.facebook.com" ja, "notgoogle.com" nein) +const REF_NAMES = [ + [/(^|\.)google\.(com?\.)?[a-z]{2,3}$/, 'Google'], + [/(^|\.)bing\.com$/, 'Bing'], + [/(^|\.)instagram\.com$/, 'Instagram'], + [/(^|\.)facebook\.com$/, 'Facebook'], + [/(^|\.)duckduckgo\.com$/, 'DuckDuckGo'], + [/(^|\.)ecosia\.org$/, 'Ecosia'], +] + +/** Lesbarer Name einer Herkunft; unbekannter Host bleibt unveraendert (beim Einsetzen in HTML maskieren). */ +export function refName(host) { + const h = String(host ?? '').trim() + if (!h) return 'Direkt oder unbekannt' + const hit = REF_NAMES.find(([re]) => re.test(h.toLowerCase())) + return hit ? hit[1] : h +} + +/** Staerkster Wochentag und Spitzentag ueber die Tage ab firstDay ('' = alle). null bei weniger als 7 Tagen. */ +export function weekdayStats(series, firstDay) { + const rows = (Array.isArray(series) ? series : []) + .filter((d) => parseDay(d?.day) && (!firstDay || d.day >= firstDay)) + .map((d) => ({ day: d.day, pv: Number(d.pv) || 0 })) + if (rows.length < 7) return null + const avg = rows.reduce((a, r) => a + r.pv, 0) / rows.length + const perWeekday = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 })) + for (const r of rows) { + const w = perWeekday[weekdayOf(r.day)] + w.sum += r.pv + w.n += 1 + } + let best = null + perWeekday.forEach((w, weekday) => { + if (w.n && (!best || w.sum / w.n > best.avg)) best = { weekday, name: WEEKDAYS[weekday], avg: w.sum / w.n } + }) + if (!best || !(avg > 0) || best.avg < 1.2 * avg) best = null + const max = rows.reduce((m, r) => (r.pv > m.pv ? r : m), rows[0]) + const peak = avg > 0 && max.pv >= 1.8 * avg ? { day: max.day, pv: max.pv } : null + return { best, avg, peak } +} + +const CONTACT_WAYS = { tel: 'per Telefon', mail: 'per E-Mail', wa: 'per WhatsApp', out: 'über einen Link nach außen' } +const DEVICE_WORDS = { m: 'vom Handy', d: 'vom Computer' } + +// Satz ueber dem Hinweis-Block; nur hier steht fett (), alle Zahlen sind fmtNum +function leadOf(stats, cmp) { + const days = Number(stats.range) || (Array.isArray(stats.series) ? stats.series.length : 0) + const pv = Number(stats.totals.pv) + const count = `${fmtNum(pv)}-mal` + if (!cmp.ok) { + return stats.firstDay && stats.from && stats.firstDay >= stats.from + ? `Seit dem Start der Analyse am ${esc(shortDay(stats.firstDay))} wurde deine Website ${count} aufgerufen.` + : `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen.` + } + const before = Number(stats.previous.totals.pv) + const diff = pv - before + const start = `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen – ` + if (Math.abs(diff) / before < 0.05) return `${start}etwa so oft wie in den ${days} Tagen davor.` + const more = diff > 0 + const size = pv >= 100 && before >= 100 + ? `${fmtNum(Math.round((Math.abs(diff) / before) * 100))} %` + : `${fmtNum(Math.abs(diff))} ${Math.abs(diff) === 1 ? 'Aufruf' : 'Aufrufe'}` + return `${start}${size} ${more ? 'mehr' : 'weniger'} als in den ${days} Tagen davor.` +} + +function contactItem(stats, cmp) { + const total = Number(stats.totals.contacts) || 0 + if (total < 1) { + return Number(stats.totals.visitors) >= 30 + ? 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' + : null + } + const main = (Array.isArray(stats.contacts) ? stats.contacts : []) + .filter((c) => CONTACT_WAYS[c.key] && c.count > 0) + .reduce((m, c) => (!m || c.count > m.count ? c : m), null) + let text = `${fmtNum(total)}-mal hat dich jemand direkt kontaktiert${main ? `, meist ${CONTACT_WAYS[main.key]} (${fmtNum(main.count)})` : ''}.` + const before = cmp.ok ? Number(stats.previous.totals.contacts) : 0 + if (before >= 1) { + const diff = total - before + text += diff === 0 ? ' Genauso oft wie zuvor.' : ` Das sind ${fmtNum(Math.abs(diff))} ${diff > 0 ? 'mehr' : 'weniger'} als zuvor.` + } + return text +} + +function dayItem(stats) { + if (!(Number(stats.totals.pv) >= 70)) return null + const w = weekdayStats(stats.series, stats.firstDay || '') + if (!w) return null + const parts = [] + if (w.best) parts.push(`${w.best.name} ist im Schnitt dein stärkster Tag.`) + if (w.peak) parts.push(`Spitzentag war ${dayLabel(w.peak.day)} mit ${fmtNum(w.peak.pv)} Aufrufen.`) + return parts.length ? parts.join(' ') : null +} + +function sourceItem(stats) { + const refs = Array.isArray(stats.refs) ? stats.refs : [] + const sum = refs.reduce((a, r) => a + (Number(r.count) || 0), 0) + const first = refs.find((r) => r.key) + if (!first || !(sum > 0) || first.count / sum < 0.2) return null + return `Die meisten, die über einen Link kamen, kommen von ${esc(refName(first.key))} (${fmtNum(first.count)}).` +} + +function deviceItem(stats) { + const devices = Array.isArray(stats.devices) ? stats.devices : [] + const sum = devices.reduce((a, d) => a + (Number(d.count) || 0), 0) + const main = devices.find((d) => DEVICE_WORDS[d.key] && sum > 0 && d.count / sum >= 0.55) + return main ? `${fmtNum(Math.round((main.count / sum) * 100))} % der Aufrufe kommen ${DEVICE_WORDS[main.key]}.` : null +} + +function pageItem(stats) { + const [first, second] = Array.isArray(stats.pages) ? stats.pages : [] + if (!first?.key) return null + if (first.key === '/') { + return second?.key + ? `Am meisten angesehen: die Startseite, danach ${esc(second.key)} (${fmtNum(second.count)}).` + : `Am meisten angesehen: die Startseite (${fmtNum(first.count)} Aufrufe).` + } + return `Am meisten angesehen: ${esc(first.key)} (${fmtNum(first.count)} Aufrufe).` +} + +/** Klartext fuer "Das Wichtigste": { lead, items: [{ icon, html }] } als maskiertes HTML; null unter 30 Seitenaufrufen. Feste Regeln, keine KI. */ +export function summarize(stats) { + if (!stats?.totals || !(Number(stats.totals.pv) >= 30)) return null + const cmp = compareFrom(stats) + const items = [ + ['contact', contactItem(stats, cmp)], + ['day', dayItem(stats)], + ['source', sourceItem(stats)], + ['device', deviceItem(stats)], + ['page', pageItem(stats)], + ].filter(([, html]) => html).slice(0, 4).map(([icon, html]) => ({ icon, html })) + return { lead: leadOf(stats, cmp), items } +} diff --git a/test/analyseInsights.test.js b/test/analyseInsights.test.js new file mode 100644 index 0000000..30c3337 --- /dev/null +++ b/test/analyseInsights.test.js @@ -0,0 +1,258 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { delta, fmtDuration, fmtNum, dayLabel, periodLabel, compareFrom, contactRate, refName, weekdayStats, summarize, chartModel } from '../public/analyse-view.js' + +// Testdaten: eine vollstaendige Statistik-Antwort mit 30 Tagen (Form wie analytics/src/stats.js) +const isoDay = (from, n) => new Date(Date.UTC(+from.slice(0, 4), +from.slice(5, 7) - 1, +from.slice(8, 10) + n)).toISOString().slice(0, 10) +const days = (from, n, pv) => Array.from({ length: n }, (_, i) => ({ day: isoDay(from, i), pv: typeof pv === 'function' ? pv(i, isoDay(from, i)) : pv })) +const t = { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 } +const prev = { + from: '2026-08-06', to: '2026-09-04', complete: true, + totals: { pv: 6080, visitors: 2200, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 }, + series: days('2026-08-06', 30, 203), + contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }, { key: 'wa', count: 23 }], +} +const stats = (over = {}) => ({ + range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '2026-07-01', + totals: t, + series: days('2026-09-05', 30, 231), + pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }], + refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }], + countries: [{ key: 'DE', count: 6000 }], + devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2200 }, { key: 't', count: 140 }], + contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'wa', count: 30 }, { key: 'out', count: 10 }], + banner: { shown: 3950, accept: 2410, reject: 1540 }, + previous: prev, + ...over, +}) + +test('delta: Prozent ab 100, sonst absolut, flat, Sekunden, null', () => { + assert.deepEqual(delta(6930, 6080), { dir: 'up', text: '▲ 14 %', aria: 'plus 14 Prozent gegenüber dem Zeitraum davor' }) + assert.equal(delta(188, 143).text, '▲ 31 %') // beide ≥ 100 → Prozent + assert.equal(delta(45, 33).text, '▲ 12') + assert.equal(delta(30, 33).text, '▼ 3') + assert.deepEqual(delta(7, 7), { dir: 'flat', text: '± 0', aria: 'unverändert gegenüber dem Zeitraum davor' }) + assert.equal(delta(1004, 1000).text, '± 0 %') // |0,4 %| < 1 + assert.equal(delta(1004, 1000).dir, 'flat') + assert.equal(delta(108, 114, { kind: 'sec' }).text, '▼ 6 s') + assert.equal(delta(200, 135, { kind: 'sec' }).text, '▲ 1:05') + assert.equal(delta(null, 3), null) + assert.equal(delta(3, undefined), null) + assert.equal(delta('3', 4), null) +}) + +test('delta: Richtung und aria in Wörtern', () => { + assert.deepEqual(delta(5000, 6000), { dir: 'down', text: '▼ 17 %', aria: 'minus 17 Prozent gegenüber dem Zeitraum davor' }) + assert.equal(delta(45, 33).aria, 'plus 12 gegenüber dem Zeitraum davor') + assert.equal(delta(30, 33).aria, 'minus 3 gegenüber dem Zeitraum davor') + assert.equal(delta(108, 114, { kind: 'sec' }).aria, 'minus 6 Sekunden gegenüber dem Zeitraum davor') + assert.equal(delta(200, 135, { kind: 'sec' }).aria, 'plus 1 Minute 5 Sekunden gegenüber dem Zeitraum davor') + assert.equal(delta(240, 120, { kind: 'sec' }).aria, 'plus 2 Minuten gegenüber dem Zeitraum davor') +}) + +test('fmtDuration, dayLabel, periodLabel, fmtNum', () => { + assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–') + assert.equal(fmtDuration(NaN), '–'); assert.equal(fmtDuration(-3), '–'); assert.equal(fmtDuration(undefined), '–') + assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.') + assert.equal(dayLabel('2026-09-20'), 'So., 20.09.') + assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.') + assert.equal(periodLabel('2026-03-01', '2026-06-30'), '1. März – 30. Juni') + assert.equal(periodLabel('', ''), '') + assert.equal(periodLabel('2026-09-05', ''), '') + assert.equal(fmtNum(6930), '6.930') + assert.equal(fmtNum(0), '0') + assert.equal(fmtNum(1234567), '1.234.567') +}) + +test('compareFrom: ok, Datum bei neuer Website, nichts ohne previous', () => { + assert.deepEqual(compareFrom(stats()), { ok: true, from: '' }) + assert.deepEqual(compareFrom(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })), { ok: false, from: '18.11.' }) // range 30: +59 Tage + assert.deepEqual(compareFrom(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })), { ok: false, from: '11.10.' }) // range 7: +13 Tage + assert.deepEqual(compareFrom(stats({ firstDay: '', previous: { ...prev, complete: false } })), { ok: false, from: '' }) + assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' }) + assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false) + assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 20 } } })).ok, true) +}) + +test('contactRate und refName', () => { + assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null) + assert.equal(contactRate(0, 30), 0); assert.equal(contactRate(null, 100), null) + assert.equal(refName('www.google.de'), 'Google'); assert.equal(refName('l.facebook.com'), 'Facebook'); assert.equal(refName(''), 'Direkt oder unbekannt'); assert.equal(refName('blog.example'), 'blog.example') + assert.equal(refName('google.com'), 'Google'); assert.equal(refName('google.co.uk'), 'Google') + assert.equal(refName('www.bing.com'), 'Bing'); assert.equal(refName('l.instagram.com'), 'Instagram') + assert.equal(refName('m.facebook.com'), 'Facebook'); assert.equal(refName('facebook.com'), 'Facebook') + assert.equal(refName('duckduckgo.com'), 'DuckDuckGo'); assert.equal(refName('www.ecosia.org'), 'Ecosia') + assert.equal(refName('notgoogle.com'), 'notgoogle.com'); assert.equal(refName('google.evil.de'), 'google.evil.de') + assert.equal(refName('Google.DE'), 'Google') +}) + +test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => { + // 05.09.2026 ist ein Samstag: 5 Samstage mit 20, alle anderen 10 → Schnitt 11,67; Samstag 20 ≥ 1,2 × 11,67, aber < 1,8 × 11,67 (kein Spitzentag) + const sat = (i, day) => (new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 20 : 10) + const w = weekdayStats(days('2026-09-05', 30, sat), '') + assert.equal(w.best.weekday, 6); assert.equal(w.best.name, 'Samstag'); assert.equal(w.best.avg, 20) + assert.ok(Math.abs(w.avg - 350 / 30) < 1e-9) + assert.equal(w.peak, null) + // ein Tag mit dem 3-Fachen des Schnitts → Spitzentag + const spike = (i, day) => (day === '2026-09-19' ? 60 : 10) + const p = weekdayStats(days('2026-09-05', 30, spike), '') + assert.deepEqual(p.peak, { day: '2026-09-19', pv: 60 }) + assert.equal(p.best.name, 'Samstag') + // gleichmäßig: weder stärkster Tag noch Spitzentag + assert.deepEqual(weekdayStats(days('2026-09-05', 30, 10), ''), { best: null, avg: 10, peak: null }) + // weniger als 7 Tage → null; firstDay schneidet Tage davor ab + assert.equal(weekdayStats(days('2026-09-05', 6, 10), ''), null) + assert.notEqual(weekdayStats(days('2026-09-05', 30, 10), '2026-09-28'), null) // 28.09.–04.10. = genau 7 Tage +}) + +test('weekdayStats: firstDay zählt nur Tage ab firstDay', () => { + assert.equal(weekdayStats(days('2026-09-05', 30, 10), '2026-09-29'), null) // nur 6 Tage (29.09.–04.10.) + const w = weekdayStats(days('2026-09-05', 30, (i, day) => (day < '2026-09-20' ? 1000 : 10)), '2026-09-20') + assert.equal(w.avg, 10) // die Tage davor (1000) bleiben außen vor + assert.equal(weekdayStats(null, ''), null) +}) + +test('summarize: Wachstum mit Prozent', () => { + const s = summarize(stats()) + assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – 14 % mehr als in den 30 Tagen davor.') + assert.equal(s.items[0].icon, 'contact') + assert.equal(s.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 45 mehr als zuvor.') + assert.ok(s.items.length <= 4) +}) + +test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => { + const lead = (totalsPv, prevPv) => summarize(stats({ totals: { ...t, pv: totalsPv }, previous: { ...prev, totals: { ...prev.totals, pv: prevPv } } })).lead + assert.equal(lead(6930, 7615), 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – 9 % weniger als in den 30 Tagen davor.') + assert.equal(lead(6930, 6800), 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – etwa so oft wie in den 30 Tagen davor.') + assert.equal(lead(80, 68), 'Deine Website wurde in den letzten 30 Tagen 80-mal aufgerufen – 12 Aufrufe mehr als in den 30 Tagen davor.') + assert.equal(lead(60, 75), 'Deine Website wurde in den letzten 30 Tagen 60-mal aufgerufen – 15 Aufrufe weniger als in den 30 Tagen davor.') + assert.equal(lead(84, 83), 'Deine Website wurde in den letzten 30 Tagen 84-mal aufgerufen – etwa so oft wie in den 30 Tagen davor.') // unter 5 % gilt auch bei kleinen Zahlen + assert.match(lead(32, 31), /aufgerufen – etwa so oft wie in den 30 Tagen davor\.$/) + assert.equal(lead(40, 30), 'Deine Website wurde in den letzten 30 Tagen 40-mal aufgerufen – 10 Aufrufe mehr als in den 30 Tagen davor.') + assert.equal(lead(31, 20), 'Deine Website wurde in den letzten 30 Tagen 31-mal aufgerufen – 11 Aufrufe mehr als in den 30 Tagen davor.') + assert.equal(lead(80, 80), 'Deine Website wurde in den letzten 30 Tagen 80-mal aufgerufen – etwa so oft wie in den 30 Tagen davor.') + assert.equal(lead(7200, 6857), 'Deine Website wurde in den letzten 30 Tagen 7.200-mal aufgerufen – 5 % mehr als in den 30 Tagen davor.') // genau 5 % gilt noch als Veränderung +}) + +test('summarize: ohne Vergleich und neu gestartet', () => { + // previous fehlt: '…in den letzten 30 Tagen X-mal aufgerufen.' + const { previous, ...ohne } = stats() + assert.equal(summarize(ohne).lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen.') + // Vorzeitraum unvollständig, firstDay vor dem Zeitraum: kein Vergleich, aber kein „Seit dem Start“ + assert.equal(summarize(stats({ firstDay: '2026-08-20', previous: { ...prev, complete: false } })).lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen.') + // Vorzeitraum zu klein: kein Vergleich + assert.equal(summarize(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 12 } } })).lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen.') + // firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website X-mal aufgerufen.' + const neu = summarize(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })) + assert.equal(neu.lead, 'Seit dem Start der Analyse am 20.09. wurde deine Website 6.930-mal aufgerufen.') + // auch das Startdatum wird maskiert, falls der Collector je Unsinn liefert + assert.ok(!summarize(stats({ firstDay: 'x', previous: { ...prev, complete: false } })).lead.includes('')) + // ohne Vergleich kein „Das sind … mehr als zuvor“ + assert.equal(neu.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96).') + // 7 Tage: „7 Tagen“ + assert.match(summarize(stats({ range: 7 })).lead, /in den letzten 7 Tagen .* als in den 7 Tagen davor\.$/) +}) + +test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => { + assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null) + assert.equal(summarize(null), null) + assert.notEqual(summarize(stats({ totals: { ...t, pv: 30 } })), null) + // contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' + const keine = summarize(stats({ totals: { ...t, contacts: 0, visitors: 40 }, contacts: [] })) + assert.deepEqual(keine.items[0], { icon: 'contact', html: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' }) + const wenig = summarize(stats({ totals: { ...t, contacts: 0, visitors: 29 }, contacts: [] })) + assert.notEqual(wenig.items[0].icon, 'contact') +}) + +test('summarize: Kontakte – Vergleichssatz und häufigste Art', () => { + const html = (contactsNow, contactsBefore, kinds) => summarize(stats({ + totals: { ...t, contacts: contactsNow }, + contacts: kinds, + previous: { ...prev, totals: { ...prev.totals, contacts: contactsBefore } }, + })).items[0].html + assert.equal(html(188, 200, [{ key: 'tel', count: 96 }]), '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 12 weniger als zuvor.') + assert.equal(html(188, 188, [{ key: 'mail', count: 100 }]), '188-mal hat dich jemand direkt kontaktiert, meist per E-Mail (100). Genauso oft wie zuvor.') + assert.equal(html(5, 0, [{ key: 'wa', count: 3 }]), '5-mal hat dich jemand direkt kontaktiert, meist per WhatsApp (3).') // Vorwert 0: kein Vergleichssatz + assert.equal(html(5, 2, [{ key: 'out', count: 3 }]), '5-mal hat dich jemand direkt kontaktiert, meist über einen Link nach außen (3). Das sind 3 mehr als zuvor.') +}) + +test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => { + // refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).' + const s = summarize(stats()) + const byIcon = Object.fromEntries(s.items.map((i) => [i.icon, i.html])) + assert.equal(byIcon.source, 'Die meisten, die über einen Link kamen, kommen von Google (880).') + // devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.' + assert.equal(byIcon.device, '66 % der Aufrufe kommen vom Handy.') + // pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).' + assert.equal(byIcon.page, 'Am meisten angesehen: die Startseite, danach /preise (1.310).') + assert.deepEqual(s.items.map((i) => i.icon), ['contact', 'source', 'device', 'page']) + // Computer, Seite ohne Startseite + const pc = summarize(stats({ devices: [{ key: 'd', count: 600 }, { key: 'm', count: 300 }, { key: 't', count: 100 }], pages: [{ key: '/preise', count: 1310 }, { key: '/', count: 900 }] })) + assert.equal(pc.items.find((i) => i.icon === 'device').html, '60 % der Aufrufe kommen vom Computer.') + assert.equal(pc.items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: /preise (1.310 Aufrufe).') + // nur Startseite + assert.equal(summarize(stats({ pages: [{ key: '/', count: 50 }] })).items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: die Startseite (50 Aufrufe).') + // Gerät unter 55 %, Herkunft unter 20 %: beide Hinweise entfallen + const flach = summarize(stats({ devices: [{ key: 'm', count: 50 }, { key: 'd', count: 50 }], refs: [{ key: 'google.com', count: 10 }, { key: '', count: 90 }] })) + assert.ok(!flach.items.some((i) => i.icon === 'device' || i.icon === 'source')) + // Direkt (leerer Host) zählt nie als Quelle, auch wenn er vorne steht + const direkt = summarize(stats({ refs: [{ key: '', count: 900 }, { key: 'google.com', count: 300 }] })) + assert.equal(direkt.items.find((i) => i.icon === 'source').html, 'Die meisten, die über einen Link kamen, kommen von Google (300).') + // Samstage 2× → Wochentag-Hinweis; alle 5 Bedingungen wahr → Seite fällt weg + const sat = (i, day) => (new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 20 : 10) + const voll = summarize(stats({ series: days('2026-09-05', 30, sat) })) + assert.deepEqual(voll.items.map((i) => i.icon), ['contact', 'day', 'source', 'device']) + assert.equal(voll.items[1].html, 'Samstag ist im Schnitt dein stärkster Tag.') +}) + +test('summarize: Wochentag und Spitzentag in einem Hinweis', () => { + // Samstage 20, Spitzentag Sa., 19.09. mit 371, sonst 10: beides trifft zu + const both = (i, day) => (day === '2026-09-19' ? 371 : new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 40 : 10) + const s = summarize(stats({ series: days('2026-09-05', 30, both) })) + assert.equal(s.items.find((i) => i.icon === 'day').html, 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 19.09. mit 371 Aufrufen.') + // nur Spitzentag am Mittwoch 23.09.: die anderen Mittwoche (0) gleichen den Wochentagsschnitt aus (alle Wochentage Ø 25) + const spike = (i, day) => (day === '2026-09-23' ? 100 : new Date(day + 'T00:00:00Z').getUTCDay() === 3 ? 0 : 25) + const p = summarize(stats({ series: days('2026-09-05', 30, spike) })) + assert.equal(p.items.find((i) => i.icon === 'day').html, 'Spitzentag war Mi., 23.09. mit 100 Aufrufen.') + // weniger als 70 Aufrufe oder weniger als 7 Tage: kein Wochentag-Hinweis + assert.ok(!summarize(stats({ totals: { ...t, pv: 69 }, series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day')) + assert.ok(!summarize(stats({ firstDay: '2026-09-30', series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day')) +}) + +test('summarize: Pfade und Hosts werden maskiert', () => { + const s = summarize(stats({ pages: [{ key: '/', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] })) + assert.ok(!JSON.stringify(s).includes(' i.icon === 'source').html, 'Die meisten, die über einen Link kamen, kommen von a"b.de (900).') + assert.equal(s.items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: /<img src=x> (900 Aufrufe).') + // auch an zweiter Stelle hinter der Startseite + const z = summarize(stats({ pages: [{ key: '/', count: 900 }, { key: '/' + const s = stats({ + pages: [{ key: evil, count: 5 }], + refs: [{ key: evil, count: 5 }], + countries: [{ key: evil, count: 5 }], + devices: [{ key: evil, count: 5 }], + series: [{ day: evil, pv: 5 }, { day: '2026-09-18', pv: 7 }], + previous: { ...prev, series: [{ pv: 3 }, { pv: 4 }] }, + }) + const all = overviewHtml(s) + rankCardHtml('seen', s, { tab: 'refs' }) + rankCardHtml('who', s, { tab: 'countries' }) + rankCardHtml('who', s) + chartHtml(s) + assert.doesNotMatch(all, /`, Schritte (Shop-Seite + Menüpunkt anlegen, Snippet einsetzen, auf **allen** Seiten im Footer Link „Vertrag widerrufen“ → `https://project.webklar.com/shop//widerruf`, Datenschutz-Absatz Webshop (`docs/funktionen/shop-datenschutz-absatz.md`) einfügen, Impressum prüfen, Vorschau `https://project.webklar.com/shop/` prüfen, Ticket schließen ⇒ Freischaltung), „Zeit im Arbeitsblatt, nicht auf Abo-Stunden“, Verweis WOMS (`anleitungsLink`). +Haken `STANDARD_HAKEN.shop`: `verfuegbar: (email) => shopFuerKunde(email)`, `bestellpruefung` (dynamisch import `./shop/anfrage.js` `anfrageOffenePunkte({customerId, projectId})` ⇒ `[]` wenn Anfrage `angenommen`, sonst `['Deine Webshop-Anfrage ist noch nicht freigegeben.']`), `unterlagenBereit: () => featureLegalReady('shop')`, `belehrung: termine-Belehrung (gleiche Quelle)`, kein `freischaltbar` (geschlossenes Ticket genügt). +Für A3 genügt in `shop/anfrage.js` ein Stub mit `anfrageOffenePunkte`, den Block B ausbaut. `featureConsentTexte.test.js`: Hash-Pin für shop ergänzen (Muster termine). +Tests: Registerwerte, `oeffentlicheAnsicht('shop')`, Haken, legalPaths('shop'), `featureLegalReady('shop')` false solange Dateien fehlen (Block J legt sie an), Ticket-Texte. + +--- + +## Block B: Anfrage („auf Anfrage“) + +### Task B1: Anfrage-Dienst und Kundenroute +**Dateien:** `server/services/shop/anfrage.js`, `server/routes/shop.js` (neu, Mount `/api/shop` in `server/index.js` neben `/api/termine`), `server/middleware/kundenansichtGuard.js`, `test/shopAnfrage.test.js`. +- `shopConfig`-Dokument-ID = portalFeatures-ID (`ensureFeatureDoc(customerId, projectId, 'shop')` liefert sie); `ensureShopConfig(featureDoc)` legt es an (siteKey kopiert). +- `normalisiereAnfrage(eingabe) ⇒ { anfrage } | { fehler }`: `warenart` ∈ `waren|lebensmittel|kleidung|kunst|sonstiges`, `beschreibung` 10–1500 Zeichen, `artikelzahl` ∈ `bis20|bis100|mehr`, + `versand` bool, `abholung` bool (mind. eins), `unternehmer === true`, `keineAusgeschlossenen === true`, `nachricht` ≤ 1000. +- `stelleAnfrage({ customerId, projectId, eingabe, person, now })`: Projekt gehört Kunde (`assertOwnProject(..., {forNewOrder:true})`), Schalter `shopFuerKunde`, nur wenn Status `''|abgelehnt|zurueckgezogen` (sonst 409), + speichert JSON + Status `offen` + `anfrageAt`, schickt **T21** an das Team (`sendAdminNotice`, Fehler nur loggen). +- `zieheZurueck` (nur `offen`), `anfrageAnsicht(shop)`, `anfrageOffenePunkte({customerId, projectId})`. +- Routen (requireSession, viewAs ⇒ 403 für POST): `GET /api/shop/status?projectId=` ⇒ `{ verfuegbar, anfrage: {status, anfrage, at, begruendung} | null, state, ... }` (Grundgerüst, C7 erweitert), + `POST /api/shop/anfrage {projectId, anfrage}`, `POST /api/shop/anfrage/zurueckziehen {projectId}`. Rate-Limit 20/h je Kunde (`createWriteLimiter`). +- Vorlage `server/mail-templates/T21 Neue Webshop-Anfrage.html` (Muster T16-Layout; Betreff `[Webshop] Neue Anfrage: {{kunde_name}}`; Platzhalter kunde_name, email, projekt, warenart, beschreibung, artikelzahl, lieferung, nachricht, woms_url); + `test/mailTemplates.test.js`: EXPECTED, `[Webshop]` in die T-Betreff-Regex, TEAM_FOOTER. + +### Task B2: Team-Entscheidung und K24 +**Dateien:** `server/routes/team/shop.js` (neu, Mount in `server/routes/team/index.js` als `/shop`), `server/services/shop/anfrage.js`, `server/mail-templates/K24 Webshop-Anfrage.html`, Tests. +- `GET /api/team/shop/:customerId` ⇒ je Projekt `{ projectId, projectName, anfrage, stripe: {accountId?, status}, geoeffnet, zahlen: { artikel, offeneBestellungen } }`. +- `POST /api/team/shop/:customerId/anfrage { projectId, entscheidung: 'annehmen'|'ablehnen', begruendung }` (Begründung Pflicht bei ablehnen, ≤ 1000) unter `withLocks`; + nur aus `offen`; speichert Entscheidung, Person (`req.session.name/email`), Zeit; **K24** an den Kunden (Blöcke `angenommen` mit Link `dashboard.html#webshop?projekt=` und Preis 299 €, `abgelehnt` mit Begründung). +- Tests: Annehmen/Ablehnen, falscher Zustand 409, Mail-Inhalte, Bestell-Haken liefert danach `[]`. + +--- + +## Block C: Shop-Daten und Kundenverwaltung (Server) + +### Task C1: Geld und Grundpreis (rein) +`server/services/shop/geld.js` + `test/shopGeld.test.js`: `euro`, `parseEuro` ('12,5' / '12,50 €' / '1.234,56' / 12.5 ⇒ Cent; negative, > 100000 €, mehr als 2 Nachkommastellen ⇒ null), +`grundpreisText({preisCent, menge, einheit})`: Einheiten `g|kg|ml|l|m|m2`; Bezug 1 kg / 1 l / 1 m / 1 m² (g/ml ⇒ kg/l); Ergebnis „(12,50 € / 1 kg)“; ohne Menge ''; Rundung kaufmännisch auf Cent. + +### Task C2: Einstellungen (rein + Speichern) +`server/services/shop/einstellungen.js` + Tests. Felder: `shopname` (1–80), `anbieter { name, strasse, plz, ort, land:'DE'|'AT', email, telefon, ustId? }` (Pflicht außer ustId/telefon), +`kleinunternehmer` bool, `versand { aktiv, laender: ['DE'] | ['DE','AT'], kostenCent { DE, AT? }, freiAbCent|null }`, `abholung { aktiv, adresse, hinweis ≤ 300 }` +(mind. eins aktiv), `lieferzeit` (Pflicht, 3–80, z. B. „2–4 Werktage“), `zahlungsarten`: Teilmenge von `card|paypal|klarna` (card immer enthalten), +`akzent` (#rrggbb, Standard #111111), `mindestbestellCent|null`, `ruecksendekosten: 'kaeufer'|'haendler'`. +`speichereEinstellungen(shop, einstellungen)` erhöht `version`, leert Kontext-Cache. Fehlerobjekt je Feld (deutsche Texte, Du-Form). + +### Task C3: Produkte (rein + CRUD) +`server/services/shop/produkte.js` + Tests. Produktfelder: `name` 1–120, `kategorie` ≤ 40, `kurz` ≤ 300, `beschreibung` ≤ 5000, `preisCent` (> 0, ≤ 10 000 000), +`steuersatz` ∈ 0|7|19 (bei Kleinunternehmer ignoriert ⇒ 0), `grundpreis {menge, einheit} | null`, `bestand` int ≥ 0 | null (unbegrenzt), `aktiv`, +`varianten { name ≤ 30, werte: [{ id (8 Zeichen, server), name ≤ 40, preisCent, bestand|null }] ≤ 30 } | null` (mit Varianten gilt Preis/Bestand der Variante), +`bilder: [imageId] ≤ 4`, `lieferart: 'beides'|'abholung'`, `widerruf: 'gesetzlich'|'verderblich'|'individuell'|'hygiene'`, +`sicherheit { hersteller: 'selbst' | { name, anschrift, kontakt }, euVerantwortlicher? { name, anschrift, kontakt }, hinweise ≤ 2000 }`, +`lebensmittel { aktiv, pflichtangaben (Pflicht wenn aktiv, ≤ 4000) }`. +CRUD auf `shopProducts` (Feld `produkt` = JSON), Grenze `config.shop.maxArtikel`; `produktOeffentlich` liefert nur Anzeigefelder + `preisText`, `grundpreisText`, +`verfuegbar` (Bestand minus Reservierung > 0 bzw. je Variante), `ausverkauft`, `widerrufText` (z. B. „Vom Widerruf ausgeschlossen: schnell verderbliche Ware“), Bild-URLs +`/api/public/shop/v1//bild/-s.webp` und `-l.webp`. + +### Task C4: Warenkorb-Berechnung (rein) +`server/services/shop/preise.js` + Tests (viele Fälle). `berechne({ produkte /*Map id⇒produkt mit id*/, positionen: [{ produktId, varianteId?, menge }], lieferart: 'versand'|'abholung', land, einstellungen, reserviert })`: +Prüft: 1–50 Positionen, Menge 1–99 ganzzahlig, Produkt aktiv, Variante passt, Bestand (minus `reserviert`) reicht, `abholung` nur wenn aktiv; `versand` nur wenn aktiv, Land erlaubt und **kein** Artikel `lieferart:'abholung'`; +Mindestbestellwert. Versandkosten = `kostenCent[land]`, 0 ab `freiAbCent` (Zwischensumme ≥ Schwelle) oder bei Abholung. Steuer: bei Kleinunternehmer keine; sonst je Satz enthaltene USt = round(brutto * satz / (100 + satz)) je Satz über die Summe der Positionen, +Versandkosten anteilig nach Bruttowert der Sätze (Rest-Cent auf den höchsten Satz). Ergebnis `{ ok:true, positionen:[{ produktId, varianteId, name, varianteText, menge, einzelCent, summeCent, steuersatz, widerruf, einzelText, summeText }], zwischensummeCent, versandCent, gesamtCent, steuer:[{ satz, betragCent, text }], texte:{ zwischensumme, versand, gesamt, steuerHinweis } }` +(`steuerHinweis`: „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“ oder „inkl. 19 % USt: 3,19 €; inkl. 7 % USt: …“), Fehler `{ ok:false, fehler: { code, text, produktId? } }` mit Sie-Texten für Käufer. + +### Task C5: Muster-Rechtstexte des Händlers +`server/services/shop/rechtstexte.js` + `docs/funktionen/shop-muster-rechtstexte.md` (Quelle, gleicher Wortlaut, Kopf „Muster – keine Rechtsberatung“) + Tests (Pins). +- `musterWiderrufsbelehrung(e)`: gesetzliches Muster für Waren (Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB) in aktueller Fassung **inkl. Hinweis auf die Widerrufsfunktion** (nach `recherche-recht-stripe.md` B2/B3), + Anbieter-Name/Anschrift/E-Mail eingesetzt, Variante Rücksendekosten (Käufer trägt unmittelbare Kosten / Händler trägt), Fristbeginn für eine Ware bzw. mehrere getrennt gelieferte Waren (ein Text mit beiden Varianten laut Gestaltungshinweis), + Ausschlussgründe als Absatz „Ausschluss des Widerrufsrechts“ (§ 312g Abs. 2 Nr. 1, 2, 3 BGB). +- `musterWiderrufsformular(e)` (Anlage 2). `musterAgb(e)`: kurze Muster-AGB (Geltung, Vertragspartner, Vertragsschluss: Bestellung durch Klick „Zahlungspflichtig bestellen“, Annahme durch Bestellbestätigung per E-Mail nach Zahlungseingang; Preise und Versandkosten; Zahlung über Stripe, Zahlungsarten; Lieferung, Lieferzeit, Liefergebiet, Abholung; Eigentumsvorbehalt; Widerrufsrecht Verweis; Gewährleistung gesetzlich; Vertragstext wird nicht vom Anbieter gespeichert, Bestellbestätigung enthält ihn; Sprache Deutsch; Streitbeilegung: keine Bereitschaft/Verpflichtung zur Teilnahme an Verbraucherschlichtung (§ 36 VSBG)). +- `versandUndZahlung(e)` (erzeugt, nicht frei editierbar). `normalisiereRechtstexte({ agb ≤ 30000, widerrufsbelehrung ≤ 20000, widerrufsformular ≤ 5000, impressumUrl, datenschutzUrl (https) })`, `rechtstexteHash`. + +### Task C6: Kontext und Bereitschaft +`server/services/shop/kontext.js` + Tests: Laden per Projekt und per siteKey (`findActiveBySiteKey(siteKey,'shop')` + shopConfig + Projekt), JSON-Felder sicher parsen (kaputt ⇒ Standard), Cache 30 s. +`bereitschaft(ctx)` Punkte: `einbau` (state active), `stripe` (`stripeStatus.charges_enabled`), `rechtstexte` (bestätigt und Hash = aktueller Hash), `einstellungen` (normalisiert ok), `artikel` (≥ 1 aktiv und lieferbar), jeweils mit Du-Text. +`istGeoeffnet(ctx)` = `shop.geoeffnet && alle Punkte außer rechtstexte-Hash-Gleichheit ok && rechtstexteBestaetigt vorhanden` (nach Spec 4.6: geändert, aber früher bestätigt ⇒ bleibt offen mit der zuletzt bestätigten Fassung; dafür speichert C7 beim Bestätigen eine Kopie `rechtstexteBestaetigt` in `rechtstexte` JSON: `{ entwurf, bestaetigt }`). + +### Task C7: Kundenrouten Verwaltung +`server/routes/shop.js` + Tests (`test/shopRoutes.test.js`). Alle mit requireSession + eigenes Projekt + Funktion `setup|active` (lesend auch `disabled`), Rate-Limit 300/h je Kunde, Kundenansicht: Lesen ja; Schreiben von Artikeln/Einstellungen/Rechtstexten erlaubt (Guard-Liste ergänzen), **nicht** Stripe, Öffnen, Erstatten. +- `GET /api/shop/status?projectId=` (vollständig): Funktion (state, ticket), Anfrage, Bereitschaft, geoeffnet, stripe (Status ohne Geheimnisse), Zahlen. +- `GET|PUT /api/shop/einstellungen`, `GET /api/shop/produkte`, `POST /api/shop/produkte`, `PUT /api/shop/produkte/:id`, `DELETE /api/shop/produkte/:id`, `POST /api/shop/produkte/sortierung {ids}`. +- `GET /api/shop/rechtstexte` (inkl. `muster` zum Zurücksetzen und `versandUndZahlung`), `PUT /api/shop/rechtstexte` (speichert Entwurf), `POST /api/shop/rechtstexte/bestaetigen { hash }` (Haken-Text aus Spec 4.6; speichert Kopie, Zeit, Person, Hash). +- `POST /api/shop/oeffnen { projectId, offen: bool }` (öffnen nur wenn `bereitschaft.bereit`; schließen immer). +- Bilder: `POST /api/shop/bild?projectId=` (octet-stream ≤ 8 MB, Muster news.js) über `server/services/shop/bilder.js` (aus `newsImages.js` verallgemeinert: Wurzel `config.shop.uploadDir//`, 1600/480 px WebP, Sidecar, `claim` beim Produkt-Speichern, `purgeUnclaimed` 24 h, `deleteShopImage`, `deleteShopImages(siteKey)`); Vorschau `GET /api/shop/bild/:projectId/:datei`. + +--- + +## Block D: Stripe Connect + +### Task D1: Connect-Dienst + Fake-Stripe +`server/services/shop/connect.js`, `test/support/fakeStripe.js` (Header `Stripe-Account` aufzeichnen; `POST /v1/accounts`, `GET /v1/accounts/:id`, `POST /v1/account_links`, Checkout-Sessions je Konto, `POST /v1/refunds`; vorhandenes Verhalten unverändert), `test/shopConnect.test.js`. +- `kontoAnlegen`: `accounts.create({ country:'DE', email, business_profile:{ url: liveUrl|undefined, name: projectName }, controller:{ fees:{payer:'account'}, losses:{payments:'stripe'}, stripe_dashboard:{type:'full'}, requirement_collection:'stripe' }, capabilities:{ card_payments:{requested:true}, transfers:{requested:true} }, metadata:{ webklar:'shop', customerId, projectId } })` – genaue Parameter nach `recherche-recht-stripe.md` A1 prüfen. +- `onboardingLink`: `accountLinks.create({ account, type:'account_onboarding', refresh_url, return_url, collection_options:{fields:'eventually_due'} })`. +- `kontoStatus`: `accounts.retrieve` ⇒ `{ charges_enabled, payouts_enabled, details_submitted, currently_due: [...], disabled_reason, geprueftAt }`. +- `checkoutAnlegen(accountId, params)` = `checkout.sessions.create(params, { stripeAccount: accountId, idempotencyKey })`; `sessionHolen`; `erstatten` = `refunds.create({ payment_intent, amount, reason:'requested_by_customer', metadata }, { stripeAccount })`. +- `connectEreignis(raw, sig)`: 503 wenn `config.shop.connectWebhookSecret` leer, sonst `getStripe().webhooks.constructEvent(raw, sig, secret)`. + +### Task D2: Kundenrouten Stripe +`server/routes/shop.js`: `POST /api/shop/stripe/einrichten { projectId }` (kein viewAs; legt Konto an falls keins, speichert `stripeAccountId` unter Sperre, liefert `{ url }` Onboarding), +`POST /api/shop/stripe/link { projectId }` (neuer Onboarding-Link), `POST /api/shop/stripe/aktualisieren { projectId }` (Status neu lesen + speichern). Rücksprung `dashboard.html#webshop?stripe=zurueck&projekt=`, refresh `...?stripe=neu&projekt=`. Tests mit Fake-Stripe. + +### Task D3: Connect-Webhook +`server/routes/shopWebhook.js` (Mount `POST /api/shop/stripe-webhook` in index.js **vor** `/api/shop`-Router, nutzt `req.rawBody` des globalen Parsers), Tests mit signierten Events (eigenes Test-Secret). +Ereignisse: `account.updated` ⇒ `stripeStatus` des Shops mit diesem Konto (Index `idx_account`); `checkout.session.completed` (payment_status `paid`) und `checkout.session.async_payment_succeeded` ⇒ `schliesseAb({ accountId: event.account, session })`; +`checkout.session.async_payment_failed` ⇒ `verwirf(..., 'zahlung_fehlgeschlagen')` + G14; `checkout.session.expired` ⇒ `verwirf(..., 'abgelaufen')`; `charge.refunded` ⇒ Erstattung nachtragen (nur wenn noch nicht über das Portal gebucht, über `refund.id`). +Nur Sessions mit `metadata.wkShop === '1'` werden behandelt; unbekannte ⇒ 200 ohne Wirkung. Fehler ⇒ 500 (Stripe wiederholt), alles idempotent. + +--- + +## Block E: Bestellungen + +### Task E1: Bestellung anlegen (Server-Kern) +`server/services/shop/bestellungen.js` + Tests. `legeBestellungAn(ctx, { positionen, lieferart, land, rueckUrl }, { now })`: +unter `withLocks(['shop-bestand:'+shopId])`: `istGeoeffnet` (sonst 409 `geschlossen`), Produkte laden, `reserviert(shopId, now)` (Summe der `offen`-Bestellungen mit `ablaufAt > now`), `berechne`; Bestellnummer `JJ-XXXXXX` (base32 ohne 0/1/O/I, eindeutig im Shop), Token 32 Byte (nur Hash speichern), +Dokument `offen` mit Schnappschuss `{ positionen, summen, lieferart, land, einstellungenAuszug (Anbieter, Lieferzeit, Zahlungsarten), rechtstexteHash der bestätigten Fassung, verlauf:[{at, was:'angelegt'}] }`, `ablaufAt = now + 30 min`; +dann Checkout auf dem Konto: `mode:'payment'`, `line_items` (je Position `price_data{ currency:'eur', unit_amount, product_data:{ name: 'Name – Variante', images? (öffentliche absolute Bild-URL) } }, quantity`), Versand als `shipping_options:[{ shipping_rate_data:{ type:'fixed_amount', fixed_amount:{amount, currency:'eur'}, display_name:'Versand' | 'Abholung', delivery_estimate? } }]`, +`shipping_address_collection:{ allowed_countries }` nur bei Versand, `billing_address_collection:'required'`, `phone_number_collection:{enabled:true}`, `payment_method_types` aus Einstellungen, `locale:'de'`, `expires_at` (30 min), `client_reference_id`, `metadata:{ wkShop:'1', shopId, orderId, bestellnummer }`, `payment_intent_data:{ metadata: gleich, description:'Bestellung ' }`, +`success_url` = geprüfte Rück-URL + `wk_bestellung=&wk_t=`, `cancel_url` = Rück-URL + `wk_abbruch=1`. Rück-URL nur, wenn Host ∈ erlaubte Hosts (`analyseDomains(project)` + Portal-Host), sonst `https://project.webklar.com/shop/`. +Scheitert Stripe ⇒ Bestellung `storniert` (Reservierung frei) und 503 mit Käufertext; PayPal/Klarna nicht freigeschaltet ⇒ einmal mit nur `card` wiederholen und dem Händler einen Hinweis in `stripeStatus.hinweis` speichern. + +### Task E2: Abschluss, Verwerfen, Ablauf +`schliesseAb({ accountId, session, now })`: Sperre `shop-order:`; Konto muss zum Shop passen; nur aus `offen` (oder `storniert` mit `grund:'abgelaufen'` und bezahlt ⇒ trotzdem bezahlt, Bestand ggf. negativ ⇒ Händler-Hinweis in K25 Block `ueberverkauft`); `amount_total` muss `summeCent` entsprechen (sonst trotzdem bezahlt, Verlauf-Vermerk + Team-Log); +setzt `bezahlt`, `bezahltAt`, `paymentIntentId`, Käuferdaten aus `customer_details` (name, email, phone, address) und `shipping_details`/`collected_information.shipping_details`, zieht Bestand ab (Produkte unter `shop-bestand`-Sperre), schickt G09 (Käufer) und K25 (Händler). Rückgabe `{ order, neu }`. +`verwirf(orderId, grund)` nur aus `offen`. Minutenjob (`server/services/shop/jobs.js` `minutenJob(now)`): `offen` mit `ablaufAt < now - 5 min` ⇒ Session bei Stripe lesen: `complete`+`paid` ⇒ `schliesseAb`, `expired`/`open` ⇒ (open: `checkout.sessions.expire`) `verwirf('abgelaufen')`; Lesefehler ⇒ nächster Lauf. + +### Task E3: Händler-Aktionen, Erstattung, CSV +`setzeStatus(shop, orderId, aktion)`: `versendet { versanddienst?, sendungsnummer? }` (aus bezahlt) ⇒ G10; `abholbereit` (aus bezahlt, Lieferart abholung) ⇒ G11; `abgeschlossen` (aus bezahlt|versendet|abholbereit); `storniert` (nur offen). +`erstatte(shop, orderId, { betragCent, grund })`: nur bezahlt/versendet/abholbereit/abgeschlossen, Betrag ≤ Rest; `erstatten()` mit Idempotenz-Schlüssel; Eintrag in `erstattungen[]`, voll ⇒ Status `erstattet`; G13 an Käufer. Fehler ⇒ 502 mit Du-Text. +`csv(shopId, von, bis)`: `;`-getrennt, UTF-8 mit BOM, Spalten Bestellnummer, Datum, Status, Name, E-Mail, Lieferart, Adresse, Positionen, Versand, Gesamt, Erstattet; Formel-Injektion verhindern (Zellen mit `= + - @` voranstellen `'`). +Kundenrouten: `GET /api/shop/bestellungen?projectId=&status=&seite=`, `GET /api/shop/bestellungen/:id`, `POST /api/shop/bestellungen/:id/aktion { aktion, ... }`, `POST /api/shop/bestellungen/:id/erstatten`, `GET /api/shop/bestellungen.csv?projectId=&von=&bis=`, `GET /api/shop/widerrufe`, `POST /api/shop/widerrufe/:id/erledigt`. + +### Task E4: Mails +Vorlagen G09–G14 (Muster `G03 …`: Gästelayout, `{{farbe}}`, `{{betrieb_name}}`, Sie-Form, kein WEBklar-Logo; Fußzeile mit Anbieter-Impressumsdaten, Link „Vertrag widerrufen“ `{{widerruf_url}}`), K24 (B2), K25, K26 (K-Layout, Du-Form), T21 (B1). +**G09** Bestellbestätigung: Betreff „Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}“; Anbieter (Name, Anschrift, E-Mail), Bestellnummer, Datum, Positionen (vorgerenderter Text/HTML-Tabelle aus dem Server, Platzhalter `positionen_html` wird **nicht** escaped? – Nein: die Vorlage-Engine escaped immer; deshalb Positionen als Textblock `{{positionen}}` mit Zeilenumbrüchen), Zwischensumme, Versand, Gesamt, Steuerhinweis, Lieferart + Adresse/Abholort, Lieferzeit, Zahlungsart, Widerrufsausschlüsse (Block), **vollständige AGB, Widerrufsbelehrung, Muster-Widerrufsformular** (Blöcke mit Text), Link Widerrufsfunktion. +`server/services/shop/mails.js`: `anKaeufer(art, ctx, order, extra, now)` ⇒ `einreihen({ featureId: ctx.doc.$id, bookingId: order.$id, art, an, fromName: shopname, replyTo: anbieter.email, template: art, values, blocks })` (Outbox `art` ≤ 16; die Outbox prüft Budget; Grenze 3 Mails/h je Empfänger über `limits.darfEmpfaenger` beachten – für G09 nicht anwenden, G09 ist Vertragspflicht); +`anHaendler(template, ctx, values, blocks)` ⇒ `sendCustomerMail(kundenEmail, {..., bccTeam:false})`. Outbox-Erweiterung: `GAST_ARTEN`/Budgetklassen so ändern, dass G09–G14 als Gästemails zählen (G09, G12 Vorrang); der Mail-Job läuft auch, wenn `TERMINE_ENABLED=off` aber `SHOP_ENABLED≠off` (in `termine/jobs.js` die Bedingung für den Mailteil erweitern, sonst unverändert). `test/mailTemplates.test.js` (EXPECTED, GUEST-Liste + Platzhaltertabellen, K-Liste). + +### Task E5: Widerrufe (§ 356a BGB) +`server/services/shop/widerrufe.js` + Tests: `erfasseWiderruf(ctx, { name, email, bestellnummer, nachricht }, { now, ip })`: Pflicht Name (2–200), E-Mail, Bestellnummer (≤ 32; muss nicht existieren); passende Bestellung (Nummer + E-Mail gleich) ⇒ `orderId` + Verlauf; speichert, schickt sofort **G12** Eingangsbestätigung (Inhalt der Erklärung, Datum und Uhrzeit Berlin, Bestellnummer) und **K26** an den Händler; Antwort `{ ok, eingang: 'TT.MM.JJJJ, HH:MM Uhr' }`. Grenzen `darfWiderrufsversuch` (10/IP/h) + 20/Shop/h. + +--- + +## Block F: Öffentliche API + +### Task F1: Lesen +`server/routes/publicShop.js` (Mount `/api/public/shop/v1` **vor** dem globalen Parser, eigenes `express.json({limit:16kb})`, Antwortmuster wie `publicTermine.js schreibe()`), Tests `test/publicShop.test.js`. +`GET /:siteKey` ⇒ `{ aktiv:false }` (unbekannt/inaktiv/aus/nicht verfügbar, kein Orakel) oder `{ aktiv:true, offen: istGeoeffnet, shop:{ name, akzent, anbieter (Name, Anschrift, E-Mail, Telefon), lieferzeit, lieferlaender:[{code,name}], abholung:{aktiv, adresse, hinweis}, zahlungsarten:[Texte], versandText, mindestbestellText, kleinunternehmer, steuerHinweisKurz }, kategorien, produkte:[produktOeffentlich], rechtstexte:{ agb, widerrufsbelehrung, widerrufsformular, versandUndZahlung, impressumUrl, datenschutzUrl } (bestätigte Fassung), widerrufUrl, formToken }`. +Rechtstexte nur, wenn eine bestätigte Fassung existiert. `GET /:siteKey/bild/:datei` (`^[a-z0-9-]{1,64}-(s|l)\.webp$`, Bild muss zu einem Produkt des Shops gehören, Cache 1 Tag, nosniff). Grenzen `darfAnfrage` 120/IP/min, 600/Shop/min. CORS GET `*`. + +### Task F2: Bestellung und Status +`POST /:siteKey/bestellung { positionen, lieferart, land, rueckUrl, formToken, kontakt_url (Honeypot) }`: Herkunftsprüfung wie `erlaubteHerkunft` (Origin-Host ∈ Projektdomains + Portal-Host), Formtoken (HMAC, ≥ 3 s), Honeypot ⇒ scheinbarer Erfolg ohne Wirkung (`{ url: rueckUrl }`), Grenzen 10/IP/10 min + 60/Shop/h, Mindestantwortzeit 300 ms; ⇒ `{ url }` (Stripe) oder Fehler `{ fehler:{ _: text }, code }`. +Vorab-Berechnung ohne Anlegen: `POST /:siteKey/pruefen { positionen, lieferart, land }` ⇒ Ergebnis von `berechne` (Texte), damit die Kasse die Zusammenfassung **vom Server** zeigt. +`GET /:siteKey/bestellung/:id?t=` ⇒ Token prüfen (timingSafeEqual auf Hash) ⇒ bei `offen` einmal `sessionHolen` + ggf. `schliesseAb`; Antwort `{ status, bestellnummer, gesamtText, lieferart, email (maskiert) }`. + +### Task F3: Widerruf und Seiten +`POST /:siteKey/widerruf { name, email, bestellnummer, nachricht, formToken, kontakt_url }` (Origin: Projektdomains + Portal-Host; Formtoken; Grenzen; Mindestantwortzeit) ⇒ `{ ok:true, eingang }`. +Funktioniert auch bei geschlossenem, aber `active`/`disabled`-Shop (Widerrufsrecht läuft weiter); nur `off`/unbekannt ⇒ `{aktiv:false}`. + +--- + +## Block G: Portal-Oberfläche + +### Task G1: Verdrahtung, Kachel, Bestellseite verallgemeinern +`public/dashboard.html` (`/shop.css`, `
` mit Kopf „Webshop“ + `#webshop-body`), `public/app.js` (`FEATURE_LABELS.shop`, `VIEW_HASHES.webshop`, `viewRefreshers.webshop` ⇒ `import('/shop.js').then(m => m.initShop(...))`, `APP_PARENT.webshop = 'funktionen'` am Ende), `public/funktionen.js` (Kachel „Webshop“: Zustände laut Spec-Tabelle 2; Text „Dein eigener Online-Shop auf Deiner Website. Zahlungen direkt auf Dein Stripe-Konto. 299 € einmalig.“; Klick ⇒ `#webshop` mit Wunschprojekt), `public/funktion-bestellen.js` (Checkout-Pfad und Zusammenfassung über Parameter `key`/`zusammenfassung`, Termine-Verhalten unverändert; Tests bleiben grün), Tests (`funktionenTiles.test.js`, neue statische Verdrahtungstests). + +### Task G2: Hülle `#webshop`, Anfrage, Status +`public/shop.js` (Hülle nach `termine.js`: Rückkehr lesen `?bestellt=1`, `?stripe=…`, Projektwahl, Zustände: nicht verfügbar ⇒ Infokarte; keine Anfrage ⇒ Anfrageformular (`public/shop-anfrage.js`); Anfrage offen/abgelehnt ⇒ Karte; freigegeben + `off` ⇒ Bestellseite (`funktion-bestellen.js` mit key `shop`, Belehrung/Unterlagen aus `GET /api/funktionen` `features.shop` + `legal.shop`); `ordered` ⇒ Statuskarte „Zahlung fortsetzen“; `setup|active|disabled` ⇒ Reiter), `public/shop-dom.js` (Helfer: `h`, `request`, Felder – Kopie der termine-dom-Helfer, nicht importieren), `public/shop.css` (Tokens `--kb-*`, Reiterleiste 5 Spalten, mobil scrollbar), Test `test/shopView.test.js` mit `installFakeDom`. + +### Task G3: Reiter Übersicht + Stripe +`public/shop-tab-uebersicht.js`: Checkliste (Bereitschaft vom Server), Schalter „Shop geöffnet“, Stripe-Karte (Einrichten / Angaben ergänzen / Status aktualisieren / Dashboard-Link `https://dashboard.stripe.com/`), Vorschau-Link `https://project.webklar.com/shop/`, Einbau-Snippet nur lesen, Kennzahlen. Test. + +### Task G4: Reiter Artikel +`public/shop-tab-artikel.js`: Liste mit Hoch/Runter, Aktiv-Schalter, Editor (alle Felder aus C3, Varianten-Editor, Bild-Upload mit Vorschau/Entfernen/Reihenfolge, Lebensmittel-/Sicherheits-Abschnitte einklappbar, Grundpreis), Speichern mit Feldfehlern. Test. + +### Task G5: Reiter Bestellungen +`public/shop-tab-bestellungen.js`: Filter, Liste, Detail, Aktionen (versendet mit Sendungsnummer, abholbereit, abgeschlossen, erstatten mit Betrag + Bestätigung), Widerrufe-Liste mit „erledigt“, CSV-Download (Zeitraum). Test. + +### Task G6: Reiter Einstellungen + Rechtliches +`public/shop-tab-einstellungen.js` (alle Felder C2, Hinweis bei PayPal/Klarna „nur einschalten, wenn in Deinem Stripe-Konto aktiviert“), `public/shop-tab-rechtliches.js` (AGB/Widerrufsbelehrung/Formular bearbeiten, „Muster einsetzen“, Versand & Zahlung nur lesen, Impressum-/Datenschutz-URL, Bestätigungshaken + Knopf, Banner wenn Änderungen unbestätigt, Hinweis „Muster ohne Gewähr; lass Deine Texte prüfen, z. B. von einer Kanzlei oder einem Händlerbund“). Test. + +--- + +## Block H: Widget und öffentliche Seiten + +### Task H1: `public/embed/shop.js` +ES5-IIFE nach `embed/termine.js` (Light DOM, `@layer wks`, Präfix `wks-`, `data-webklar-shop`, `data-api`, `--wks-akzent`, `credentials:'omit'`, 20 s Timeout, Ausweichtext bleibt bei Fehlern). Ansichten: Raster (Kategorien-Filter), Detail (Galerie, Beschreibung, Preis + Grundpreis + Steuerhinweis, Varianten, Menge, Lieferhinweis, Widerrufsausschluss, Produktsicherheit, Lebensmittelangaben), Warenkorb (Zustand im Speicher **und** `sessionStorage` mit try/catch, nur IDs/Mengen), Kasse (Lieferart/Land, Zusammenfassung **vom Server** `/pruefen`, Pflichtinformationen, Links AGB/Widerrufsbelehrung/Versand, Hinweis „Name, Adresse und Zahlung geben Sie im nächsten Schritt bei Stripe ein“, Knopf **„Zahlungspflichtig bestellen“**), Rückkehr (`wk_bestellung`/`wk_t` ⇒ Status anzeigen, `wk_abbruch` ⇒ Warenkorb; danach `history.replaceState` bereinigen), Widerruf (Schaltfläche „Vertrag widerrufen“ ⇒ Formular ⇒ Knopf **„Widerruf bestätigen“** ⇒ Eingangsanzeige mit Zeit). Kopfzeile: Lieferländer + Zahlungsarten (§ 312j Abs. 1). Fußzeile: AGB · Widerrufsbelehrung · Versand & Zahlung · Vertrag widerrufen · Impressum · Datenschutz. Geschlossen ⇒ „Der Shop ist gerade geschlossen.“ + Widerruf weiter erreichbar. Rechtstexte als Overlay/`
` mit `textContent` (Absätze per `\n\n`). Keine Zahlen-/Datumsformatierung im Widget (alles vom Server). Statische Tests `test/shopEmbedStatic.test.js` (kein `innerHTML` mit Fremddaten, kein `eval`, Knopftexte vorhanden). + +### Task H2: Seiten und Demo +`public/shop-seite.html` (neutrale Seite mit Widget; Server-Route `GET /shop/:siteKey` und `GET /shop/:siteKey/widerruf` (siteKey-Regex, sonst 404) liefert die Datei; `?ansicht=widerruf` öffnet direkt den Widerruf), `public/embed/shop-demo.html`, Test der Route. + +--- + +## Block I: WOMS (eigener Worktree) + +### Task I1: Funktionen-Karte + Anfrage +WOMS `src/lib/funktionenView.js` (`FUNKTIONEN` + `shop` mit Bestätigungstexten; `aktionen` für shop wie termine: setup ⇒ abnahme/ticket/disable), `src/lib/teamApi.js` (`getShop`, `entscheideShopAnfrage`), `src/components/customer/CustomerFunktionen.jsx` (Webshop-Panel je Projekt: Anfrage lesen, „Anfrage annehmen“/„Ablehnen“ mit Begründung, Stripe-Status, Kennzahlen), `scripts/setup-legal.mjs` (`DOCUMENTS` + `leistungsbeschreibung-webshop`, `avv-webshop`; Test-Anzahl anpassen), Tests `npm test` + `nice npx vite build`. + +--- + +## Block J: Rechtstexte, Jobs, Aufräumen + +### Task J1: Leistungsbeschreibung + AVV Webshop +`docs/rechtliches/leistungsbeschreibung-webshop.md`, `docs/rechtliches/avv-webshop.md` (Inhalt Spec 10; Muster LB/AVV Terminplaner; Fassung `config.shop.fassung`), `scripts/build-legal-html.mjs` und `scripts/build-legal-pdfs.sh` um Satz/Modus `shop` erweitern, HTML + PDF + `.sha256` erzeugen (Chrome headless wie vorhanden, `timeout 120`), Tests `legalDocs`, `buildLegalHtml` erweitern; danach `featureLegalReady('shop') === true`. + +### Task J2: Datenschutz, Widerruf der Funktion, Doku +`public/datenschutz.html` Abschnitt „Webshop“ (Stand-Datum, PDF neu über `build-legal-pdfs.sh datenschutz`), `server/routes/abo.js` (`ORDERS`, `FEATURE_OF_ORDER`, `orderLabel` um `shop`) + `public/widerrufen.html` (Option „Webshop (Einrichtung)“), `docs/funktionen/shop-einbau.md`, `docs/funktionen/shop-datenschutz-absatz.md` (Muster für die Händler-Website), `docs/rechtliches/webshop-kanzlei-paket.md` (Fragen + Fundstellen), Tests. + +### Task J3: Jobs, Aufbewahrung, Kundenlöschung +`server/services/shop/jobs.js` (`minutenJob` aus E2 + Bilder-Aufräumen stündlich; `taeglicherJob`: Käuferdaten anonymisieren 3 Jahre nach Ende des Bestelljahres, unbezahlte/stornierte nach 30 Tagen; Shops `disabled` seit > 90 Tagen: Produkte, Bestellungen, Widerrufe, Bilder, shopConfig löschen, Dokument bleibt `disabled`; seitenweise, Deckel je Lauf), `server/index.js` (Jobs registrieren, nur wenn `shopModus() !== 'off'` oder es Shop-Dokumente gibt), `server/services/customerAdmin.js` (Kundenlöschung: `disableCustomerFeatures(customerId,'shop',…)`, offenen Shop schließen), `retention.js` nur falls nötig. Tests. + +--- + +## Block K: Abschluss (Controller) +- [ ] K1 ganze Suite (Host, `--max-old-space-size`), Node-20-Lauf der Shop-Tests im Container. +- [ ] K2 Gesamt-Review (2 sonnet-Reviewer: Geld/Stripe/Bestellungen; Recht/UI/Widget), Fixrunde. +- [ ] K3 End-to-End mit Mocks: Server mit Appwrite-Mock + Fake-Stripe + SMTP-Senke, Playwright: Anfrage → Freigabe → Bestellung → Artikel → Öffnen → Kauf im Widget → Webhook → Mails → Versand → Erstattung → Widerruf; Screenshots nach `/root/reports/webshop-2026-10-05/ui/`. +- [ ] K4 Livegang-Anleitung `docs/funktionen/shop-livegang.md` (Spec 11) und Memory/Ledger aktualisieren. diff --git a/docs/superpowers/specs/2026-10-05-webshop-design.md b/docs/superpowers/specs/2026-10-05-webshop-design.md new file mode 100644 index 0000000..0010595 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-webshop-design.md @@ -0,0 +1,346 @@ +# Webshop (Funktion `shop`): Design + +Stand 2026-10-05. Basis: Portal-Zweig `feature/terminplaner-tp2` af63b69 (Register `config.features`, generischer Checkout, +Zustimmungsnachweis `featureConsents`, versionierte Rechtsunterlagen). Arbeitszweig `feature/webshop`, Worktree `dev-webshop`. +Pfade: **P** = Portal-Repo (`kundenbereich.webklar.com`), **W** = WOMS-Repo (`ticket.webklar.com`). +Fakten aus dem Code: `/root/reports/webshop-2026-10-05/fakten-*.md`; Recht und Stripe: `recherche-recht-stripe.md`. + +Auftrag des Inhabers (2026-10-05, `/goal`): „Online-Webshop-Integration, auf Anfrage, einmalige Kosten, der Kunde +konfiguriert alle Artikel, Stripe-Anbindung (PayPal optional, nur Stripe ist ok), keine Rückfragen, Recht beachten.“ +Alle Entscheidungen unten (W1–W30) hat Claude ohne Rückfrage getroffen; sie stehen gesammelt in Abschnitt 12. + +--- + +## 1. Ziel und Abgrenzung + +**Der Webshop liefert:** Ein Kunde fragt im Portal einen Webshop für eine seiner Websites an. Das Team prüft die Anfrage +(was wird verkauft, ist es erlaubt, passt es) und gibt sie frei. Danach bestellt der Kunde die Einrichtung (einmalig +299 €, Stripe-Checkout wie bei den anderen Funktionen). Es entsteht ein Einrichtungs-Ticket, das Team baut den Shop in +die Website ein. Der Kunde verbindet sein **eigenes** Stripe-Konto (Stripe Connect), pflegt Artikel, Versand, Abholung +und seine Rechtstexte im Portal und öffnet den Shop. Endkunden kaufen im Widget auf seiner Website und bezahlen über +Stripe Checkout direkt an den Kunden. Bestellungen, Versand, Erstattung und Widerrufe verwaltet der Kunde im Portal. + +**Nicht Teil (V1):** digitale Produkte/Downloads, Abos/wiederkehrende Zahlungen, Gutscheine/Rabattcodes, Versand +außerhalb Deutschlands und Österreichs, Lagerverwaltung über mehrere Lager, Rechnungserstellung durch WEBklar (die +Stripe-Quittung und die Bestellbestätigung gehen an den Käufer, Rechnungen schreibt der Händler selbst), Anbindung +eines schon bestehenden Stripe-Kontos per OAuth (später), alkoholische Getränke, Tabak, Arzneimittel, Waffen, +Erotik, Lebende Tiere und alles auf Stripes Liste verbotener Geschäfte (Bestellgate + Leistungsbeschreibung). + +**Warum nur Stripe (W1):** Stripe läuft schon für Abo und Funktionen. Stripe Checkout bietet PayPal, Klarna, Apple Pay +und Google Pay als Zahlungsarten **innerhalb** desselben Checkouts an, wenn der Händler sie in seinem Stripe-Konto +einschaltet. Eine eigene PayPal-Anbindung hätte eine zweite Integration, einen zweiten Webhook und eine zweite +Erstattungslogik bedeutet, ohne dem Käufer eine Zahlungsart mehr zu geben. + +**Warum Stripe Connect mit eigenem Konto des Kunden (W2):** Das Geld fließt direkt vom Käufer an den Händler (Direct +Charges auf dem verbundenen Konto). WEBklar nimmt nie fremdes Geld an, behält keine Gebühr ein und haftet nicht für +Rückbuchungen. Damit ist WEBklar kein Zahlungsdienst im Sinne des ZAG (Stripe ist das lizenzierte Institut), und der +Händler hat sein volles Stripe-Dashboard (Auszahlungen, Erstattungen, Belege). Konten werden mit den +`controller`-Eigenschaften angelegt: Stripe-Dashboard voll, Gebühren zahlt das Konto, Verluste trägt Stripe/Konto, +Anforderungen sammelt Stripe (Gegenstück zum früheren `type: 'standard'`). + +**Pilot und Schalter (W3):** `SHOP_ENABLED=off|pilot|on` wie `TERMINE_ENABLED`; `pilot` nur für `SHOP_PILOT_EMAILS`. +Pilotkunde ist das Testkonto (kenso.gri@gmail.com, Projekt `test-website`). + +--- + +## 2. Ablauf aus Kundensicht + +| Zustand | Kachel (Funktionen-Tab) | Ansicht `#webshop` | Mail | +|---|---|---|---| +| nichts angefragt | „Webshop anfragen“ | Anfrageformular | – | +| Anfrage offen | „Anfrage wird geprüft“ | Anfrage lesen, zurückziehen | T21 an Team | +| Anfrage abgelehnt | „Anfrage abgelehnt“ + Begründung, neue Anfrage möglich | Begründung | K24 | +| freigegeben (`off`) | „Webshop bestellen · 299 €“ | Bestellseite (generisch) | K24 | +| `ordered` | „Zahlung offen“ | Statuskarte, „Zahlung fortsetzen“ | – | +| `setup` | „In Einrichtung“ | Statuskarte + **alle Reiter nutzbar** (Artikel, Stripe, Rechtliches) | K15, T16 | +| `active` | „Webshop öffnen“ | Verwaltung | K16 | +| `disabled` | „Webshop ansehen“ | lesen, CSV-Export | – | + +**Auf Anfrage (W4):** Der Inhaber will die Funktion „auf Anfrage“. Umsetzung als vorgeschaltete Freigabe: Die Kachel ist +für jeden Kunden mit Website-Projekt sichtbar (wenn der Schalter es erlaubt), bestellbar wird sie erst nach Freigabe +durch das Team. Die Anfrage fragt: Was wird verkauft (Freitext + Warenart: Waren/Lebensmittel/Kleidung/Kunst/Sonstiges), +ungefähre Artikelzahl, Versand und/oder Abholung, Händler ist Unternehmer (Pflicht-Haken), keine der ausgeschlossenen +Waren (Pflicht-Haken), Nachricht ans Team. Das Team sieht die Anfrage im WOMS (Kunde → Funktionen) und nimmt sie an oder +lehnt sie mit Begründung ab. Der Preis bleibt fest (299 €); Sonderfälle (sehr viele Artikel, Sonderdesign) bespricht das +Team im Chat und rechnet Mehraufwand nach Stunden ab, wie beim Terminplaner (Leistungsbeschreibung). + +**Einmalige Kosten (W5):** 299 € einmalig (Kleinunternehmer, ohne USt gemäß § 19 UStG), kein Wartungs-Abo nötig, keine +laufende Gebühr von WEBklar, keine Umsatzbeteiligung. Der Shop läuft, solange WEBklar die Website betreibt (Hosting oder +Wartung). Stripe-Gebühren zahlt der Händler direkt an Stripe. + +**Ab `setup` nutzbar (W6):** Anders als beim Terminplaner (Einstellung ab Bestellung gesperrt) pflegt der Kunde ab +`setup` Artikel, Stripe-Konto und Rechtstexte, damit der Shop zum Einbau fertig ist. Die Website zeigt den Shop erst, +wenn alles erfüllt ist (Abschnitt 4.6, „Shop geöffnet“). + +--- + +## 3. Datenmodell (Appwrite, `scripts/shop-setup.mjs`, nur Server-Key) + +Alle Collections ohne Client-Rechte (wie `termineConfig`); alles läuft über den Portal-Server. + +- **`shopConfig`** (Dokument-ID = ID des `portalFeatures`-Dokuments; eins je Projekt): + `customerId`, `projectId`, `siteKey` (Kopie aus portalFeatures, Index), `anfrage` (JSON-Text ≤ 4000), + `anfrageStatus` (`''|offen|angenommen|abgelehnt|zurueckgezogen`), `anfrageAt`, `anfrageEntschiedenAt`, + `anfrageEntschiedenVon`, `anfrageBegruendung` (≤ 1000), `stripeAccountId`, `stripeStatus` (JSON ≤ 4000: + charges_enabled, payouts_enabled, details_submitted, requirements.currently_due, disabled_reason, geprüft am), + `einstellungen` (JSON ≤ 20000), `rechtstexte` (JSON ≤ 100000), `rechtstexteBestaetigtAt`, `rechtstexteBestaetigtVon`, + `rechtstexteFassung` (Hash), `geoeffnet` (bool), `geoeffnetAt`. +- **`shopProducts`**: `shopId` (= shopConfig-ID, Index), `customerId`, `aktiv` (bool), `sort` (int), `name` (≤ 120), + `daten` (JSON ≤ 30000: Kurz-/Langbeschreibung, Preis in Cent, Grundpreis-Menge+Einheit, Steuersatz, Bestand, + Varianten, Bilder, Lieferart, Widerrufsausschluss, Produktsicherheit, Lebensmittelangaben, Kategorie). +- **`shopOrders`**: `shopId` (Index), `customerId`, `bestellnummer` (eindeutig je Shop, Index), `status` (Index), + `tokenHash`, `stripeSessionId` (Index), `paymentIntentId`, `summeCent`, `erstelltAt`, `ablaufAt`, `bezahltAt`, + `kaeuferEmail` (≤ 320), `daten` (JSON ≤ 60000: Positionen als Schnappschuss, Lieferart, Adressen, Versandkosten, + Steuerausweis, Fassung der Rechtstexte, Verlauf), `anonymisiertAt`. +- **`shopWiderrufe`**: `shopId` (Index), `customerId`, `bestellnummer`, `name`, `email`, `nachricht` (≤ 2000), + `eingangAt`, `bestaetigungGesendetAt`, `bearbeitetAt`. +- **Bilder** auf der Platte unter `upload-data/shop//.` (gitignored, wie News-Fotos), ausgeliefert über + die öffentliche API. + +Bestellnummer (W7): `-<6 Zeichen base32>` (z. B. `26-K7QM2X`), zufällig, bei Kollision im Shop neu ziehen. Keine +fortlaufende Zählung nötig (kein Rechnungsnummernkreis, Rechnungen schreibt der Händler). + +--- + +## 4. Shop-Verwaltung im Portal (`#webshop`) + +Reiter: **Übersicht · Artikel · Bestellungen · Einstellungen · Rechtliches**. Projektwahl wie beim Terminplaner (Shop je +Website-Projekt). + +### 4.1 Übersicht +Checkliste „Bereit zum Öffnen“ mit Haken: Einbau fertig (`active`), Stripe verbunden und Zahlungen freigeschaltet +(`charges_enabled`), Rechtstexte vollständig und bestätigt, mindestens ein aktiver Artikel, Versand oder Abholung +eingerichtet. Schalter „Shop geöffnet“ (nur wenn alle Haken grün; serverseitig erneut geprüft). Kennzahlen: offene +Bestellungen, Bestellungen 30 Tage, Umsatz 30 Tage (nur Anzeige). Knopf „Shop-Vorschau“ (Standalone-Seite, Abschnitt 5.4). + +### 4.2 Stripe-Konto +Knopf „Stripe-Konto einrichten“: Server legt das verbundene Konto an (Land DE, E-Mail und Website des Kunden +vorbefüllt) und holt einen Onboarding-Link (Account Link), Weiterleitung zu Stripe; Rücksprung `#webshop?stripe=zurueck` +liest den Status neu. Danach: Status (Zahlungen aktiv / Auszahlungen aktiv / fehlende Angaben) und Knöpfe +„Angaben ergänzen“ (neuer Account Link) und „Stripe-Dashboard öffnen“ (dashboard.stripe.com). Status wird zusätzlich +per Connect-Webhook `account.updated` aktuell gehalten. + +### 4.3 Artikel +Liste (sortierbar mit Hoch/Runter, aktiv/inaktiv, Bestand) und Editor: +- Name, Kategorie (frei, gruppiert im Shop), Kurzbeschreibung (≤ 300), Beschreibung (≤ 5000, Absätze, kein HTML). +- Preis brutto in € (PAngV: Gesamtpreis), Steuersatz (19 / 7 / 0 % – bei Kleinunternehmer ausgeblendet). +- Grundpreis (PAngV § 4): Menge + Einheit (g, kg, ml, l, m, m², Stück-los) → Anzeige „x € / 1 kg“ bzw. „/ 100 g“ nach + § 5 PAngV (bei ≤ 250 g/ml darf 100 g/ml verwendet werden; V1 rechnet immer auf 1 kg / 1 l / 1 m / 1 m²). +- Bestand (leer = unbegrenzt) und „ausverkauft anzeigen“ statt ausblenden. +- Varianten (optional, eine Dimension, z. B. „Größe“): je Wert eigener Preis und Bestand (max 30). +- Bilder: bis 4, JPEG/PNG/WebP ≤ 8 MB, serverseitig geprüft (Signatur), erstes = Titelbild. +- Lieferart: Versand und Abholung / nur Abholung. +- Widerruf (§ 312g Abs. 2 BGB): „gesetzliches Widerrufsrecht“ (Standard) oder Ausschluss mit Grund: schnell + verderblich, nach Kundenwunsch angefertigt, versiegelt aus Hygienegründen. Der Shop zeigt den Hinweis am Artikel und in + der Bestellübersicht, die Bestellbestätigung nennt ihn. +- Produktsicherheit (GPSR Art. 19): Hersteller (Name, Anschrift, E-Mail/Website; Vorgabe „ich selbst“ = Anbieterdaten), + bei Herstellern außerhalb der EU die verantwortliche Person in der EU, Warn- und Sicherheitshinweise (Freitext). +- Lebensmittel (LMIV Art. 14): Schalter „ist ein Lebensmittel“ → Pflichtfeld „Pflichtangaben“ (Bezeichnung, Zutaten, + Allergene hervorgehoben, Menge, ggf. Hersteller), wird vor dem Kauf am Artikel gezeigt. + +### 4.4 Bestellungen +Liste (Status-Filter, Suche nach Nummer/Name), Detail mit Positionen, Lieferart, Adresse, Zahlung, Verlauf. Aktionen: +„Versendet“ (optional Sendungsnummer + Versanddienst → Mail G10), „Abholbereit“ (→ G11), „Abgeschlossen“, „Erstatten“ +(voll oder Teilbetrag, Grund; Stripe-Refund auf dem verbundenen Konto → G13), „Stornieren“ (nur unbezahlt). Widerrufe +erscheinen an der Bestellung und als eigene Liste. CSV-Export (Bestellungen eines Zeitraums). + +### 4.5 Einstellungen +Shopname, Anbieter (Firma/Name, Anschrift, E-Mail, Telefon, optional USt-IdNr.), Steuer (Kleinunternehmer ja/nein), +Versand (an/aus, Länder DE / DE+AT, Versandkosten je Land, versandkostenfrei ab), Abholung (an/aus, Adresse, Hinweis z. B. +Öffnungszeiten), Lieferzeit (Pflicht, z. B. „2–4 Werktage“), Zahlungsarten (Karte inkl. Apple Pay/Google Pay immer; +PayPal, Klarna wählbar – nur einschalten, wenn im eigenen Stripe-Konto aktiviert; der Shop zeigt genau diese Liste), +Akzentfarbe, Mindestbestellwert (optional). + +### 4.6 Rechtliches (Händler-Rechtstexte) und „Shop geöffnet“ +Der Händler ist Verkäufer und verantwortlich. WEBklar liefert Muster, keine Rechtsberatung (W8, RDG): +- **Widerrufsbelehrung**: vorbefüllt mit dem gesetzlichen Muster (Anlage 1 zu Art. 246a § 1 Abs. 2 EGBGB) inkl. der + Hinweise auf die elektronische Widerrufsfunktion, Name/Anschrift/E-Mail eingesetzt; Rücksendekosten trägt der Käufer + (Standard, umschaltbar); Muster-Widerrufsformular (Anlage 2). Bearbeitbar; „Auf Muster zurücksetzen“. +- **AGB**: Muster-AGB für kleine Shops (Vertragsschluss, Preise, Versand, Zahlung über Stripe, Eigentumsvorbehalt, + Gewährleistung gesetzlich, Streitbeilegung VSBG-Hinweis), deutlich als Muster gekennzeichnet; bearbeitbar. +- **Versand- und Zahlungsinformationen**: aus den Einstellungen erzeugt (nicht frei editierbar, damit sie stimmen). +- **Impressum-URL** und **Datenschutz-URL** (Pflicht, https). +- Bestätigung: Haken „Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise + verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.“ (Zeit, Person, Hash der Texte gespeichert; + jede Änderung der Texte hebt die Bestätigung auf und schließt den Shop nicht, verlangt aber neue Bestätigung vor der + nächsten Öffnung — Festlegung: geöffnet bleibt geöffnet, Banner „bitte neu bestätigen“; neue Bestellungen werden mit + der zuletzt bestätigten Fassung geschlossen, siehe 5.2). + +**Shop geöffnet** (serverseitig bei jeder Bestellung geprüft): Funktion `active` · `geoeffnet` · Stripe `charges_enabled` · +bestätigte Rechtstexte vorhanden · Artikel lieferbar. Fällt ein Punkt weg, zeigt das Widget „Der Shop ist gerade +geschlossen“ und nimmt keine Bestellungen an. + +--- + +## 5. Shop auf der Website (Widget `P/public/embed/shop.js`) + +### 5.1 Einbau +`
` + `` +(Muster News/Termine). Eigene Styles mit Präfix/Shadow-DOM nach Vorbild Termine, Akzentfarbe aus den Einstellungen. +Ansichten: Kategorien + Artikelraster, Artikeldetail (Bilder, Beschreibung, Preis, Grundpreis, Varianten, Lieferhinweis, +Widerrufsausschluss, Produktsicherheit, Lebensmittelangaben), Warenkorb (im `localStorage` des Besuchers, nur Artikel-IDs +und Mengen; Begründung: technisch notwendig, § 25 Abs. 2 Nr. 2 TDDDG), Kasse, Danke-Seite, Widerrufsformular. Ganz oben +im Widget (Beginn des Bestellvorgangs, § 312j Abs. 1 BGB): Lieferländer und akzeptierte Zahlungsarten. Fußzeile immer: +AGB · Widerrufsbelehrung · Versand & Zahlung · **Vertrag widerrufen** · Impressum · Datenschutz. + +### 5.2 Kasse und Vertragsschluss (W9) +Die verbindliche Bestellung geschieht **im Widget** mit dem Knopf **„Zahlungspflichtig bestellen“** (§ 312j Abs. 3 BGB); +Stripe Checkout ist danach nur noch die Zahlung. Unmittelbar über dem Knopf (§ 312j Abs. 2, Art. 246a § 1 EGBGB): +Positionen mit wesentlichen Merkmalen, Einzel- und Gesamtpreis inkl. USt (bzw. Kleinunternehmer-Hinweis), Versandkosten, +Gesamtsumme, Lieferart, Lieferzeit, Zahlungsarten, Hinweis auf Widerrufsrecht bzw. dessen Ausschluss je Artikel, Link +AGB und Widerrufsbelehrung, Anbieter-Identität. Lieferart wählt der Käufer im Widget; Name, E-Mail, Telefon (optional) +und Lieferadresse sammelt Stripe Checkout (`shipping_address_collection` nur bei Versand, erlaubte Länder aus den +Einstellungen). Korrektur von Eingabefehlern (§ 312i Abs. 1 Nr. 1): Warenkorb und Lieferart sind bis zum Knopf änderbar, +Adresse in Stripe bis zur Zahlung. + +Server (`POST /api/public/shop/:siteKey/bestellung`): prüft „Shop geöffnet“, rechnet alle Preise aus der Datenbank neu +(nie aus dem Browser), prüft und **reserviert** Bestand (offene Bestellung zählt bis `ablaufAt`), legt `shopOrders` mit +Status `offen` an (Schnappschuss der Positionen und der bestätigten Rechtstexte-Fassung), erstellt die Checkout-Session +**auf dem verbundenen Konto** (`stripeAccount`): `mode: payment`, `price_data` je Position + Versandkosten als +`shipping_options` (fester Betrag aus dem Server), `payment_method_types` aus den Einstellungen, `locale: 'de'`, +`expires_at` = jetzt + 30 min, `client_reference_id` = Bestell-ID, `metadata` {shopId, orderId, bestellnummer}, +`customer_creation: 'if_required'`, `phone_number_collection`. `success_url` = Seite des Kunden mit +`?wk_bestellung=&wk_t=` (nur erlaubte Hosts des Projekts, sonst Standalone-Seite), `cancel_url` = Warenkorb. +Unter einer Sperre je Shop (`withLocks`), damit zwei gleichzeitige Käufe das letzte Stück nicht doppelt reservieren. + +### 5.3 Zahlung, Bestätigung, Verlauf +- **Connect-Webhook** `POST /api/shop/stripe-webhook` (eigenes Signatur-Geheimnis `STRIPE_CONNECT_WEBHOOK_SECRET`, + Endpunkt mit `connect: true`): `checkout.session.completed` (bei `payment_status: paid`), + `checkout.session.async_payment_succeeded` → `bezahlt`, Bestand endgültig abziehen, Käuferdaten aus der Session + speichern, **G09 Bestellbestätigung** an den Käufer, **K25 Neue Bestellung** an den Händler. + `async_payment_failed` / `expired` → `storniert`, Reservierung frei (G14 nur bei fehlgeschlagener Zahlung). + `account.updated` → `stripeStatus`. `charge.refunded` → Erstattung aus dem Stripe-Dashboard nachtragen. + Alles idempotent (Status-Übergänge nur vorwärts, Sperre je Bestellung). +- **Danke-Seite** fragt `GET /api/public/shop/:siteKey/bestellung/:id?t=`; ist die Bestellung noch `offen`, holt der + Server die Session bei Stripe und schließt selbst ab (gleicher Code wie der Webhook) — fällt der Webhook aus, geht + trotzdem nichts verloren. +- **G09 Bestellbestätigung** (dauerhafter Datenträger, § 312f Abs. 2 BGB): Anbieter, Bestellnummer, Positionen, + Preise, Versand, Gesamt, Lieferart/-zeit, Lieferadresse, Zahlungsart, Hinweis auf Widerrufsausschlüsse, vollständige + AGB, vollständige Widerrufsbelehrung und Muster-Widerrufsformular (im Mailtext), Link „Vertrag widerrufen“. + Absender: WEBklar-Versandadresse mit Anzeigename „“, Reply-To = E-Mail des Händlers. + +### 5.4 Standalone-Seite und Widerrufsfunktion (W10, § 356a BGB) +- `https://project.webklar.com/shop/` zeigt das Widget auf einer neutralen Seite (Vorschau, Rücksprung-Ersatz). +- `https://project.webklar.com/shop//widerruf` und die Widget-Ansicht „Vertrag widerrufen“: Schaltfläche + „Vertrag widerrufen“ führt zu einem Formular (Name, E-Mail, Bestellnummer, optional Erklärung/Artikel), danach + Bestätigungsschaltfläche **„Widerruf bestätigen“**. Eingang wird gespeichert, **G12 Eingangsbestätigung** mit Inhalt, + Datum und Uhrzeit sofort an den Käufer (dauerhafter Datenträger), **K26** an den Händler. Erreichbar ohne Login, auch + ohne passende Bestellung (der Händler klärt), Missbrauchsschutz per Rate-Limit. + +--- + +## 6. Bestellung der Funktion bei WEBklar (Register-Eintrag `shop`) + +Registereintrag nach Muster `termine`: `product: 'shopSetup'` (29900 Cent, Lookup-Key `webklar_shop_einrichtung_29900`), +`priceLabel '299 €'`, `setupEstimate 'meist innerhalb von 2–3 Wochen'`, `requiresWartungsAbo: false`, +`ticketFreischaltung: true`, `returnHash 'webshop'`, `returnProjekt: true`, Haken: +- `verfuegbar(email)`: Schalter `SHOP_ENABLED` / Pilotliste. +- `bestellpruefung`: Anfrage des Projekts ist `angenommen` (sonst offener Punkt „Anfrage noch nicht freigegeben“). +- `unterlagenBereit`: Leistungsbeschreibung + AVV Webshop der aktuellen Fassung abrufbar (HTML + PDF, sha256). +- `belehrung`: die gemeinsame Widerrufsbelehrung der Funktionen. +- `freischaltbar(doc)`: wie News (Ticket geschlossen genügt). + +Zustimmungen (alle Haken Pflicht, Fassung = `config.shop.fassung`): +1. **unterlagen**: Leistungsbeschreibung Webshop und AVV Webshop in der Fassung X. +2. **verantwortung**: „Ich verkaufe als Unternehmer in eigenem Namen. Für Produkte, Preise, Produktangaben und meine + Rechtstexte bin ich verantwortlich. Ich verkaufe keine ausgeschlossenen Waren (Leistungsbeschreibung Nr. 3).“ +3. **stripe**: „Zahlungen laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält kein Geld + meiner Käufer.“ +4. **beginn**: vorzeitiger Beginn, Erlöschen (§ 356 Abs. 5 Nr. 2 BGB), Wertersatz (§ 357a Abs. 2 BGB) – Wortlaut wie + Terminplaner. +5. **information**: Pflichtinformationen (Leistung, 299 € einmalig ohne USt nach § 19 UStG, Dauer, Laufzeit „solange die + Website bei WEBklar läuft“), Widerrufsbelehrung mit Muster-Formular gelesen. + +Ticket: Betreff „Webshop einbauen – “, Details: siteKey, Snippet, Domain, Anfrage (Warenart, Versand/Abholung, +Artikelzahl), Schritte (Shop-Seite anlegen, Snippet einsetzen, Menüpunkt, Fußzeilen-Link „Vertrag widerrufen“ auf allen +Seiten, Datenschutz-Absatz Webshop einfügen, Impressum prüfen, Testkauf im Stripe-Testmodus entfällt → Vorschau prüfen), +Verweis WOMS. Einbauanleitung `docs/funktionen/shop-einbau.md`. + +--- + +## 7. WOMS + +Funktionen-Panel des Kunden: Webshop-Zeile mit Anfrage (Inhalt, Zeit) und Knöpfen **„Anfrage annehmen“** / +**„Ablehnen“** (Begründung Pflicht) → Portal-Admin-Route; Zustand, Ticket, Stripe-Status (nur lesen), Kennzahlen. +Aktivieren/Abschalten über die vorhandenen generischen Aktionen. „Rechtliches“: Karten Leistungsbeschreibung Webshop und +AVV Webshop. + +--- + +## 8. Mails + +Neue IDs (nächste freie laut Test-Liste; Zählung im Plan geprüft): **K24** „Webshop-Anfrage: Entscheidung“ (Varianten +angenommen/abgelehnt), **K25** „Neue Bestellung im Webshop“ (an Händler), **K26** „Widerruf im Webshop eingegangen“ (an +Händler), **T21** „Neue Webshop-Anfrage“ (Team). Käufer-Mails **G09** Bestellbestätigung, **G10** versendet, **G11** +abholbereit, **G12** Eingangsbestätigung Widerruf, **G13** Erstattung, **G14** Zahlung fehlgeschlagen — eigene Vorlagen +im Stil der G-Vorlagen des Terminplaners (Absender WEBklar, Anzeigename Shop, Reply-To Händler), über die vorhandene +Gast-Mail-Warteschlange, falls wiederverwendbar (Plan), sonst eigene kleine Warteschlange mit Wiederholung. +K15/K16/T16 bekommen ihre Texte aus dem Register (vorhanden). + +--- + +## 9. Datenschutz und Aufbewahrung + +- Rollen: Händler = Verantwortlicher für Käuferdaten; WEBklar = Auftragsverarbeiter (AVV Webshop, Hetzner als + Unterauftragsverarbeiter, Mailversand über Hetzner); Stripe = eigener Vertragspartner des Händlers (nicht + Unterauftragsverarbeiter von WEBklar). Gespeichert werden nur Bestelldaten, die für Abwicklung und Nachweis nötig sind. +- Aufbewahrung (W11): personenbezogene Käuferdaten (Name, E-Mail, Telefon, Adressen) einer Bestellung werden **3 Jahre + nach Ende des Bestelljahres** anonymisiert (Gewährleistung 2 Jahre + Puffer); Beträge, Positionen, Bestellnummer + bleiben für die Buchhaltung des Händlers (dessen Aufbewahrungspflicht deckt der Händler über Stripe-Belege und eigene + Unterlagen; er kann jederzeit CSV exportieren). Unbezahlte/stornierte Bestellungen: Käuferdaten nach 30 Tagen weg. + Widerrufe: wie die Bestellung. Wird der Shop abgeschaltet: 90 Tage Lesen + Export, dann Löschung aller Shopdaten + (Bilder eingeschlossen). Job im bestehenden Löschlauf (`retention.js`). +- Portal-Datenschutzerklärung: neuer Abschnitt „Webshop“ (Stripe Connect: Weitergabe von E-Mail/Website des Kunden an + Stripe zur Kontoeröffnung, Abruf des Kontostatus; Verarbeitung als Auftragsverarbeiter). +- Muster-Datenschutzabsatz für die Website des Händlers (`docs/funktionen/shop-datenschutz-absatz.md`): Bestellabwicklung, + WEBklar als Auftragsverarbeiter, Stripe (inkl. PayPal/Klarna über Stripe), Warenkorb im Browser-Speicher. + +--- + +## 10. Rechtstexte von WEBklar (neu, Fassung `2026-10-05`) + +1. **Leistungsbeschreibung Webshop** (`public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html` + PDF): + Leistung (Anfrage, Einrichtung, Einbau, Betrieb des Widgets, Verwaltung im Portal), ausgeschlossene Waren, + Voraussetzungen (eigenes Stripe-Konto, Unternehmer, eigene Rechtstexte), keine Rechtsberatung/Muster ohne Gewähr, + Verfügbarkeit (keine Zusage einer bestimmten Verfügbarkeit über das Hosting hinaus), Haftung (Begrenzung wie + bestehende LB; keine Haftung für Inhalte, Preise und Rechtstexte des Händlers), Preis 299 € einmalig, Laufzeit/Ende + (mit dem Website-Betrieb; Abschalten, 90 Tage Export, Löschung), Änderungen. +2. **AVV Webshop** (Geschwister AVV Terminplaner): Gegenstand, Art der Daten (Käufer: Name, E-Mail, Telefon, Liefer- und + Rechnungsadresse, Bestellung, Widerrufe), Betroffene (Käufer), Unterauftragsverarbeiter Hetzner, Löschfristen genau wie + gebaut, TOM-Anlage, Weisungen über das Portal. +3. Gemeinsame **Widerrufsbelehrung Funktionen** bleibt (bestehende Datei der aktuellen Fassung). +4. **Kanzlei-Fragen** `docs/rechtliches/webshop-kanzlei-paket.md` (Muster-AGB, Muster-Widerrufsbelehrung inkl. 356a, + Zustimmungstexte, LB-Haftung, Rolle Plattform/ZAG) – vor `on`, nicht vor `pilot`. + +--- + +## 11. Livegang (je Schritt mit Freigabe des Inhabers; erst nach TP2-Livegang) + +1. Stripe-Dashboard WEBklar: **Connect aktivieren** (Plattformprofil: „Plattform, Verkäufer verkaufen eigene Waren, + Direct Charges, keine Gebühr“), Branding (Name/Icon/Farbe für das Onboarding). +2. `node scripts/shop-setup.mjs` (Collections) und `scripts/stripe-products.mjs --anlegen` (shopSetup). +3. Connect-Webhook anlegen (`scripts/shop-setup.mjs --webhook` oder Dashboard, Ereignisse aus 5.3), Geheimnis als + `STRIPE_CONNECT_WEBHOOK_SECRET` in `/home/webklar/apps/.env`, Compose-Zeilen `SHOP_ENABLED`, `SHOP_PILOT_EMAILS`. +4. Push `feature/webshop` → `main` (nach TP2), WOMS-Zweig → `test`. +5. Pilot mit Testkonto: Anfrage, Freigabe, Bestellung (Test-Coupon), Einbau auf `test-website`, Stripe-Konto im + Testmodus? (Live-Konto des Pilots; Testkauf 1 € mit Erstattung), Widerruf testen. +6. Kanzlei-Paket → Befund einarbeiten → `SHOP_ENABLED=on`. + +--- + +## 12. Entscheidungen (Claude, 2026-10-05, ohne Rückfrage auf Wunsch des Inhabers) + +| Nr. | Entscheidung | Begründung | +|---|---|---| +| W1 | Nur Stripe; PayPal/Klarna/Wallets über Stripe Checkout | eine Integration, gleiche Zahlungsarten | +| W2 | Stripe Connect, eigenes Konto des Händlers, Direct Charges, keine Plattformgebühr | kein Fremdgeld bei WEBklar (ZAG), Händler hat volles Dashboard | +| W3 | Schalter `SHOP_ENABLED` off/pilot/on | wie Terminplaner | +| W4 | „Auf Anfrage“ = Anfrage → Team-Freigabe → Bestellung | Prüfung erlaubter Waren, Wunsch des Inhabers | +| W5 | 299 € einmalig, kein Abo nötig, keine laufende Gebühr | „einmalige Kostensache“ | +| W6 | Verwaltung ab `setup` nutzbar | Shop ist zum Einbau fertig befüllt | +| W7 | Bestellnummer zufällig `JJ-XXXXXX` | keine Zählung/Sperre nötig | +| W8 | Händler-Rechtstexte als Muster + Pflichtbestätigung, keine Rechtsberatung | RDG, Verantwortung beim Verkäufer | +| W9 | Vertragsschluss im Widget mit „Zahlungspflichtig bestellen“, Stripe nur Zahlung | § 312j Abs. 3 BGB sicher erfüllt | +| W10 | Widerrufsfunktion (§ 356a BGB) im Widget + Standalone-Seite | seit 19.06.2026 Pflicht | +| W11 | Käuferdaten 3 Jahre nach Bestelljahr anonymisieren, Shopdaten 90 Tage nach Abschalten löschen | Datensparsamkeit, Gewährleistung | +| W12 | Versand nur DE/AT, keine digitalen Produkte, keine Gutscheine | Umfang V1, Recht (digitale Inhalte, Mehrzweckgutscheine) | From e9ab15bdb53459f1c98d5d6cd1dbe7f180c3afa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:30:21 +0000 Subject: [PATCH 035/105] Webshop A1/A2: Konfiguration, Schalter, Katalog shopSetup, Schema und Setup-Skript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.shop (SHOP_ENABLED, SHOP_PILOT_EMAILS, SHOP_UPLOAD_DIR, STRIPE_CONNECT_WEBHOOK_SECRET), config.shopSetup (299 € einmalig), Collection-IDs shopConfig/shopProducts/shopOrders/shopWiderrufe, services/shop/schalter.js, Katalogschluessel shopSetup in stripeBilling und stripe-products.mjs, services/shop/schema.js, scripts/shop-setup.mjs (--check), schemaKeys fuer die Test-Mocks. Co-Authored-By: Claude Opus 5.5 --- scripts/shop-setup.mjs | 85 +++++++++ scripts/stripe-products.mjs | 5 +- server/config.js | 47 +++++ server/services/shop/schalter.js | 26 +++ server/services/shop/schema.js | 108 +++++++++++ server/services/stripeBilling.js | 4 +- test/shopSchalter.test.js | 163 +++++++++++++++++ test/shopSetup.test.js | 285 ++++++++++++++++++++++++++++++ test/stripeBilling.test.js | 21 +++ test/stripeProductsScript.test.js | 16 +- test/support/schema.js | 5 +- 11 files changed, 759 insertions(+), 6 deletions(-) create mode 100644 scripts/shop-setup.mjs create mode 100644 server/services/shop/schalter.js create mode 100644 server/services/shop/schema.js create mode 100644 test/shopSchalter.test.js create mode 100644 test/shopSetup.test.js diff --git a/scripts/shop-setup.mjs b/scripts/shop-setup.mjs new file mode 100644 index 0000000..7ba7a65 --- /dev/null +++ b/scripts/shop-setup.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Webshop: legt die vier Collections shopConfig, shopProducts, shopOrders und shopWiderrufe mit + * Attributen und Indizes in Appwrite an (nur Server-Key, keine Client-Rechte). Das Schema steht in + * server/services/shop/schema.js. Idempotent: Vorhandenes wird uebersprungen, fehlende Attribute und Indizes + * auch in schon vorhandenen Collections nachgezogen. Vor dem Einschalten (SHOP_ENABLED) ausfuehren. + * Usage: + * node scripts/shop-setup.mjs --check (nur melden, nichts anlegen; Exit 1 bei Fehlendem) + * node scripts/shop-setup.mjs (Fehlendes anlegen) + */ +import { pathToFileURL } from 'node:url' +import { config } from '../server/config.js' +import { SHOP_COLLECTIONS } from '../server/services/shop/schema.js' +import { createAppwriteClient } from './analyse-setup.mjs' + +export async function main({ check = false, client, log = console.log } = {}) { + const api = client || createAppwriteClient() + const missing = [] + const missingCollections = [] + const missingIndexes = [] + const plan = [] + + // Erst alles pruefen, dann (ohne --check) anlegen, damit --check garantiert nichts veraendert + for (const [name, { attributes, indexes }] of Object.entries(SHOP_COLLECTIONS)) { + const id = config.collections[name] + const exists = await api.collectionExists(id) + const haveAttributes = exists ? new Set((await api.listAttributes(id)).map((a) => a.key)) : new Set() + const haveIndexes = exists ? new Set((await api.listIndexes(id)).map((i) => i.key)) : new Set() + const absentAttributes = attributes.filter((a) => !haveAttributes.has(a.key)) + const absentIndexes = indexes.filter((i) => !haveIndexes.has(i.key)) + + if (!exists) { + log(`${check ? '-' : '+'} Collection ${id}${check ? ' FEHLT' : ''}`) + missingCollections.push(id) + } else { + log(`= ${id}: ${haveAttributes.size} Attribute, ${haveIndexes.size} Indizes vorhanden`) + } + if (check) { + absentAttributes.forEach((a) => log(`- ${id}.${a.key} FEHLT`)) + absentIndexes.forEach((i) => log(`- Index ${id}.${i.key} FEHLT`)) + } + absentAttributes.forEach((a) => missing.push({ ...a, collection: id })) + absentIndexes.forEach((i) => missingIndexes.push({ ...i, collection: id })) + plan.push({ id, exists, absentAttributes, absentIndexes }) + } + + const result = { missing, missingCollections, missingIndexes } + if (check) { + const open = missing.length + missingCollections.length + missingIndexes.length + log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.') + return result + } + + for (const { id, exists, absentAttributes } of plan) { + if (!exists) await api.ensureCollection(id, id) + for (const attr of absentAttributes) { + await api.createAttribute(id, attr) + log(`+ Attribut ${id}.${attr.key}`) + } + } + // Indizes brauchen verfuegbare Attribute: erst warten, dann anlegen + for (const { id, absentAttributes } of plan) { + for (const attr of absentAttributes) await api.waitForAttribute(id, attr.key) + } + for (const { id, absentIndexes } of plan) { + for (const index of absentIndexes) { + await api.createIndex(id, index) + log(`+ Index ${id}.${index.key}`) + } + } + log('Fertig.') + return result +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (invokedDirectly) { + const check = process.argv.includes('--check') + main({ check }) + .then(({ missing, missingCollections, missingIndexes }) => + process.exit(check && (missing.length || missingCollections.length || missingIndexes.length) ? 1 : 0)) + .catch((e) => { + console.error(e.message) + process.exit(1) + }) +} diff --git a/scripts/stripe-products.mjs b/scripts/stripe-products.mjs index dac61f5..5fab24e 100644 --- a/scripts/stripe-products.mjs +++ b/scripts/stripe-products.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News- und Terminplaner-Einrichtung als Einmalpreise): legt fehlende + * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-, Terminplaner- und Webshop-Einrichtung als Einmalpreise): legt fehlende * Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext * (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten"). * Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt. @@ -17,7 +17,7 @@ import { config } from '../server/config.js' import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js' import { PRODUCT_KEYS } from '../server/services/products.js' -export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup'] +export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup'] const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen' export function parseArgs(argv) { @@ -31,6 +31,7 @@ export function catalogEntry(key) { if (key === 'mailStorage') return config.mailStorage if (key === 'newsSetup') return config.newsSetup if (key === 'termineSetup') return config.termineSetup + if (key === 'shopSetup') return config.shopSetup return config.products[key] } diff --git a/server/config.js b/server/config.js index eafd10a..15062ba 100644 --- a/server/config.js +++ b/server/config.js @@ -30,6 +30,7 @@ function nonNegativeNumber(raw, fallback) { } const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase() +const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase() // Abo-Produkte (Spec 2026-09-27 "Verträge, Hosting-Abo und E-Mail-Zusatzspeicher"). Preise in Cent, // Endpreise gemäß § 19 UStG. Texte, die Kunden sehen, stimmen mit den Vertragsbedingungen überein. @@ -116,6 +117,16 @@ const termineSetup = { priceNickname: 'Terminplaner-Einrichtung 149 €', } +// Webshop: einmalige Einrichtung (Einmalpreis, kein Abo); der Shop selbst verkauft ueber das eigene Stripe-Konto des Kunden (Connect) +const shopSetup = { + key: 'shopSetup', + name: 'Webshop – Einrichtung', + amountCents: 29900, + lookupKey: 'webklar_shop_einrichtung_29900', + oneTime: true, + priceNickname: 'Webshop-Einrichtung 299 €', +} + // Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '') @@ -396,6 +407,11 @@ export const config = { termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings', termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims', termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox', + // Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe; scripts/shop-setup.mjs) + shopConfig: process.env.APPWRITE_COLLECTION_SHOP_CONFIG || 'shopConfig', + shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts', + shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders', + shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe', }, publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''), @@ -412,6 +428,7 @@ export const config = { mailStorage, newsSetup, termineSetup, + shopSetup, ustHinweis, // Register der kaufbaren Funktionen (siehe oben) features, @@ -641,6 +658,36 @@ export const config = { minAntwortMs: nonNegativeNumber(process.env.TERMINE_MIN_ANTWORT_MS, 300), }, + // Webshop (Funktionsschluessel 'shop'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur Laufzeit); + // Schalter siehe server/services/shop/schalter.js + shop: { + // off (Vorgabe) | pilot (nur SHOP_PILOT_EMAILS) | on (alle Kunden) + mode: ['pilot', 'on'].includes(shopEnabled) ? shopEnabled : 'off', + // Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle + pilotEmails: (process.env.SHOP_PILOT_EMAILS || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + // Fassung der Webshop-Bestellunterlagen (Leistungsbeschreibung, AVV, Bestelltexte): Berliner Kalendertag des + // Fassungs-Commits (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben + fassung: '2026-10-05', + // Artikelbilder der Shops: // + uploadDir: process.env.SHOP_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'shop'), + // Signing-Secret des Stripe-Connect-Webhooks (Ereignisse der verbundenen Konten); leer = Webhook antwortet 503 + connectWebhookSecret: process.env.STRIPE_CONNECT_WEBHOOK_SECRET || '', + // Eine unbezahlte Bestellung haelt den Bestand (und die Stripe-Checkout-Sitzung) so lange + bestellAblaufMinuten: 30, + maxArtikel: 500, + maxBilder: 4, + maxVarianten: 30, + // Bestellungen werden so viele Jahre nach Abschluss anonymisiert (Aufbewahrungsfrist), siehe Block J + anonymisierenJahre: 3, + // Tage nach Abschalten der Funktion bis zur Loeschung der Shop-Daten + loeschTageNachAbschalten: 90, + // Unbezahlte (offene/stornierte) Bestellungen werden nach so vielen Tagen geloescht + unbezahltLoeschTage: 30, + }, + invoiceNinja: { url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''), apiToken: process.env.INVOICE_NINJA_API_TOKEN || '', diff --git a/server/services/shop/schalter.js b/server/services/shop/schalter.js new file mode 100644 index 0000000..c154a9f --- /dev/null +++ b/server/services/shop/schalter.js @@ -0,0 +1,26 @@ +import { config } from '../../config.js' + +/** + * Webshop-Schalter (Funktionsschluessel 'shop'): SHOP_ENABLED = off (Vorgabe) | pilot | on. + * Liest config.shop bei jedem Aufruf. Bei off laufen keine Shop-Jobs und keine Shop-Abfragen. + */ + +/** 'off' | 'pilot' | 'on'; alles andere (leer, unbekannt) gilt als off. */ +export function shopModus() { + const mode = config.shop.mode + return mode === 'pilot' || mode === 'on' ? mode : 'off' +} + +/** + * Darf dieser Kunde den Webshop nutzen? pilot = nur Adressen aus SHOP_PILOT_EMAILS ('*' = alle; eine leere + * Liste laesst niemanden durch); on = alle Kunden; off = niemand. + */ +export function shopFuerKunde(email) { + const modus = shopModus() + if (modus === 'off') return false + if (modus === 'on') return true + const erlaubt = config.shop.pilotEmails + if (erlaubt.includes('*')) return true + const adresse = String(email || '').trim().toLowerCase() + return adresse !== '' && erlaubt.includes(adresse) +} diff --git a/server/services/shop/schema.js b/server/services/shop/schema.js new file mode 100644 index 0000000..aca5665 --- /dev/null +++ b/server/services/shop/schema.js @@ -0,0 +1,108 @@ +/** + * Webshop: Appwrite-Schema der vier Collections (Spezifikation 2026-10-05-webshop-design.md). + * Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor useTestEnv(), + * ein frueh geladenes config.js wuerde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs stehen in + * config.collections.; scripts/shop-setup.mjs legt alles an. Kein Feld heisst data (appwriteAdmin.formatRequestBody + * haelt Bodies mit data fuer fertig verpackt), daher heissen die JSON-Felder einstellungen, rechtstexte, produkt, bestellung. + * + * Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false } + * Index: { key, type: 'key', attributes: [...] } + * Datumswerte sind UTC (toISOString()); Appwrite liefert sie als "+00:00" zurueck, daher immer mit new Date() lesen. + */ + +const str = (key, size) => ({ key, type: 'string', size, required: false }) +const int = (key) => ({ key, type: 'integer', required: false }) +const bool = (key) => ({ key, type: 'boolean', required: false }) +const dt = (key) => ({ key, type: 'datetime', required: false }) +const index = (key, ...attributes) => ({ key, type: 'key', attributes }) + +export const SHOP_COLLECTIONS = { + // Ein Shop je Funktion; Dokument-ID = ID des portalFeatures-Dokuments. Anfrage, Stripe-Konto, Einstellungen und Rechtstexte + // als JSON-Text; die grossen Spalten werden in Appwrite Textspalten und zaehlen nicht zur Zeilenbreite. + shopConfig: { + attributes: [ + str('customerId', 36), + str('projectId', 36), + str('siteKey', 16), + str('anfrage', 4000), + str('anfrageStatus', 16), + dt('anfrageAt'), + dt('anfrageEntschiedenAt'), + str('anfrageEntschiedenVon', 255), + str('anfrageBegruendung', 1000), + str('stripeAccountId', 64), + str('stripeStatus', 4000), + str('einstellungen', 20000), + str('rechtstexte', 100000), + dt('rechtstexteBestaetigtAt'), + str('rechtstexteBestaetigtVon', 255), + str('rechtstexteHash', 64), + bool('geoeffnet'), + dt('geoeffnetAt'), + int('version'), + ], + indexes: [ + index('idx_customer', 'customerId'), + index('idx_sitekey', 'siteKey'), + index('idx_anfrage', 'anfrageStatus'), + index('idx_account', 'stripeAccountId'), + ], + }, + + // Ein Artikel je Dokument (ID randomUUID); Name und Sortierung stehen zusaetzlich als Spalten, der Rest im JSON-Text produkt + shopProducts: { + attributes: [str('shopId', 36), str('customerId', 36), bool('aktiv'), int('sort'), str('name', 120), str('produkt', 30000)], + indexes: [index('idx_shop', 'shopId', 'sort')], + }, + + // Eine Bestellung je Dokument; enthaelt personenbezogene Daten des Kaeufers (Aufbewahrung und Anonymisierung: Block J) + shopOrders: { + attributes: [ + str('shopId', 36), + str('customerId', 36), + str('bestellnummer', 16), + str('status', 16), + str('tokenHash', 64), + str('stripeSessionId', 128), + str('paymentIntentId', 64), + int('summeCent'), + dt('erstelltAt'), + dt('ablaufAt'), + dt('bezahltAt'), + str('kaeuferEmail', 320), + str('bestellung', 60000), + dt('anonymisiertAt'), + ], + indexes: [ + index('idx_shop_status', 'shopId', 'status'), + index('idx_nummer', 'shopId', 'bestellnummer'), + index('idx_session', 'stripeSessionId'), + index('idx_erstellt', 'shopId', 'erstelltAt'), + ], + }, + + // Widerruf von Vertraegen (§ 356a BGB) ueber die oeffentliche Widerrufsseite + shopWiderrufe: { + attributes: [ + str('shopId', 36), + str('customerId', 36), + str('bestellnummer', 32), + str('name', 200), + str('email', 320), + str('nachricht', 2000), + dt('eingangAt'), + dt('bestaetigungAt'), + dt('bearbeitetAt'), + str('orderId', 36), + ], + indexes: [index('idx_shop', 'shopId', 'eingangAt')], + }, +} + +/** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */ +export const SHOP_DATETIMES = Object.fromEntries( + Object.entries(SHOP_COLLECTIONS).map(([name, { attributes }]) => [ + name, + attributes.filter((attribute) => attribute.type === 'datetime').map((attribute) => attribute.key), + ]), +) diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 0fe6f28..b3ddbcf 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -58,6 +58,7 @@ const PRODUCT_METADATA = { mailStorage: 'mail-speicher', newsSetup: 'news-einrichtung', termineSetup: 'termine-einrichtung', + shopSetup: 'shop-einrichtung', } /** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, Zusatzspeicher und Einrichtungen aus config. */ @@ -65,13 +66,14 @@ function catalogFor(key) { if (key === 'mailStorage') return config.mailStorage if (key === 'newsSetup') return config.newsSetup if (key === 'termineSetup') return config.termineSetup + if (key === 'shopSetup') return config.shopSetup return getProduct(key) } const priceIdCache = new Map() /** * Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' | - * 'mailStorage' | 'newsSetup' | 'termineSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe + * 'mailStorage' | 'newsSetup' | 'termineSetup' | 'shopSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe * nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog. */ export async function ensurePrice(key) { diff --git a/test/shopSchalter.test.js b/test/shopSchalter.test.js new file mode 100644 index 0000000..68ec00c --- /dev/null +++ b/test/shopSchalter.test.js @@ -0,0 +1,163 @@ +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { config } from '../server/config.js' +import { shopFuerKunde, shopModus } from '../server/services/shop/schalter.js' + +const SHOP_ENV = [ + 'SHOP_ENABLED', + 'SHOP_PILOT_EMAILS', + 'SHOP_UPLOAD_DIR', + 'STRIPE_CONNECT_WEBHOOK_SECRET', + 'APPWRITE_COLLECTION_SHOP_CONFIG', + 'APPWRITE_COLLECTION_SHOP_PRODUCTS', + 'APPWRITE_COLLECTION_SHOP_ORDERS', + 'APPWRITE_COLLECTION_SHOP_WIDERRUFE', +] + +let original +beforeEach(() => { + original = { ...config.shop } +}) +afterEach(() => { + Object.assign(config.shop, original) +}) + +/** Frische config.js-Instanz mit genau diesen Shop-Umgebungsvariablen (alle anderen entfernt). */ +let freshCounter = 0 +async function freshConfig(env = {}) { + const saved = Object.fromEntries(SHOP_ENV.map((key) => [key, process.env[key]])) + for (const key of SHOP_ENV) delete process.env[key] + Object.assign(process.env, env) + try { + freshCounter += 1 + return (await import(`../server/config.js?shop-test=${freshCounter}`)).config + } finally { + for (const key of SHOP_ENV) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + } +} + +test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => { + const fresh = await freshConfig() + assert.deepEqual(fresh.shop, { + mode: 'off', + pilotEmails: [], + fassung: '2026-10-05', + uploadDir: path.join(process.cwd(), 'upload-data', 'shop'), + connectWebhookSecret: '', + bestellAblaufMinuten: 30, + maxArtikel: 500, + maxBilder: 4, + maxVarianten: 30, + anonymisierenJahre: 3, + loeschTageNachAbschalten: 90, + unbezahltLoeschTage: 30, + }) + assert.equal(fresh.collections.shopConfig, 'shopConfig') + assert.equal(fresh.collections.shopProducts, 'shopProducts') + assert.equal(fresh.collections.shopOrders, 'shopOrders') + assert.equal(fresh.collections.shopWiderrufe, 'shopWiderrufe') +}) + +test('Konfiguration: Umgebungsvariablen werden gelesen und bereinigt', async () => { + const fresh = await freshConfig({ + SHOP_ENABLED: ' Pilot ', + SHOP_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ', + SHOP_UPLOAD_DIR: '/tmp/shop-bilder', + STRIPE_CONNECT_WEBHOOK_SECRET: 'whsec_connect', + APPWRITE_COLLECTION_SHOP_CONFIG: 'sConfig', + APPWRITE_COLLECTION_SHOP_PRODUCTS: 'sProducts', + APPWRITE_COLLECTION_SHOP_ORDERS: 'sOrders', + APPWRITE_COLLECTION_SHOP_WIDERRUFE: 'sWiderrufe', + }) + assert.equal(fresh.shop.mode, 'pilot') + assert.deepEqual(fresh.shop.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com']) + assert.equal(fresh.shop.uploadDir, '/tmp/shop-bilder') + assert.equal(fresh.shop.connectWebhookSecret, 'whsec_connect') + assert.deepEqual( + [fresh.collections.shopConfig, fresh.collections.shopProducts, fresh.collections.shopOrders, fresh.collections.shopWiderrufe], + ['sConfig', 'sProducts', 'sOrders', 'sWiderrufe'], + ) +}) + +test('Konfiguration: leerer oder unbekannter Schalter ist off', async () => { + for (const value of ['', ' ', 'banane', 'true', '1', 'ON ']) { + const fresh = await freshConfig({ SHOP_ENABLED: value }) + assert.equal(fresh.shop.mode, value.trim().toLowerCase() === 'on' ? 'on' : 'off', `SHOP_ENABLED=${JSON.stringify(value)}`) + } +}) + +test('Konfiguration: Katalogeintrag shopSetup', () => { + assert.deepEqual(config.shopSetup, { + key: 'shopSetup', + name: 'Webshop – Einrichtung', + amountCents: 29900, + lookupKey: 'webklar_shop_einrichtung_29900', + oneTime: true, + priceNickname: 'Webshop-Einrichtung 299 €', + }) +}) + +test('shopModus: leer und unbekannt sind off, pilot und on bleiben', () => { + for (const value of [undefined, null, '', 'banane', 'PILOT', 'true']) { + config.shop.mode = value + assert.equal(shopModus(), 'off', `mode=${JSON.stringify(value)}`) + } + config.shop.mode = 'pilot' + assert.equal(shopModus(), 'pilot') + config.shop.mode = 'on' + assert.equal(shopModus(), 'on') +}) + +test('shopModus: Aenderung von config.shop.mode wirkt sofort', () => { + config.shop.mode = 'off' + assert.equal(shopModus(), 'off') + config.shop.mode = 'on' + assert.equal(shopModus(), 'on') + config.shop.mode = 'off' + assert.equal(shopModus(), 'off') +}) + +test('shopFuerKunde: off sperrt alle, auch mit Stern in der Liste', () => { + config.shop.mode = 'off' + config.shop.pilotEmails = ['*', 'kenso.gri@gmail.com'] + assert.equal(shopFuerKunde('kenso.gri@gmail.com'), false) + assert.equal(shopFuerKunde('jemand@example.com'), false) +}) + +test('shopFuerKunde: pilot erlaubt nur Adressen der Liste, Schreibweise egal', () => { + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['kenso.gri@gmail.com'] + assert.equal(shopFuerKunde('kenso.gri@gmail.com'), true) + assert.equal(shopFuerKunde(' Kenso.Gri@Gmail.com '), true) + assert.equal(shopFuerKunde('jemand@example.com'), false) + assert.equal(shopFuerKunde(''), false) + assert.equal(shopFuerKunde(undefined), false) + assert.equal(shopFuerKunde(null), false) +}) + +test('shopFuerKunde: pilot mit leerer Liste laesst niemanden durch, Stern alle (auch ohne Adresse)', () => { + config.shop.mode = 'pilot' + config.shop.pilotEmails = [] + assert.equal(shopFuerKunde('kenso.gri@gmail.com'), false) + assert.equal(shopFuerKunde(''), false) + config.shop.pilotEmails = ['*'] + assert.equal(shopFuerKunde('jemand@example.com'), true) + assert.equal(shopFuerKunde(''), true) +}) + +test('shopFuerKunde: on erlaubt alle Kunden, die Liste spielt keine Rolle', () => { + config.shop.mode = 'on' + config.shop.pilotEmails = [] + assert.equal(shopFuerKunde('jemand@example.com'), true) + assert.equal(shopFuerKunde(''), true) +}) + +test('shopFuerKunde: Unbekannter Modus gilt als off', () => { + config.shop.mode = 'banane' + config.shop.pilotEmails = ['*'] + assert.equal(shopFuerKunde('kenso.gri@gmail.com'), false) +}) diff --git a/test/shopSetup.test.js b/test/shopSetup.test.js new file mode 100644 index 0000000..7da087e --- /dev/null +++ b/test/shopSetup.test.js @@ -0,0 +1,285 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_COLLECTIONS, SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Das Skript und die Konfiguration laden erst nach useTestEnv (Endpunkt des Nachbaus), daher dynamische Imports +const aw = await startAppwriteMock({ schema: { shopConfig: schemaKeys('shopConfig') }, datetimes: SHOP_DATETIMES }) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { main } = await import('../scripts/shop-setup.mjs') +const { createDocument, updateDocument, getDocument } = await import('../server/services/appwriteAdmin.js') +after(() => Promise.all([aw.close(), st.close()])) + +const NAMES = ['shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe'] +const keysOf = (name) => SHOP_COLLECTIONS[name].attributes.map((a) => a.key) + +/** + * Appwrite-Client mit Zustand: Attribute gibt es nur in vorhandenen Collections, Indizes nur auf vorhandenen + * Attributen (wie Appwrite). `initial` = { collectionId: { attrs: [...], indexes: [...] } }. + */ +function fakeClient(initial = {}) { + const state = new Map( + Object.entries(initial).map(([id, c]) => [id, { attrs: new Map((c.attrs || []).map((key) => [key, null])), indexes: new Set(c.indexes || []) }]), + ) + const calls = { collections: [], attrs: [], indexes: [] } + const need = (id) => { + if (!state.has(id)) throw Object.assign(new Error(`Collection ${id} fehlt`), { status: 404 }) + return state.get(id) + } + return { + state, + calls, + async collectionExists(id) { + return state.has(id) + }, + async ensureCollection(id, name) { + if (state.has(id)) return false + state.set(id, { attrs: new Map(), indexes: new Set() }) + calls.collections.push({ id, name }) + return true + }, + async listAttributes(id) { + return [...need(id).attrs.keys()].map((key) => ({ key })) + }, + async createAttribute(id, attr) { + const collection = need(id) + if (collection.attrs.has(attr.key)) throw new Error(`Attribut ${id}.${attr.key} existiert schon`) + collection.attrs.set(attr.key, attr) + calls.attrs.push(`${id}.${attr.key}`) + }, + async waitForAttribute(id, key) { + assert.ok(need(id).attrs.has(key), `wartet auf nie angelegtes Attribut ${id}.${key}`) + }, + async listIndexes(id) { + return [...need(id).indexes].map((key) => ({ key })) + }, + async createIndex(id, index) { + const collection = need(id) + for (const key of index.attributes) { + if (!collection.attrs.has(key)) throw new Error(`Index ${index.key}: Attribut ${key} fehlt in ${id}`) + } + collection.indexes.add(index.key) + calls.indexes.push(`${id}.${index.key}`) + }, + } +} + +const quiet = () => {} +const expectedAttrs = NAMES.flatMap((name) => keysOf(name).map((key) => `${config.collections[name]}.${key}`)) +const expectedIndexes = NAMES.flatMap((name) => SHOP_COLLECTIONS[name].indexes.map((i) => `${config.collections[name]}.${i.key}`)) + +test('Schema: Attribute je Collection wie in der Spezifikation', () => { + assert.deepEqual(Object.keys(SHOP_COLLECTIONS), NAMES) + assert.deepEqual(keysOf('shopConfig'), [ + 'customerId', 'projectId', 'siteKey', 'anfrage', 'anfrageStatus', 'anfrageAt', 'anfrageEntschiedenAt', + 'anfrageEntschiedenVon', 'anfrageBegruendung', 'stripeAccountId', 'stripeStatus', 'einstellungen', 'rechtstexte', + 'rechtstexteBestaetigtAt', 'rechtstexteBestaetigtVon', 'rechtstexteHash', 'geoeffnet', 'geoeffnetAt', 'version', + ]) + assert.deepEqual(keysOf('shopProducts'), ['shopId', 'customerId', 'aktiv', 'sort', 'name', 'produkt']) + assert.deepEqual(keysOf('shopOrders'), [ + 'shopId', 'customerId', 'bestellnummer', 'status', 'tokenHash', 'stripeSessionId', 'paymentIntentId', 'summeCent', + 'erstelltAt', 'ablaufAt', 'bezahltAt', 'kaeuferEmail', 'bestellung', 'anonymisiertAt', + ]) + assert.deepEqual(keysOf('shopWiderrufe'), [ + 'shopId', 'customerId', 'bestellnummer', 'name', 'email', 'nachricht', 'eingangAt', 'bestaetigungAt', 'bearbeitetAt', 'orderId', + ]) +}) + +test('Schema: Typen und Groessen', () => { + const byKey = (name) => Object.fromEntries(SHOP_COLLECTIONS[name].attributes.map((a) => [a.key, a])) + const shop = byKey('shopConfig') + const shopSizes = { + customerId: 36, projectId: 36, siteKey: 16, anfrage: 4000, anfrageStatus: 16, anfrageEntschiedenVon: 255, + anfrageBegruendung: 1000, stripeAccountId: 64, stripeStatus: 4000, einstellungen: 20000, rechtstexte: 100000, + rechtstexteBestaetigtVon: 255, rechtstexteHash: 64, + } + for (const [key, size] of Object.entries(shopSizes)) { + assert.deepEqual([shop[key].type, shop[key].size], ['string', size], `shopConfig.${key}`) + } + assert.equal(shop.geoeffnet.type, 'boolean') + assert.equal(shop.version.type, 'integer') + for (const key of ['anfrageAt', 'anfrageEntschiedenAt', 'rechtstexteBestaetigtAt', 'geoeffnetAt']) assert.equal(shop[key].type, 'datetime', key) + + const products = byKey('shopProducts') + assert.deepEqual([products.shopId.size, products.customerId.size, products.name.size, products.produkt.size], [36, 36, 120, 30000]) + assert.equal(products.aktiv.type, 'boolean') + assert.equal(products.sort.type, 'integer') + + const orders = byKey('shopOrders') + const orderSizes = { + shopId: 36, customerId: 36, bestellnummer: 16, status: 16, tokenHash: 64, stripeSessionId: 128, paymentIntentId: 64, + kaeuferEmail: 320, bestellung: 60000, + } + for (const [key, size] of Object.entries(orderSizes)) { + assert.deepEqual([orders[key].type, orders[key].size], ['string', size], `shopOrders.${key}`) + } + assert.equal(orders.summeCent.type, 'integer') + for (const key of ['erstelltAt', 'ablaufAt', 'bezahltAt', 'anonymisiertAt']) assert.equal(orders[key].type, 'datetime', key) + + const widerrufe = byKey('shopWiderrufe') + assert.deepEqual( + [widerrufe.shopId.size, widerrufe.customerId.size, widerrufe.bestellnummer.size, widerrufe.name.size, widerrufe.email.size, widerrufe.nachricht.size, widerrufe.orderId.size], + [36, 36, 32, 200, 320, 2000, 36], + ) + for (const key of ['eingangAt', 'bestaetigungAt', 'bearbeitetAt']) assert.equal(widerrufe[key].type, 'datetime', key) +}) + +test('Schema: jedes Attribut ist optional, hat eine gueltige Form und einen gueltigen Schluessel (kein Feld data)', () => { + for (const name of NAMES) { + const { attributes } = SHOP_COLLECTIONS[name] + assert.equal(new Set(attributes.map((a) => a.key)).size, attributes.length, `${name}: doppelte Schluessel`) + for (const attr of attributes) { + assert.match(attr.key, /^[A-Za-z][A-Za-z0-9]{0,35}$/, `${name}.${attr.key}`) + assert.notEqual(attr.key, 'data', `${name}: Feld data ist verboten`) + assert.equal(attr.required, false, `${name}.${attr.key}`) + assert.ok(['string', 'integer', 'boolean', 'datetime'].includes(attr.type), `${name}.${attr.key}: Typ ${attr.type}`) + if (attr.type === 'string') assert.ok(Number.isInteger(attr.size) && attr.size > 0, `${name}.${attr.key}: Groesse`) + else assert.equal('size' in attr, false, `${name}.${attr.key}: Groesse nur bei Text`) + } + } +}) + +test('Schema: Indizes wie in der Spezifikation und nur auf vorhandenen Attributen', () => { + const indexes = (name) => SHOP_COLLECTIONS[name].indexes.map((i) => [i.key, ...i.attributes]) + assert.deepEqual(indexes('shopConfig'), [ + ['idx_customer', 'customerId'], + ['idx_sitekey', 'siteKey'], + ['idx_anfrage', 'anfrageStatus'], + ['idx_account', 'stripeAccountId'], + ]) + assert.deepEqual(indexes('shopProducts'), [['idx_shop', 'shopId', 'sort']]) + assert.deepEqual(indexes('shopOrders'), [ + ['idx_shop_status', 'shopId', 'status'], + ['idx_nummer', 'shopId', 'bestellnummer'], + ['idx_session', 'stripeSessionId'], + ['idx_erstellt', 'shopId', 'erstelltAt'], + ]) + assert.deepEqual(indexes('shopWiderrufe'), [['idx_shop', 'shopId', 'eingangAt']]) + for (const name of NAMES) { + for (const index of SHOP_COLLECTIONS[name].indexes) { + assert.equal(index.type, 'key') + assert.ok(index.attributes.every((key) => keysOf(name).includes(key)), `${name}.${index.key}`) + } + } +}) + +test('Schema: SHOP_DATETIMES nennt je Collection genau die datetime-Attribute', () => { + assert.deepEqual(SHOP_DATETIMES, { + shopConfig: ['anfrageAt', 'anfrageEntschiedenAt', 'rechtstexteBestaetigtAt', 'geoeffnetAt'], + shopProducts: [], + shopOrders: ['erstelltAt', 'ablaufAt', 'bezahltAt', 'anonymisiertAt'], + shopWiderrufe: ['eingangAt', 'bestaetigungAt', 'bearbeitetAt'], + }) +}) + +test('schemaKeys kennt die Shop-Collections; bestehende Setup-Skripte funktionieren weiter', () => { + for (const name of NAMES) assert.deepEqual(schemaKeys(name), keysOf(name)) + assert.ok(schemaKeys('shopConfig').includes('einstellungen')) + assert.ok(!schemaKeys('shopConfig').includes('data')) + assert.ok(schemaKeys('termineConfig').includes('einstellung')) + assert.ok(schemaKeys('subscriptions').length > 0) + assert.throws(() => schemaKeys('gibtEsNicht'), /fehlt im Setup-Skript/) +}) + +test('Rundreise: Shop-Dokument mit Datum und JSON-Text anlegen, aendern und zuruecklesen', async () => { + const collection = config.collections.shopConfig + const einstellungen = JSON.stringify({ schema: 1, betrieb: { name: 'Blumen Müller' } }) + const created = await createDocument( + collection, + { customerId: 'c1', projectId: 'p1', siteKey: 'abc123', anfrageStatus: 'offen', anfrageAt: '2026-10-05T10:00:00.000Z', einstellungen, geoeffnet: false, version: 1 }, + 'feature-1', + ) + assert.equal(created.$id, 'feature-1') + await updateDocument(collection, 'feature-1', { anfrageStatus: 'angenommen', geoeffnet: true, version: 2 }) + const doc = await getDocument(collection, 'feature-1') + assert.equal(doc.anfrageStatus, 'angenommen') + assert.equal(doc.geoeffnet, true) + assert.equal(doc.version, 2) + assert.equal(doc.customerId, 'c1') + assert.equal(JSON.parse(doc.einstellungen).betrieb.name, 'Blumen Müller') +}) + +test('Rundreise: unbekanntes Attribut lehnt der Nachbau ab wie Appwrite (Schema-Pruefung greift)', async () => { + await assert.rejects( + () => createDocument(config.collections.shopConfig, { customerId: 'c1', fremd: 'x' }, 'feature-2'), + /Unknown attribute/, + ) +}) + +test('Konfiguration: Collection-IDs der Shop-Collections', () => { + assert.deepEqual(NAMES.map((name) => config.collections[name]), NAMES) +}) + +test('main legt auf leerer Datenbank 4 Collections, alle Attribute und Indizes an', async () => { + const client = fakeClient() + const result = await main({ client, log: quiet }) + assert.deepEqual(client.calls.collections, NAMES.map((id) => ({ id, name: id }))) + assert.deepEqual([...client.calls.attrs].sort(), [...expectedAttrs].sort()) + assert.deepEqual([...client.calls.indexes].sort(), [...expectedIndexes].sort()) + assert.equal(result.missing.length, expectedAttrs.length) + assert.deepEqual(result.missingCollections, NAMES) + assert.equal(result.missingIndexes.length, expectedIndexes.length) + const orders = client.state.get('shopOrders').attrs + assert.deepEqual(orders.get('kaeuferEmail'), { key: 'kaeuferEmail', type: 'string', size: 320, required: false }) + assert.deepEqual(orders.get('erstelltAt'), { key: 'erstelltAt', type: 'datetime', required: false }) + assert.deepEqual(orders.get('summeCent'), { key: 'summeCent', type: 'integer', required: false }) + assert.deepEqual(client.state.get('shopConfig').attrs.get('geoeffnet'), { key: 'geoeffnet', type: 'boolean', required: false }) +}) + +test('main: zweiter Lauf legt nichts mehr an', async () => { + const client = fakeClient() + await main({ client, log: quiet }) + client.calls.collections.length = client.calls.attrs.length = client.calls.indexes.length = 0 + const result = await main({ client, log: quiet }) + assert.deepEqual(client.calls, { collections: [], attrs: [], indexes: [] }) + assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] }) +}) + +test('main: legt nur fehlende Attribute und Indizes einer vorhandenen Collection an', async () => { + const client = fakeClient({ + shopWiderrufe: { attrs: ['shopId', 'customerId', 'bestellnummer', 'name', 'email', 'nachricht', 'eingangAt', 'bestaetigungAt'], indexes: [] }, + }) + await main({ client, log: quiet }) + assert.ok(!client.calls.collections.some((c) => c.id === 'shopWiderrufe')) + assert.deepEqual(client.calls.attrs.filter((k) => k.startsWith('shopWiderrufe.')), ['shopWiderrufe.bearbeitetAt', 'shopWiderrufe.orderId']) + assert.deepEqual(client.calls.indexes.filter((k) => k.startsWith('shopWiderrufe.')), ['shopWiderrufe.idx_shop']) + assert.equal(client.calls.collections.length, 3) +}) + +test('main --check legt nichts an und meldet Fehlendes', async () => { + const client = fakeClient() + const lines = [] + const result = await main({ check: true, client, log: (line) => lines.push(line) }) + assert.deepEqual(client.calls, { collections: [], attrs: [], indexes: [] }) + assert.equal(client.state.size, 0) + assert.deepEqual(result.missingCollections, NAMES) + assert.equal(result.missing.length, expectedAttrs.length) + assert.equal(result.missingIndexes.length, expectedIndexes.length) + assert.ok(lines.some((line) => line.includes('shopOrders') && line.includes('FEHLT'))) +}) + +test('main --check meldet bei vollstaendigem Stand nichts', async () => { + const client = fakeClient() + await main({ client, log: quiet }) + const result = await main({ check: true, client, log: quiet }) + assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] }) +}) + +test('main --check meldet ein einzelnes fehlendes Attribut und einen fehlenden Index', async () => { + const client = fakeClient() + await main({ client, log: quiet }) + client.state.get('shopOrders').attrs.delete('anonymisiertAt') + client.state.get('shopConfig').indexes.delete('idx_account') + const lines = [] + const result = await main({ check: true, client, log: (line) => lines.push(line) }) + assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), ['shopOrders.anonymisiertAt']) + assert.deepEqual(result.missingIndexes.map((i) => `${i.collection}.${i.key}`), ['shopConfig.idx_account']) + assert.deepEqual(result.missingCollections, []) + assert.ok(lines.some((line) => line.includes('shopOrders.anonymisiertAt FEHLT'))) + assert.ok(lines.some((line) => line.includes('Index shopConfig.idx_account FEHLT'))) +}) diff --git a/test/stripeBilling.test.js b/test/stripeBilling.test.js index d880a38..17a97af 100644 --- a/test/stripeBilling.test.js +++ b/test/stripeBilling.test.js @@ -281,3 +281,24 @@ test('termine_einrichtung_einmalpreis', async () => { assert.equal(await ensurePrice('termineSetup'), id) assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0) }) + +test('shop_einrichtung_einmalpreis', async () => { + const before = st.calls.length + const id = await ensurePrice('shopSetup') + const calls = st.calls.slice(before) + const created = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices') + assert.equal(created.length, 1) + assert.equal(created[0].body['unit_amount'], '29900') + assert.equal(created[0].body['lookup_key'], 'webklar_shop_einrichtung_29900') + assert.equal(created[0].body['nickname'], 'Webshop-Einrichtung 299 €') + assert.ok(!Object.keys(created[0].body).some((k) => k.startsWith('recurring')), 'kein recurring') + assert.equal(st.prices.get(id).recurring, null) + const products = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products') + assert.equal(products.length, 1) + assert.equal(products[0].body['name'], 'Webshop – Einrichtung') + assert.equal(products[0].body['metadata[webklar]'], 'shop-einrichtung') + // zweiter Aufruf: kein zweiter Preis (auch ohne Cache per lookup_key gefunden) + const again = st.calls.length + assert.equal(await ensurePrice('shopSetup'), id) + assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0) +}) diff --git a/test/stripeProductsScript.test.js b/test/stripeProductsScript.test.js index 8c4de44..cb72745 100644 --- a/test/stripeProductsScript.test.js +++ b/test/stripeProductsScript.test.js @@ -31,18 +31,30 @@ test('Import startet main() nicht: kein Stripe-Aufruf, kein process.exit', () => assert.equal(st.calls.length, 0) }) -test('KEYS enthaelt den Terminplaner-Katalogschluessel', () => { - assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup']) +test('KEYS enthaelt die Katalogschluessel der Einrichtungen', () => { + assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup']) }) test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => { assert.equal(script.catalogEntry('termineSetup'), config.termineSetup) + assert.equal(script.catalogEntry('shopSetup'), config.shopSetup) assert.equal(script.catalogEntry('newsSetup'), config.newsSetup) assert.equal(script.catalogEntry('mailStorage'), config.mailStorage) assert.equal(script.catalogEntry('wartung'), config.products.wartung) for (const key of script.KEYS) assert.ok(script.catalogEntry(key)?.lookupKey, `${key} hat einen lookupKey`) }) +test('config.shopSetup: Festwerte', () => { + assert.deepEqual(config.shopSetup, { + key: 'shopSetup', + name: 'Webshop – Einrichtung', + amountCents: 29900, + lookupKey: 'webklar_shop_einrichtung_29900', + oneTime: true, + priceNickname: 'Webshop-Einrichtung 299 €', + }) +}) + test('config.termineSetup: Festwerte', () => { assert.deepEqual(config.termineSetup, { key: 'termineSetup', diff --git a/test/support/schema.js b/test/support/schema.js index 89bdbda..b3f606d 100644 --- a/test/support/schema.js +++ b/test/support/schema.js @@ -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 { FEATURE_CONSENT_ATTRIBUTES, PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs' // Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],) @@ -9,12 +10,14 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs'] * Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs bzw. * 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); + * 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); * portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES); * featureConsents (Zustimmungsnachweis) steht ganz dort (FEATURE_CONSENT_ATTRIBUTES). */ 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 (collection === 'featureConsents') return FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key) for (const script of SETUP_SCRIPTS) { const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8') From 308268e90104c8c13af6c3b9ae3f9e33e3d99739 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:30:56 +0000 Subject: [PATCH 036/105] =?UTF-8?q?Webshop:=20Recherche=20eingearbeitet=20?= =?UTF-8?q?(kein=20PayPal=20bei=20Connect,=20Gew=C3=A4hrleistungshinweis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-10-05-webshop.md | 12 +++++++++--- docs/superpowers/specs/2026-10-05-webshop-design.md | 13 ++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-10-05-webshop.md b/docs/superpowers/plans/2026-10-05-webshop.md index 858d2aa..47cc997 100644 --- a/docs/superpowers/plans/2026-10-05-webshop.md +++ b/docs/superpowers/plans/2026-10-05-webshop.md @@ -30,6 +30,11 @@ Website und zahlen über Stripe Checkout direkt an den Kunden; Bestellungen, Ver - Appwrite-Felder: kein Feld namens `data`; Dokument-IDs ≤ 36 Zeichen `[A-Za-z0-9][A-Za-z0-9._-]*`. - Käufer-PII nie in Logs; Fehlertexte an Browser nur eigene deutsche Texte. +- **Recht aus `recherche-recht-stripe.md` (verbindlich):** Stripe Accounts **v1** mit `controller`-Eigenschaften (v2 nicht nötig); Webhook prüft `livemode` (Live-Schlüssel ⇒ nur `livemode:true`); + kein PayPal; SEPA asynchron (Abschluss erst bei `async_payment_succeeded`). Harmonisierter **Hinweis auf die gesetzliche Gewährleistung** (VO (EU) 2025/1960 Anhang I, Art. 246a § 1 Abs. 1 Nr. 11 EGBGB, seit 27.09.2026) + hervorgehoben in Kasse (über dem Knopf) und in G09 – Text in `shop/rechtstexte.js` `gewaehrleistungsHinweis()` (Wortlaut nach Anhang I, Kanzlei-Frage, Grafik später). + Ausgeschlossene Waren (LB + Anfrage): Tabak/E-Zigaretten, Alkohol, Arzneimittel, Waffen/Feuerwerk, Erotik, lebende Tiere, digitale Inhalte, Gutscheine. + ## Schnittstellen über Blockgrenzen (verbindlich) - `config.shop` (A1): `{ mode: SHOP_ENABLED ('off'|'pilot'|'on', Standard 'off'), pilotEmails: SHOP_PILOT_EMAILS (Liste, '*' = alle), @@ -163,7 +168,7 @@ Tests: Registerwerte, `oeffentlicheAnsicht('shop')`, Haken, legalPaths('shop'), ### Task C2: Einstellungen (rein + Speichern) `server/services/shop/einstellungen.js` + Tests. Felder: `shopname` (1–80), `anbieter { name, strasse, plz, ort, land:'DE'|'AT', email, telefon, ustId? }` (Pflicht außer ustId/telefon), `kleinunternehmer` bool, `versand { aktiv, laender: ['DE'] | ['DE','AT'], kostenCent { DE, AT? }, freiAbCent|null }`, `abholung { aktiv, adresse, hinweis ≤ 300 }` -(mind. eins aktiv), `lieferzeit` (Pflicht, 3–80, z. B. „2–4 Werktage“), `zahlungsarten`: Teilmenge von `card|paypal|klarna` (card immer enthalten), +(mind. eins aktiv), `lieferzeit` (Pflicht, 3–80, z. B. „2–4 Werktage“), `zahlungsarten`: Teilmenge von `card|klarna|sepa_debit` (card immer enthalten; PayPal geht bei Connect-Direct-Charges laut Stripe NICHT; SEPA nur mit Warnung „Zahlung erst nach Tagen sicher, Ware erst nach Zahlungseingang versenden“), `akzent` (#rrggbb, Standard #111111), `mindestbestellCent|null`, `ruecksendekosten: 'kaeufer'|'haendler'`. `speichereEinstellungen(shop, einstellungen)` erhöht `version`, leert Kontext-Cache. Fehlerobjekt je Feld (deutsche Texte, Du-Form). @@ -239,7 +244,7 @@ Dokument `offen` mit Schnappschuss `{ positionen, summen, lieferart, land, einst dann Checkout auf dem Konto: `mode:'payment'`, `line_items` (je Position `price_data{ currency:'eur', unit_amount, product_data:{ name: 'Name – Variante', images? (öffentliche absolute Bild-URL) } }, quantity`), Versand als `shipping_options:[{ shipping_rate_data:{ type:'fixed_amount', fixed_amount:{amount, currency:'eur'}, display_name:'Versand' | 'Abholung', delivery_estimate? } }]`, `shipping_address_collection:{ allowed_countries }` nur bei Versand, `billing_address_collection:'required'`, `phone_number_collection:{enabled:true}`, `payment_method_types` aus Einstellungen, `locale:'de'`, `expires_at` (30 min), `client_reference_id`, `metadata:{ wkShop:'1', shopId, orderId, bestellnummer }`, `payment_intent_data:{ metadata: gleich, description:'Bestellung ' }`, `success_url` = geprüfte Rück-URL + `wk_bestellung=&wk_t=`, `cancel_url` = Rück-URL + `wk_abbruch=1`. Rück-URL nur, wenn Host ∈ erlaubte Hosts (`analyseDomains(project)` + Portal-Host), sonst `https://project.webklar.com/shop/`. -Scheitert Stripe ⇒ Bestellung `storniert` (Reservierung frei) und 503 mit Käufertext; PayPal/Klarna nicht freigeschaltet ⇒ einmal mit nur `card` wiederholen und dem Händler einen Hinweis in `stripeStatus.hinweis` speichern. +Scheitert Stripe ⇒ Bestellung `storniert` (Reservierung frei) und 503 mit Käufertext; Klarna/SEPA nicht freigeschaltet ⇒ einmal mit nur `card` wiederholen und dem Händler einen Hinweis in `stripeStatus.hinweis` speichern. ### Task E2: Abschluss, Verwerfen, Ablauf `schliesseAb({ accountId, session, now })`: Sperre `shop-order:`; Konto muss zum Shop passen; nur aus `offen` (oder `storniert` mit `grund:'abgelaufen'` und bezahlt ⇒ trotzdem bezahlt, Bestand ggf. negativ ⇒ Händler-Hinweis in K25 Block `ueberverkauft`); `amount_total` muss `summeCent` entsprechen (sonst trotzdem bezahlt, Verlauf-Vermerk + Team-Log); @@ -299,7 +304,7 @@ Funktioniert auch bei geschlossenem, aber `active`/`disabled`-Shop (Widerrufsrec `public/shop-tab-bestellungen.js`: Filter, Liste, Detail, Aktionen (versendet mit Sendungsnummer, abholbereit, abgeschlossen, erstatten mit Betrag + Bestätigung), Widerrufe-Liste mit „erledigt“, CSV-Download (Zeitraum). Test. ### Task G6: Reiter Einstellungen + Rechtliches -`public/shop-tab-einstellungen.js` (alle Felder C2, Hinweis bei PayPal/Klarna „nur einschalten, wenn in Deinem Stripe-Konto aktiviert“), `public/shop-tab-rechtliches.js` (AGB/Widerrufsbelehrung/Formular bearbeiten, „Muster einsetzen“, Versand & Zahlung nur lesen, Impressum-/Datenschutz-URL, Bestätigungshaken + Knopf, Banner wenn Änderungen unbestätigt, Hinweis „Muster ohne Gewähr; lass Deine Texte prüfen, z. B. von einer Kanzlei oder einem Händlerbund“). Test. +`public/shop-tab-einstellungen.js` (alle Felder C2, Hinweis bei Klarna/SEPA „nur einschalten, wenn in Deinem Stripe-Konto aktiviert“; SEPA-Warnung; Satz „PayPal ist über Stripe für Shops wie Deinen leider nicht verfügbar“), `public/shop-tab-rechtliches.js` (AGB/Widerrufsbelehrung/Formular bearbeiten, „Muster einsetzen“, Versand & Zahlung nur lesen, Impressum-/Datenschutz-URL, Bestätigungshaken + Knopf, Banner wenn Änderungen unbestätigt, Hinweis „Muster ohne Gewähr; lass Deine Texte prüfen, z. B. von einer Kanzlei oder einem Händlerbund“). Test. --- @@ -337,4 +342,5 @@ WOMS `src/lib/funktionenView.js` (`FUNKTIONEN` + `shop` mit Bestätigungstexten; - [ ] K1 ganze Suite (Host, `--max-old-space-size`), Node-20-Lauf der Shop-Tests im Container. - [ ] K2 Gesamt-Review (2 sonnet-Reviewer: Geld/Stripe/Bestellungen; Recht/UI/Widget), Fixrunde. - [ ] K3 End-to-End mit Mocks: Server mit Appwrite-Mock + Fake-Stripe + SMTP-Senke, Playwright: Anfrage → Freigabe → Bestellung → Artikel → Öffnen → Kauf im Widget → Webhook → Mails → Versand → Erstattung → Widerruf; Screenshots nach `/root/reports/webshop-2026-10-05/ui/`. +- [ ] K3b Gewährleistungshinweis: siehe Global Constraints - [ ] K4 Livegang-Anleitung `docs/funktionen/shop-livegang.md` (Spec 11) und Memory/Ledger aktualisieren. diff --git a/docs/superpowers/specs/2026-10-05-webshop-design.md b/docs/superpowers/specs/2026-10-05-webshop-design.md index 0010595..844eafe 100644 --- a/docs/superpowers/specs/2026-10-05-webshop-design.md +++ b/docs/superpowers/specs/2026-10-05-webshop-design.md @@ -26,10 +26,10 @@ Stripe-Quittung und die Bestellbestätigung gehen an den Käufer, Rechnungen sch eines schon bestehenden Stripe-Kontos per OAuth (später), alkoholische Getränke, Tabak, Arzneimittel, Waffen, Erotik, Lebende Tiere und alles auf Stripes Liste verbotener Geschäfte (Bestellgate + Leistungsbeschreibung). -**Warum nur Stripe (W1):** Stripe läuft schon für Abo und Funktionen. Stripe Checkout bietet PayPal, Klarna, Apple Pay -und Google Pay als Zahlungsarten **innerhalb** desselben Checkouts an, wenn der Händler sie in seinem Stripe-Konto -einschaltet. Eine eigene PayPal-Anbindung hätte eine zweite Integration, einen zweiten Webhook und eine zweite -Erstattungslogik bedeutet, ohne dem Käufer eine Zahlungsart mehr zu geben. +**Warum nur Stripe (W1):** Stripe läuft schon für Abo und Funktionen. Stripe Checkout bietet Karte, Apple Pay, Google Pay, +Klarna und SEPA-Lastschrift im selben Checkout an. **PayPal geht über Stripe bei Connect-Direct-Charges nicht** (Stripe-Doku, +Recherche A3); eine eigene PayPal-Anbindung wäre eine zweite Integration samt eigenem Webhook, eigener Erstattung und eigenem +Konto-Onboarding je Händler. Der Inhaber hat „nur Stripe“ ausdrücklich erlaubt; PayPal bleibt ein späterer Ausbau. **Warum Stripe Connect mit eigenem Konto des Kunden (W2):** Das Geld fließt direkt vom Käufer an den Händler (Direct Charges auf dem verbundenen Konto). WEBklar nimmt nie fremdes Geld an, behält keine Gebühr ein und haftet nicht für @@ -148,7 +148,8 @@ erscheinen an der Bestellung und als eigene Liste. CSV-Export (Bestellungen eine Shopname, Anbieter (Firma/Name, Anschrift, E-Mail, Telefon, optional USt-IdNr.), Steuer (Kleinunternehmer ja/nein), Versand (an/aus, Länder DE / DE+AT, Versandkosten je Land, versandkostenfrei ab), Abholung (an/aus, Adresse, Hinweis z. B. Öffnungszeiten), Lieferzeit (Pflicht, z. B. „2–4 Werktage“), Zahlungsarten (Karte inkl. Apple Pay/Google Pay immer; -PayPal, Klarna wählbar – nur einschalten, wenn im eigenen Stripe-Konto aktiviert; der Shop zeigt genau diese Liste), +Klarna und SEPA-Lastschrift wählbar – nur einschalten, wenn im eigenen Stripe-Konto aktiviert; SEPA mit Warnung, weil die +Zahlung erst nach Tagen sicher ist; der Shop zeigt genau diese Liste), Akzentfarbe, Mindestbestellwert (optional). ### 4.6 Rechtliches (Händler-Rechtstexte) und „Shop geöffnet“ @@ -343,4 +344,6 @@ K15/K16/T16 bekommen ihre Texte aus dem Register (vorhanden). | W9 | Vertragsschluss im Widget mit „Zahlungspflichtig bestellen“, Stripe nur Zahlung | § 312j Abs. 3 BGB sicher erfüllt | | W10 | Widerrufsfunktion (§ 356a BGB) im Widget + Standalone-Seite | seit 19.06.2026 Pflicht | | W11 | Käuferdaten 3 Jahre nach Bestelljahr anonymisieren, Shopdaten 90 Tage nach Abschalten löschen | Datensparsamkeit, Gewährleistung | +| W13 | Hinweis auf die gesetzliche Gewährleistung (VO (EU) 2025/1960) in Kasse und Bestätigung | Pflicht seit 27.09.2026 | +| W14 | Stripe Accounts v1 mit `controller` statt v2 | v1 weiter unterstützt, passt zu stripe-node 19, kein Plattform-Umbau | | W12 | Versand nur DE/AT, keine digitalen Produkte, keine Gutscheine | Umfang V1, Recht (digitale Inhalte, Mehrzweckgutscheine) | From 6782d99960c090b091bac854b63e536e5fc516b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:32:38 +0000 Subject: [PATCH 037/105] Shop C1: Geld- und Grundpreis-Funktionen (euro, parseEuro, grundpreisText) Co-Authored-By: Claude Opus 5.5 --- server/services/shop/geld.js | 106 +++++++++++++++++++++++++++++++++++ test/shopGeld.test.js | 79 ++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 server/services/shop/geld.js create mode 100644 test/shopGeld.test.js diff --git a/server/services/shop/geld.js b/server/services/shop/geld.js new file mode 100644 index 0000000..d2fcb5b --- /dev/null +++ b/server/services/shop/geld.js @@ -0,0 +1,106 @@ +/** + * Geld und Grundpreis fuer den Shop: reine Funktionen ohne Abhaengigkeiten. + * Geld ist immer eine ganze Zahl in Cent, nie Gleitkomma-Euro; Eingaben werden als Ziffernfolge gelesen, nicht gerechnet. + */ + +const NBSP = ' ' + +/** Obergrenze fuer eingegebene Preise: 100.000 € in Cent. */ +const MAX_CENT = 10_000_000 + +/** + * Formatiert ganze Cent als deutschen Eurobetrag: Tausenderpunkt, Dezimalkomma, immer zwei Nachkommastellen, + * geschuetztes Leerzeichen (U+00A0) vor dem Eurozeichen. Beispiel: 123456 => '1.234,56 €', -1250 => '-12,50 €'. + * Wirft einen TypeError, wenn cent keine ganze Zahl ist. + */ +export function euro(cent) { + if (!Number.isInteger(cent)) throw new TypeError('euro: cent muss eine ganze Zahl sein') + const neg = cent < 0 + const abs = Math.abs(cent) + const euros = String(Math.floor(abs / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.') + const rest = String(abs % 100).padStart(2, '0') + return `${neg ? '-' : ''}${euros},${rest}${NBSP}€` +} + +/** + * Liest einen Eurobetrag und liefert ganze Cent, sonst null. + * Erlaubt: Zahl in Euro (12.5), Text wie '12', '12,5', '12,50 €', '1.234,56', '1234.56'. + * Mit Komma sind Punkte nur Tausendertrenner. Ohne Komma ist ein einzelner Punkt mit 1-2 Ziffern dahinter das Dezimaltrennzeichen, + * mit genau 3 Ziffern dahinter ein Tausenderpunkt ('1.234' = 1234 €). + * null bei leer, negativ, keine Zahl, mehr als zwei Nachkommastellen oder ueber 100.000 €. + */ +export function parseEuro(value) { + let s + if (typeof value === 'number') { + if (!Number.isFinite(value)) return null + s = String(value) + // Zahlen nur als schlichte Dezimalzahl (kein Exponent, keine Tausendergruppen) + if (!/^\d+(\.\d{1,2})?$/.test(s)) return null + return zuCent(s, '.') + } + if (typeof value !== 'string') return null + s = value.replace(/€/g, '').trim() // trim entfernt auch geschuetzte Leerzeichen; Leerzeichen mitten in der Zahl sind ungueltig + if (s === '') return null + if (s.includes(',')) { + const m = /^(\d{1,3}(?:\.\d{3})+|\d+),(\d{1,2})$/.exec(s) + if (!m) return null + return zuCent(`${m[1].replace(/\./g, '')}.${m[2]}`, '.') + } + if (/^\d+$/.test(s)) return zuCent(s, '.') + if (/^\d+\.\d{1,2}$/.test(s)) return zuCent(s, '.') + if (/^\d{1,3}(?:\.\d{3})+$/.test(s)) return zuCent(s.replace(/\./g, ''), '.') + return null +} + +/** Wandelt eine geprueft schlichte Dezimalzahl ('12.5') per Ziffernarithmetik in Cent; null ueber der Obergrenze. */ +function zuCent(text, trenner) { + const [ganz, nach = ''] = text.split(trenner) + const cent = Number(ganz) * 100 + Number(nach.padEnd(2, '0')) + if (!Number.isSafeInteger(cent) || cent > MAX_CENT) return null + return cent +} + +/** Unterstuetzte Einheiten: Anzeige-Einheit der Bezugsmenge und Faktor von der Eingabeeinheit auf diese. */ +const EINHEITEN = Object.freeze({ + g: { anzeige: 'kg', faktor: 1000 }, + kg: { anzeige: 'kg', faktor: 1 }, + ml: { anzeige: 'l', faktor: 1000 }, + l: { anzeige: 'l', faktor: 1 }, + m: { anzeige: 'm', faktor: 1 }, + m2: { anzeige: 'm²', faktor: 1 }, +}) + +/** Nachkommastellen der Menge, die exakt beruecksichtigt werden (0,75 l, 0,333 kg ...). */ +const MENGE_STELLEN = 6n + +/** + * Grundpreis nach PAngV, z. B. '(16,67 € / 1 kg)'. Bezug ist 1 kg, 1 l, 1 m oder 1 m² (g => kg, ml => l). + * einheit: g | kg | ml | l | m | m2; menge: positive Zahl (auch Dezimal, z. B. 0.75). + * Rundung kaufmaennisch (halb auf) auf ganze Cent, mit BigInt gerechnet, damit keine Gleitkommafehler entstehen. + * Fehlen Menge, Einheit oder Preis oder sind sie ungueltig, kommt '' zurueck. + */ +export function grundpreisText(angaben) { + const { preisCent, menge, einheit } = angaben ?? {} + const e = typeof einheit === 'string' && Object.hasOwn(EINHEITEN, einheit) ? EINHEITEN[einheit] : null + if (!e) return '' + if (!Number.isInteger(preisCent) || preisCent < 0) return '' + const m = mengeSkaliert(menge) + if (m === null) return '' + // Cent je Bezugsmenge = preisCent * faktor / menge, menge = m / 10^6 + const zaehler = BigInt(preisCent) * BigInt(e.faktor) * 10n ** MENGE_STELLEN + const cent = (2n * zaehler + m) / (2n * m) + if (cent > BigInt(Number.MAX_SAFE_INTEGER)) return '' + return `(${euro(Number(cent))} / 1 ${e.anzeige})` +} + +/** Menge (Zahl oder Dezimaltext) als BigInt mit 6 Nachkommastellen; null bei <= 0 oder ungueltig. */ +function mengeSkaliert(menge) { + let n + if (typeof menge === 'number') n = menge + else if (typeof menge === 'string' && /^\d+(\.\d+)?$/.test(menge.trim())) n = Number(menge.trim()) + else return null + if (!Number.isFinite(n) || n <= 0) return null + const skaliert = Math.round(n * 10 ** Number(MENGE_STELLEN)) + if (!Number.isSafeInteger(skaliert) || skaliert <= 0) return null + return BigInt(skaliert) +} diff --git a/test/shopGeld.test.js b/test/shopGeld.test.js new file mode 100644 index 0000000..fa16489 --- /dev/null +++ b/test/shopGeld.test.js @@ -0,0 +1,79 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { euro, parseEuro, grundpreisText } from '../server/services/shop/geld.js' + +const NB = ' ' + +test('euro formatiert ganze Cent deutsch mit geschuetztem Leerzeichen', () => { + assert.equal(euro(123456), `1.234,56${NB}€`) + assert.equal(euro(0), `0,00${NB}€`) + assert.equal(euro(5), `0,05${NB}€`) + assert.equal(euro(1250), `12,50${NB}€`) + assert.equal(euro(100), `1,00${NB}€`) + assert.equal(euro(99999999), `999.999,99${NB}€`) + assert.equal(euro(100000000), `1.000.000,00${NB}€`) + assert.equal(euro(-1250), `-12,50${NB}€`) + assert.equal(euro(-5), `-0,05${NB}€`) +}) + +test('euro lehnt Nicht-Ganzzahlen ab', () => { + for (const v of [12.5, NaN, Infinity, '1250', null, undefined, {}]) { + assert.throws(() => euro(v), TypeError) + } +}) + +test('parseEuro: gueltige Eingaben', () => { + const faelle = [ + [12.5, 1250], [12, 1200], [0, 0], [19.99, 1999], [0.1, 10], [1.005, null], + ['12', 1200], ['12,5', 1250], ['12,50', 1250], ['12,50 €', 1250], [`12,50${NB}€`, 1250], + [' 12,50 ', 1250], ['€ 12,50', 1250], ['1.234,56', 123456], ['1.234.567,00', null], + ['1234,56', 123456], ['1234.56', 123456], ['12.5', 1250], ['1.23', 123], ['1.234', 123400], + ['12.500', 1250000], ['0,99', 99], ['0', 0], ['007,5', 750], ['1000', 100000], + ['100000', 10000000], ['100.000,00', 10000000], ['12.345,6', 1234560], ['99999,99', 9999999], + ] + for (const [eingabe, cent] of faelle) assert.equal(parseEuro(eingabe), cent, `parseEuro(${JSON.stringify(eingabe)})`) +}) + +test('parseEuro: ungueltige Eingaben ergeben null', () => { + const faelle = [ + '', ' ', '€', 'abc', '12,5x', '-5', '-0,50', -5, NaN, Infinity, null, undefined, {}, [], + '12,345', '1.2345', '1234.567', '12,', ',50', '1,2,3', '1..234,00', '1.23.456', + '100000,01', 100000.01, '100001', 1e21, 0.30000000000000004, '12 50', '1 234,56', '1e3', + ] + for (const eingabe of faelle) assert.equal(parseEuro(eingabe), null, `parseEuro(${JSON.stringify(eingabe)})`) +}) + +test('grundpreisText: Bezugsmengen und Format', () => { + assert.equal(grundpreisText({ preisCent: 1250, menge: 750, einheit: 'g' }), `(16,67${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 1250, menge: 1, einheit: 'kg' }), `(12,50${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 299, menge: 500, einheit: 'ml' }), `(5,98${NB}€ / 1 l)`) + assert.equal(grundpreisText({ preisCent: 299, menge: 0.75, einheit: 'l' }), `(3,99${NB}€ / 1 l)`) + assert.equal(grundpreisText({ preisCent: 500, menge: 2, einheit: 'm' }), `(2,50${NB}€ / 1 m)`) + assert.equal(grundpreisText({ preisCent: 1000, menge: 4, einheit: 'm2' }), `(2,50${NB}€ / 1 m²)`) +}) + +test('grundpreisText: kaufmaennische Rundung ohne Gleitkommafehler', () => { + // 1 / 8 = 0,125 -> 0,13 (halb auf) + assert.equal(grundpreisText({ preisCent: 1, menge: 8, einheit: 'kg' }), `(0,00${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 5, menge: 8, einheit: 'kg' }), `(0,01${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 1005, menge: 2, einheit: 'kg' }), `(5,03${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 1001, menge: 2, einheit: 'l' }), `(5,01${NB}€ / 1 l)`) + assert.equal(grundpreisText({ preisCent: 333, menge: 3, einheit: 'm' }), `(1,11${NB}€ / 1 m)`) + assert.equal(grundpreisText({ preisCent: 1, menge: 1000, einheit: 'g' }), `(0,01${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 10, menge: 0.1, einheit: 'kg' }), `(1,00${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 3, menge: 0.3, einheit: 'l' }), `(0,10${NB}€ / 1 l)`) +}) + +test('grundpreisText: fehlende oder ungueltige Angaben ergeben leeren Text', () => { + const ok = { preisCent: 1250, menge: 750, einheit: 'g' } + assert.equal(grundpreisText(), '') + assert.equal(grundpreisText(null), '') + assert.equal(grundpreisText({}), '') + for (const aend of [ + { menge: 0 }, { menge: -1 }, { menge: NaN }, { menge: Infinity }, { menge: '' }, { menge: null }, { menge: undefined }, { menge: 'abc' }, + { einheit: 'stk' }, { einheit: 'KG' }, { einheit: '' }, { einheit: undefined }, + { preisCent: 12.5 }, { preisCent: -1 }, { preisCent: undefined }, { preisCent: '1250' }, + ]) { + assert.equal(grundpreisText({ ...ok, ...aend }), '', JSON.stringify(aend)) + } +}) From 0e74f1669c5b3d34de14323d0e430289d86fd5aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:35:28 +0000 Subject: [PATCH 038/105] Webshop A3: Register-Eintrag shop, Haken, Rechtstext-Satz und Ticket-Details Registereintrag config.features.shop (Bestelltexte mit Fassung config.shop.fassung, Rechtsunterlagen-Getter, Mailtexte, Ticket), STANDARD_HAKEN.shop (Schalter, Bestellpruefung ueber shop/anfrage.js, Unterlagen, Belehrung), legalDocs-Satz shop, shopTicketDetails sowie Rumpf von anfrageOffenePunkte. Tests angepasst und um Shop-Faelle erweitert. Co-Authored-By: Claude Opus 5.5 --- server/config.js | 132 +++++++++++++++- server/services/featureRegistry.js | 29 +++- server/services/legalDocs.js | 17 ++- server/services/shop/anfrage.js | 29 ++++ server/services/shop/ticketDetails.js | 69 +++++++++ test/featureConsentTexte.test.js | 38 +++++ test/featureFreischaltung.test.js | 8 +- test/featureRegistry.test.js | 210 +++++++++++++++++++++++++- test/funktionenRoutes.test.js | 21 ++- test/funktionenTermine.test.js | 2 +- test/legalDocs.test.js | 55 ++++++- test/siteFeatures.test.js | 7 +- 12 files changed, 594 insertions(+), 23 deletions(-) create mode 100644 server/services/shop/anfrage.js create mode 100644 server/services/shop/ticketDetails.js diff --git a/server/config.js b/server/config.js index 15062ba..1c63937 100644 --- a/server/config.js +++ b/server/config.js @@ -233,7 +233,85 @@ function termineConsent() { } /** - * Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen, + * Rechtsunterlage des Webshops: wie termineRechtsunterlage, aber mit Fassung und Adresse aus config.shop.fassung (bei jedem + * Zugriff gelesen). Slugs wie in services/legalDocs.js (SAETZE.shop); test/legalDocs.test.js gleicht beide ab. + */ +function shopRechtsunterlage(slug, { nullIstWert = false } = {}) { + let ueberschrieben // undefined = Standard + const standard = () => ({ version: config.shop.fassung, url: `/rechtliches/${slug}-${config.shop.fassung}.html` }) + return { + get: () => (ueberschrieben === undefined ? standard() : ueberschrieben), + set(wert) { + const std = standard() + const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url) + ueberschrieben = zurueck ? undefined : wert + }, + } +} +const shopLb = shopRechtsunterlage('leistungsbeschreibung-webshop') +const shopAvv = shopRechtsunterlage('avv-webshop') +const shopWiderruf = shopRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true }) + +const SHOP_PREIS = '299 €' +const SHOP_DAUER = 'meist innerhalb von 2–3 Wochen' + +/** + * Bestelltexte des Webshops: fuenf Haken (alle Besteller), ohne Adressen im Text (die versionierten Links zeigt die + * Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.shop.fassung; die dynamischen Teile + * (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text. Aufbau wie + * termineConsent (Haken je Fassung einmal gebaut; Tests duerfen einzelne Texte aendern und zurueckstellen). Bei Textaenderung + * neues Fassungsdatum und neuen Hash im Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und + * die Pflichtinformationen sind Entwurf bis zur Kanzlei. + */ +function shopConsent() { + let versionGesetzt // undefined = config.shop.fassung + let gebaut = null // { version, items } + const version = () => versionGesetzt ?? config.shop.fassung + const items = (v) => [ + { + id: 'unterlagen', + haken: true, + text: `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${v} zu.`, + }, + { + id: 'verantwortung', + haken: true, + text: 'Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.', + }, + { + id: 'stripe', + haken: true, + text: 'Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.', + }, + { + id: 'beginn', + haken: true, + text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', + }, + { + id: 'information', + haken: true, + text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis ${SHOP_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${SHOP_DAUER}, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`, + }, + ] + return { + get version() { + return version() + }, + set version(wert) { + versionGesetzt = wert === undefined || wert === null || wert === config.shop.fassung ? undefined : wert + }, + knopf: `Zahlungspflichtig bestellen · ${SHOP_PREIS}`, + get items() { + const v = version() + if (gebaut?.version !== v) gebaut = { version: v, items: items(v) } + return gebaut.items + }, + } +} + +/** + * Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner, Webshop): Preis, Zustimmungen, Rechtsunterlagen, * Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig, * featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar, * bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem @@ -360,6 +438,58 @@ const features = { orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.', unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.', }, + shop: { + key: 'shop', + product: 'shopSetup', + label: { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' }, + priceLabel: SHOP_PREIS, + setupEstimate: SHOP_DAUER, + consent: shopConsent(), + // Fassung und Adresse folgen config.shop.fassung bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js + // importiert keine Dienste, test/legalDocs.test.js gleicht beide ab) + get leistungsbeschreibung() { + return shopLb.get() + }, + set leistungsbeschreibung(wert) { + shopLb.set(wert) + }, + get avv() { + return shopAvv.get() + }, + set avv(wert) { + shopAvv.set(wert) + }, + get widerruf() { + return shopWiderruf.get() + }, + set widerruf(wert) { + shopWiderruf.set(wert) + }, + returnHash: 'webshop', + returnProjekt: true, + ticket: { + topic: (project) => `Webshop einbauen – ${ticketProjektName(project)}`, + // Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch + details: async (ctx) => { + const { shopTicketDetails } = await import('./services/shop/ticketDetails.js') + return shopTicketDetails(ctx) + }, + }, + mails: { + funktion: 'Webshop', + formen: { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' }, + knopf: 'Webshop öffnen', + danach: 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.', + bereit: 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.', + preisHinweis: `einmalig, ${ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`, + anleitung: false, + }, + requiresWartungsAbo: false, + ticketFreischaltung: true, + disabledMessage: 'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.', + orderedConflictMessage: 'Der Webshop ist für dieses Projekt schon bestellt.', + unavailableMessage: 'Der Webshop ist noch nicht verfügbar.', + }, } export const config = { diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index fc14d4e..fd1d31c 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -1,9 +1,10 @@ import { config } from '../config.js' import { termineFuerKunde } from './termine/schalter.js' +import { shopFuerKunde } from './shop/schalter.js' -// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js). -// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahme: -// termine/schalter.js, das nur config liest): Standard-Haken laden ihre Dienste per dynamischem import() +// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop; Daten in server/config.js). +// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahmen: +// termine/schalter.js und shop/schalter.js, die nur config lesen): Standard-Haken laden ihre Dienste per dynamischem import() // innerhalb der Funktion (sonst entstuenden Importzyklen). /** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */ @@ -58,6 +59,21 @@ async function termineBelehrung() { return widerrufsbelehrungFunktion() } +/** + * Bestellpruefung des Webshops im Checkout: die Anfrage des Projekts muss vom Team angenommen sein (shop/anfrage.js, + * Block B). Geladen werden die Dienste erst beim Aufruf. + */ +async function shopBestellpruefung({ customerId, projectId }) { + const { anfrageOffenePunkte } = await import('./shop/anfrage.js') + return anfrageOffenePunkte({ customerId, projectId }) +} + +/** Vertragsunterlagen des Webshops abrufbar? (Stufe 5b des Checkouts; legalDocs.featureLegalReady, fail-closed, wirft nie.) */ +async function shopUnterlagenBereit() { + const { featureLegalReady } = await import('./legalDocs.js') + return featureLegalReady('shop') +} + /** * Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit * (Terminplaner: featureLegalReady, F8), B7 belehrung (Text der Belehrung, der vor dem Knopf steht @@ -73,6 +89,13 @@ const STANDARD_HAKEN = { belehrung: termineBelehrung, freischaltbar: termineFreischaltbar, }, + // Webshop: kein freischaltbar (ein geschlossenes Ticket genuegt); Belehrung aus derselben Quelle wie beim Terminplaner + shop: { + verfuegbar: (email) => shopFuerKunde(email), + bestellpruefung: shopBestellpruefung, + unterlagenBereit: shopUnterlagenBereit, + belehrung: termineBelehrung, + }, } /** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */ diff --git a/server/services/legalDocs.js b/server/services/legalDocs.js index dbf82f6..f7b1ede 100644 --- a/server/services/legalDocs.js +++ b/server/services/legalDocs.js @@ -1,6 +1,6 @@ // Dokument-Registry der veroeffentlichten Rechtstexte (je Fassung feste Dateien unter public/rechtliches/): -// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und -// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum. +// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse), 'termine' und 'shop' (Leistungsbeschreibung, AVV und +// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners bzw. Webshops). analyseLegal.js ist ein duenner Wrapper darum. // Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben. import { access, open, readFile, stat } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -32,13 +32,22 @@ const SAETZE = { avv: { slug: 'avv-terminplaner', pdf: 'AVV-Terminplaner' }, widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' }, }, + shop: { + lb: { slug: 'leistungsbeschreibung-webshop', pdf: 'Leistungsbeschreibung-Webshop' }, + avv: { slug: 'avv-webshop', pdf: 'AVV-Webshop' }, + widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' }, + }, } /** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */ -const FUNKTION_SATZ = { news: null, termine: 'termine' } +const FUNKTION_SATZ = { news: null, termine: 'termine', shop: 'shop' } /** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */ -const aktuelleFassung = (satz) => (satz === 'analyse' ? config.analyse.avvVersion : config.termine.avvVersion) +const aktuelleFassung = (satz) => { + if (satz === 'analyse') return config.analyse.avvVersion + if (satz === 'shop') return config.shop.fassung + return config.termine.avvVersion +} function satzDokumente(satz) { if (typeof satz !== 'string' || !Object.hasOwn(SAETZE, satz)) throw new Error('Rechtstexte: unbekannter Satz') diff --git a/server/services/shop/anfrage.js b/server/services/shop/anfrage.js new file mode 100644 index 0000000..6ebabf2 --- /dev/null +++ b/server/services/shop/anfrage.js @@ -0,0 +1,29 @@ +import { config } from '../../config.js' +import { getDocument } from '../appwriteAdmin.js' +import { getFeatureDoc } from '../siteFeatures.js' + +/** + * Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Dieser Stand ist der Rumpf aus + * Task A3: nur die Bestellpruefung; Block B (B1, B2) baut Anfrage stellen, zurueckziehen und die Team-Entscheidung aus. + * Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des portalFeatures-Dokuments des Projekts ist. + */ + +export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigegeben.' + +/** + * Offene Punkte der Bestellpruefung (featureHaken('shop').bestellpruefung): [] wenn die Anfrage des Projekts vom Team angenommen + * ist (anfrageStatus 'angenommen'), sonst ein Punkt. Ohne portalFeatures-Dokument, ohne shopConfig-Dokument oder bei jedem anderen + * Status ist die Anfrage nicht freigegeben. Lesefehler werden weitergereicht (der Checkout bestellt dann nicht). + */ +export async function anfrageOffenePunkte({ customerId, projectId }) { + const doc = await getFeatureDoc(customerId, projectId, 'shop') + if (!doc) return [ANFRAGE_OFFEN_TEXT] + let shop + try { + shop = await getDocument(config.collections.shopConfig, doc.$id) + } catch (err) { + if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return [ANFRAGE_OFFEN_TEXT] + throw err + } + return shop?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT] +} diff --git a/server/services/shop/ticketDetails.js b/server/services/shop/ticketDetails.js new file mode 100644 index 0000000..d5a5d76 --- /dev/null +++ b/server/services/shop/ticketDetails.js @@ -0,0 +1,69 @@ +import { config } from '../../config.js' +import { getDocument } from '../appwriteAdmin.js' +import { TICKET_DETAILS_MAX, anleitungsLink, kuerze, minimaleTicketDetails } from '../featureRegistry.js' + +/** + * Details des Einrichtungs-Tickets fuer den Webshop (config.features.shop.ticket.details, Spezifikation Abschnitt 6). + * ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt (doc = portalFeatures-Dokument, project = websiteProjects- + * Dokument, customer = Kunde oder null). Der Text ist deterministisch: Kopfzeilen, Kurzfassung der Anfrage (best effort aus + * shopConfig, Dokument-ID = ID des portalFeatures-Dokuments; Lesefehler oder unlesbares JSON => ohne), Snippet und Schritte. + * Die Funktion wirft nie (bezahlt ist bezahlt, das Ticket entsteht immer) und enthaelt keine Daten von Kaeufern. + */ + +const WARENART = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst', sonstiges: 'Sonstiges' } +const ARTIKELZAHL = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' } + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/** Kurzfassung der Anfrage als Zeile, oder '' (Lesefehler, fehlendes Dokument, leere Anfrage). */ +async function anfrageZeile(doc) { + try { + const shop = await getDocument(config.collections.shopConfig, doc.$id) + const anfrage = typeof shop?.anfrage === 'string' && shop.anfrage ? JSON.parse(shop.anfrage) : null + if (!istObjekt(anfrage)) return '' + const teile = [] + if (Object.hasOwn(WARENART, anfrage.warenart)) teile.push(`Warenart: ${WARENART[anfrage.warenart]}`) + const lieferung = [anfrage.versand === true ? 'Versand' : '', anfrage.abholung === true ? 'Abholung' : ''].filter(Boolean) + if (lieferung.length) teile.push(`Lieferung: ${lieferung.join(' und ')}`) + if (Object.hasOwn(ARTIKELZAHL, anfrage.artikelzahl)) teile.push(`Artikelzahl: ${ARTIKELZAHL[anfrage.artikelzahl]}`) + return teile.length ? `Anfrage: ${teile.join(', ')}` : '' + } catch { + return '' + } +} + +export async function shopTicketDetails(ctx) { + try { + const { doc } = ctx + const siteKey = doc?.siteKey || '' + const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Webshop' } }).split('\n').filter((zeile) => !zeile.startsWith('Verweis: ')) + const anfrage = doc?.$id ? await anfrageZeile(doc) : '' + const zeilen = [ + ...kopf, + ...(anfrage ? [anfrage] : []), + '', + 'Snippet (an der Stelle der Shop-Seite einsetzen):', + `
`, + '', + '', + 'Schritte:', + '1. Shop-Seite und Menüpunkt anlegen, Snippet einsetzen.', + `2. Auf allen Seiten im Footer den Link "Vertrag widerrufen" setzen: https://project.webklar.com/shop/${siteKey}/widerruf`, + '3. Datenschutz-Absatz Webshop einfügen (docs/funktionen/shop-datenschutz-absatz.md).', + '4. Impressum prüfen.', + `5. Vorschau prüfen: https://project.webklar.com/shop/${siteKey}`, + '6. Ticket schließen: das schaltet die Funktion frei.', + '', + 'Zeit im Arbeitsblatt erfassen, nicht auf Abo-Stunden anrechnen.', + `Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`, + ] + return kuerze(zeilen.join('\n').toWellFormed(), TICKET_DETAILS_MAX, { anZeile: true }) + } catch (err) { + console.error(`[shop-ticket] Details nicht erzeugbar, Notfall-Details: ${err?.message}`) + try { + return kuerze(minimaleTicketDetails({ ...ctx, feature: { key: 'Webshop' } }).toWellFormed(), TICKET_DETAILS_MAX) + } catch { + return 'Funktion: Webshop\nDetails konnten nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).' + } + } +} diff --git a/test/featureConsentTexte.test.js b/test/featureConsentTexte.test.js index f2bf91b..b050abd 100644 --- a/test/featureConsentTexte.test.js +++ b/test/featureConsentTexte.test.js @@ -149,6 +149,44 @@ test('textstand_pin: Hash über die verbundenen Bestelltexte beider Funktionen s assert.equal(sha256(texte), TEXTSTAND.sha256, 'Bestelltexte geändert: neues Fassungsdatum und neuen Hash eintragen') }) +/** + * Pin auf die Bestelltexte des Webshops (Haken und Knopf, durch eine Leerzeile verbunden), gebunden an config.shop.fassung. + * Bei Textänderung neues Fassungsdatum (config.shop.fassung) UND neuen Hash: gespeicherte Nachweise tragen Text und Fassung. + */ +const SHOP_TEXTSTAND = { fassung: '2026-10-05', sha256: '09553de56c794602ca462d980de8548015df7b03d90a5ba1227ce83a58a6608a' } + +test('shop_haken_woertlich: fünf Haken in fester Reihenfolge mit dem Wortlaut aus dem Plan', () => { + const S = config.shop.fassung + const SHOP = config.features.shop + assert.deepEqual(SHOP.consent.items.map((i) => i.id), ['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information']) + assert.deepEqual(SHOP.consent.items.map((i) => i.haken), [true, true, true, true, true]) + assert.deepEqual(SHOP.consent.items.map((i) => i.text), [ + `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${S} zu.`, + 'Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.', + 'Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.', + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', + 'Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis 299 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), voraussichtliche Dauer meist innerhalb von 2–3 Wochen, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.', + ]) + assert.equal(SHOP.consent.knopf, 'Zahlungspflichtig bestellen · 299 €') + for (const item of SHOP.consent.items) assert.deepEqual(Object.keys(item).sort(), ['haken', 'id', 'text'], 'ohne url') + assert.equal(SHOP.consent.version, S) +}) + +test('shop_textstand_pin: Hash über die verbundenen Bestelltexte des Webshops steht im Test', () => { + const SHOP = config.features.shop + const texte = [...SHOP.consent.items.map((i) => i.text), SHOP.consent.knopf].join('\n\n') + assert.equal(config.shop.fassung, SHOP_TEXTSTAND.fassung, 'neue Fassung: Pin und Hash hier und in den Rechtstext-Tests nachziehen') + assert.equal(sha256(texte), SHOP_TEXTSTAND.sha256, 'Bestelltexte geändert: neues Fassungsdatum und neuen Hash eintragen') +}) + +test('shop_belehrung_haken: dieselbe Belehrung wie beim Terminplaner, ohne ENTWURF', async () => { + const belehrung = await featureHaken('shop').belehrung() + assert.equal(belehrung, widerrufsbelehrungFunktion()) + assert.equal(belehrung, await featureHaken('termine').belehrung()) + assert.deepEqual(config.features.shop.widerruf, { version: config.shop.fassung, url: legalPaths('shop', config.shop.fassung).widerruf }) + assert.doesNotMatch(JSON.stringify([config.features.shop.consent.items, belehrung]), /ENTWURF/) +}) + // ------------------------------------------------------------------------------------------------ Belehrung im Haken test('belehrung_haken: der Standard-Haken liefert die Widerrufsbelehrung der Funktionen, News hat keinen', async () => { diff --git a/test/featureFreischaltung.test.js b/test/featureFreischaltung.test.js index 9f3dc28..e6d5974 100644 --- a/test/featureFreischaltung.test.js +++ b/test/featureFreischaltung.test.js @@ -103,15 +103,15 @@ afterEach(() => { // ---------------------------------------------------------------------------------------- Register --- test('ticketFreischaltungKeys: Register-Schluessel mit ticketFreischaltung, in Registerreihenfolge', () => { - assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine']) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop']) NEWS.ticketFreischaltung = false - assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine']) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop']) NEWS.ticketFreischaltung = 'ja' - assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine'], 'nur true zaehlt') + assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop'], 'nur true zaehlt') NEWS.ticketFreischaltung = true TERMINE.ticketFreischaltung = false try { - assert.deepEqual(reg.ticketFreischaltungKeys(), ['news']) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'shop']) } finally { TERMINE.ticketFreischaltung = true } diff --git a/test/featureRegistry.test.js b/test/featureRegistry.test.js index c38a72b..9f12e09 100644 --- a/test/featureRegistry.test.js +++ b/test/featureRegistry.test.js @@ -4,7 +4,7 @@ import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' -// Register config.features (News + Terminplaner) und featureRegistry.js. Dienste (siteFeatures, ticketAdmin) +// Register config.features (News, Terminplaner, Webshop) und featureRegistry.js. Dienste (siteFeatures, ticketAdmin) // brauchen die Test-Umgebung vor dem ersten Import von server/config.js. const aw = await startAppwriteMock() const st = await startFakeStripe() @@ -121,10 +121,11 @@ test('FEATURE_LABELS und FEATURE_KEYS unverändert, aus dem Register abgeleitet' assert.deepEqual(sf.FEATURE_LABELS, { news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' }, + shop: { name: 'Webshop', keine: 'keinen Webshop' }, }) - assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine']) + assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop']) assert.deepEqual(sf.FEATURE_KEYS, reg.featureKeys()) - assert.deepEqual(reg.featureKeys(), ['news', 'termine']) + assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop']) }) // ---------------------------------------------------------------------------------- Schlüsselschutz --- @@ -295,3 +296,206 @@ test('Bestelltexte ohne ENTWURF', async () => { const belehrung = reg.featureHaken('termine').belehrung if (belehrung) frei(await belehrung(), 'belehrung') }) + +// ---------------------------------------------------------------------------------- Webshop (Block A3) --- +test('Shop: Preis, Struktur und Texte', async () => { + const s = reg.featureConfig('shop') + assert.equal(s, config.features.shop) + assert.equal(s.key, 'shop') + assert.equal(s.product, 'shopSetup') + assert.equal(config.shopSetup.amountCents, 29900) + assert.deepEqual(s.label, { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' }) + assert.equal(s.priceLabel, '299 €') + assert.equal(s.setupEstimate, 'meist innerhalb von 2–3 Wochen') + assert.equal(s.consent.knopf, 'Zahlungspflichtig bestellen · 299 €') + assert.deepEqual( + s.consent.items.map((i) => i.id), + ['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information'] + ) + assert.deepEqual( + s.consent.items.map((i) => i.haken), + [true, true, true, true, true] + ) + for (const item of s.consent.items) { + assert.equal(typeof item.text, 'string') + assert.ok(item.text.length > 0) + assert.equal(item.url, undefined, 'ohne url') + } + const F = config.shop.fassung + assert.equal(s.consent.version, F) + assert.equal(s.consent.items[0].text, `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${F} zu.`) + assert.match(s.consent.items[3].text, /Einrichtung des Webshops/) + assert.ok(s.consent.items[4].text.includes(`Gesamtpreis 299 € einmalig (${config.ustHinweis})`)) + assert.ok(s.consent.items[4].text.includes('meist innerhalb von 2–3 Wochen')) + + // Fassung und Adressen folgen config.shop.fassung; die Widerrufsbelehrung ist die der Funktionen + assert.deepEqual(s.leistungsbeschreibung, { version: F, url: `/rechtliches/leistungsbeschreibung-webshop-${F}.html` }) + assert.deepEqual(s.avv, { version: F, url: `/rechtliches/avv-webshop-${F}.html` }) + assert.deepEqual(s.widerruf, { version: F, url: `/rechtliches/widerrufsbelehrung-funktionen-${F}.html` }) + + assert.equal(s.returnHash, 'webshop') + assert.equal(s.returnProjekt, true) + assert.equal(s.requiresWartungsAbo, false) + assert.equal(s.ticketFreischaltung, true) + assert.equal(s.disabledMessage, 'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.') + assert.equal(s.orderedConflictMessage, 'Der Webshop ist für dieses Projekt schon bestellt.') + assert.equal(s.unavailableMessage, 'Der Webshop ist noch nicht verfügbar.') + + assert.equal(s.mails.funktion, 'Webshop') + assert.deepEqual(s.mails.formen, { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' }) + assert.equal(s.mails.knopf, 'Webshop öffnen') + assert.equal(s.mails.anleitung, false) + assert.equal(s.mails.preisHinweis, `einmalig, ${config.ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`) + assert.equal(s.mails.danach, 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.') + assert.equal(s.mails.bereit, 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.') + + assert.equal(s.ticket.topic({ projectName: 'Laden' }), 'Webshop einbauen – Laden') + assert.equal(s.ticket.topic({ subdomain: 'laden' }), 'Webshop einbauen – laden') + assert.equal(typeof s.ticket.details, 'function') + + const ansicht = reg.oeffentlicheAnsicht('shop') + assert.equal(ansicht.name, 'Webshop') + assert.equal(ansicht.kurz, 'Shop') + assert.equal(ansicht.priceLabel, '299 €') + assert.equal(ansicht.requiresWartungsAbo, false) + assert.equal(ansicht.returnHash, 'webshop') + assert.deepEqual(ansicht.consent.items.map((i) => i.id), ['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information']) + assert.equal(ansicht.consent.version, F) + assert.deepEqual(ansicht.avv, { version: F, url: `/rechtliches/avv-webshop-${F}.html` }) + assert.doesNotMatch(JSON.stringify(ansicht), /ENTWURF/) + + // Termine bleibt unberührt + assert.equal(config.features.termine.consent.knopf, 'Zahlungspflichtig bestellen · 149 €') +}) + +test('Shop: Fassung folgt config.shop.fassung bei jedem Zugriff', () => { + const s = config.features.shop + const vorher = config.shop.fassung + try { + config.shop.fassung = '2099-01-02' + assert.equal(s.consent.version, '2099-01-02') + assert.ok(s.consent.items[0].text.includes('in der Fassung 2099-01-02 zu.')) + assert.deepEqual(s.avv, { version: '2099-01-02', url: '/rechtliches/avv-webshop-2099-01-02.html' }) + assert.equal(s.leistungsbeschreibung.url, '/rechtliches/leistungsbeschreibung-webshop-2099-01-02.html') + // null beim Widerruf heißt "keine Unterlage" (Tests setzen so eine Bestellung ohne sie), undefined stellt den Standard her + s.widerruf = null + assert.equal(s.widerruf, null) + s.widerruf = undefined + assert.equal(s.widerruf.version, '2099-01-02') + } finally { + config.shop.fassung = vorher + s.widerruf = undefined + } + assert.equal(s.consent.version, vorher) + assert.ok(s.consent.items[0].text.includes(`in der Fassung ${vorher} zu.`)) +}) + +test('Shop-Haken: verfügbar nach Schalter, Belehrung, kein freischaltbar', async () => { + const haken = reg.featureHaken('shop') + assert.deepEqual(Object.keys(haken).sort(), ['belehrung', 'bestellpruefung', 'unterlagenBereit', 'verfuegbar']) + assert.equal(haken.freischaltbar, undefined) + const vorher = { ...config.shop } + try { + config.shop.mode = 'off' + assert.equal(haken.verfuegbar('a@b.de'), false) + assert.equal(reg.featureSperre('shop', 'a@b.de'), 'Der Webshop ist noch nicht verfügbar.') + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['a@b.de'] + assert.equal(haken.verfuegbar('A@B.de'), true) + assert.equal(haken.verfuegbar('x@y.de'), false) + assert.equal(reg.featureSperre('shop', 'a@b.de'), null) + config.shop.mode = 'on' + assert.equal(haken.verfuegbar('x@y.de'), true) + } finally { + Object.assign(config.shop, vorher) + } + // dieselbe Belehrung wie beim Terminplaner (gleiche Quelle) + assert.equal(await haken.belehrung(), await reg.featureHaken('termine').belehrung()) + assert.equal(typeof (await haken.belehrung()), 'string') +}) + +test('Shop-Haken: Bestellprüfung verlangt die angenommene Anfrage', async () => { + const { bestellpruefung } = reg.featureHaken('shop') + const OFFEN = ['Deine Webshop-Anfrage ist noch nicht freigegeben.'] + aw.seed('customers', { $id: 'sc1', name: 'Shop Eins' }) + aw.seed('websiteProjects', { $id: 'sp1', customerId: 'sc1', projectName: 'Laden' }) + // kein portalFeatures-Dokument + assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN) + aw.seed('portalFeatures', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', featureKey: 'shop', siteKey: SITE_KEY, state: 'off', enabled: false }) + // portalFeatures-Dokument, aber kein shopConfig + assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN) + for (const status of ['', 'offen', 'abgelehnt', 'zurueckgezogen']) { + aw.seed('shopConfig', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', siteKey: SITE_KEY, anfrageStatus: status }) + assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN, `Status "${status}"`) + } + aw.seed('shopConfig', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', siteKey: SITE_KEY, anfrageStatus: 'angenommen' }) + assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), []) + // ein fremder Kunde sieht die Anfrage nicht + assert.deepEqual(await bestellpruefung({ customerId: 'sc2', projectId: 'sp1' }), OFFEN) +}) + +test('Shop-Haken: Unterlagen sind bereit, sobald die Dateien da sind (bis Block J: nicht)', async () => { + const { unterlagenBereit } = reg.featureHaken('shop') + const { featureLegalReady, legalPaths } = await import('../server/services/legalDocs.js') + const { existsSync } = await import('node:fs') + const F = config.shop.fassung + const dateien = Object.values(legalPaths('shop', F)).flatMap((p) => [p, p.replace(/\.html$/, '.pdf')]) + const alleDa = dateien.every((p) => existsSync(new URL(`../public${p}`, import.meta.url))) + assert.equal(await featureLegalReady('shop'), alleDa) + assert.equal(await unterlagenBereit(), alleDa) +}) + +test('Shop-Ticket: Details deterministisch, mit Snippet und Schritten', async () => { + aw.seed('shopConfig', { + $id: 'f_sp9_shop', + customerId: 'sc9', + projectId: 'sp9', + siteKey: SITE_KEY, + anfrage: JSON.stringify({ warenart: 'kleidung', beschreibung: 'Streng geheim', artikelzahl: 'bis100', versand: true, abholung: true, nachricht: 'Hallo' }), + anfrageStatus: 'angenommen', + }) + const doc = { $id: 'f_sp9_shop', customerId: 'sc9', projectId: 'sp9', siteKey: SITE_KEY, featureKey: 'shop' } + const ctx = { feature: config.features.shop, doc, project: { projectName: 'Laden', liveDomain: 'laden.de' }, customer: { name: 'Frau Muster' } } + const text = await config.features.shop.ticket.details(ctx) + assert.equal(typeof text, 'string') + for (const teil of [ + 'Funktion: Webshop', + 'Projekt: Laden', + 'Domain: laden.de', + 'Kunde: Frau Muster', + `siteKey: ${SITE_KEY}`, + 'Anfrage: Warenart: Kleidung, Lieferung: Versand und Abholung, Artikelzahl: bis 100', + `
`, + '', + `https://project.webklar.com/shop/${SITE_KEY}/widerruf`, + `https://project.webklar.com/shop/${SITE_KEY}`, + 'docs/funktionen/shop-datenschutz-absatz.md', + 'Zeit im Arbeitsblatt', + 'Verweis: ', + '/customers/sc9?tab=funktionen&anleitung=sp9', + ]) { + assert.ok(text.includes(teil), `fehlt: ${teil}`) + } + // freier Text der Anfrage gehört nicht ins Ticket; deterministisch + assert.ok(!text.includes('Streng geheim')) + assert.equal(await config.features.shop.ticket.details(ctx), text) + assert.ok(text.length <= reg.TICKET_DETAILS_MAX) + + // ohne Anfrage oder bei Lesefehler: dieselben Teile, nur ohne Anfrage-Zeile; wirft nie + const ohne = await config.features.shop.ticket.details({ ...ctx, doc: { ...doc, $id: 'f_unbekannt_shop' } }) + assert.ok(!ohne.includes('Anfrage:')) + assert.ok(ohne.includes(`data-webklar-shop="${SITE_KEY}"`)) + aw.seed('shopConfig', { $id: 'f_sp8_shop', customerId: 'sc9', projectId: 'sp8', siteKey: SITE_KEY, anfrage: '{kaputt' }) + const kaputt = await config.features.shop.ticket.details({ ...ctx, doc: { ...doc, $id: 'f_sp8_shop' } }) + assert.ok(!kaputt.includes('Anfrage:')) + const notfall = await config.features.shop.ticket.details(null) + assert.equal(typeof notfall, 'string') + assert.ok(notfall.length > 0) +}) + +test('Shop-Ticket über ticketTexte: Betreff und Details', async () => { + const doc = { $id: 'f_sp7_shop', customerId: 'sc7', projectId: 'sp7', siteKey: SITE_KEY, featureKey: 'shop' } + const { topic, details } = await reg.ticketTexte({ feature: config.features.shop, doc, project: { projectName: 'Laden' }, customer: null }) + assert.equal(topic, 'Webshop einbauen – Laden') + assert.ok(details.includes(`data-webklar-shop="${SITE_KEY}"`)) +}) diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index f91c131..835b876 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -69,13 +69,14 @@ test('liste_zustaende', async () => { assert.deepEqual(body.termine, { verfuegbar: false }) // Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei termine.verfuegbar: false - assert.deepEqual(Object.keys(body.features).sort(), ['news', 'termine']) + assert.deepEqual(Object.keys(body.features).sort(), ['news', 'shop', 'termine']) + assert.equal(body.features.shop.priceLabel, '299 €') assert.equal(body.features.news.priceLabel, '30 €') assert.equal(body.features.termine.priceLabel, '149 €') assert.equal(body.ustHinweis, config.ustHinweis) assert.ok(typeof body.ustHinweis === 'string' && body.ustHinweis.length > 0) // Items vollstaendig: Kennung, Wortlaut und Haken jedes Registerpunkts, dazu Fassung und Knopf - for (const key of ['news', 'termine']) { + for (const key of ['news', 'termine', 'shop']) { const eintrag = config.features[key] const ansicht = body.features[key] assert.equal(ansicht.key, key) @@ -111,7 +112,7 @@ test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Be const { legalPaths } = await import('../server/services/legalDocs.js') const V = config.termine.avvVersion const body = await get('/api/funktionen', { cookie }) - assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'termine']) + assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'shop', 'termine']) assert.equal(body.legal.news, null) const termine = body.legal.termine assert.deepEqual(Object.keys(termine).sort(), ['belehrung', 'links', 'version']) @@ -130,6 +131,20 @@ test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Be assert.deepEqual(body.features.termine.avv, { version: V, url: adressen.avv }) assert.deepEqual(body.features.termine.leistungsbeschreibung, { version: V, url: adressen.lb }) assert.deepEqual(body.features.news.widerruf, null) + + // Webshop: eigene Fassung (config.shop.fassung), eigene Slugs, dieselbe Belehrung der Funktionen + const S = config.shop.fassung + const shop = body.legal.shop + assert.deepEqual(Object.keys(shop).sort(), ['belehrung', 'links', 'version']) + assert.equal(shop.version, S) + assert.deepEqual(Object.keys(shop.links), ['lb', 'avv', 'widerruf']) + const shopAdressen = legalPaths('shop', S) + for (const [doc, link] of Object.entries(shop.links)) assert.equal(link, `${config.publicBaseUrl}${shopAdressen[doc]}`) + assert.ok(shop.links.lb.includes('leistungsbeschreibung-webshop-')) + assert.ok(shop.links.avv.includes('avv-webshop-')) + assert.ok(shop.links.widerruf.includes('widerrufsbelehrung-funktionen-')) + assert.equal(shop.belehrung, widerrufsbelehrungFunktion()) + assert.deepEqual(body.features.shop.avv, { version: S, url: shopAdressen.avv }) }) test('liste_register_ohne_freigabe: features.termine steht auch bei Schalter off und Pilot ohne Teilnahme', async () => { diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index f12d12d..bf92c39 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -295,7 +295,7 @@ test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher' assert.equal(pt1.features.news.state, 'disabled') const pt2 = body.projects.find((p) => p.projectId === 'pt2') assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off']) - assert.deepEqual(Object.keys(pt2.features), ['news', 'termine']) + assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'shop']) // Abschaltgrund und Loeschfrist (E2, R105): News ist hier vom Team abgeschaltet: Grund und Zeitpunkt ja, ein Loeschdatum nein // (der 30-Tage-Loeschlauf loescht nur Terminplaner-Daten); der aktive Terminplaner und das leere Projekt haben nichts const aus = pt1.features.news diff --git a/test/legalDocs.test.js b/test/legalDocs.test.js index 84a63b7..01c19f9 100644 --- a/test/legalDocs.test.js +++ b/test/legalDocs.test.js @@ -1,6 +1,6 @@ import { test, after, mock } from 'node:test' import assert from 'node:assert/strict' -import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs' +import { closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs' import fsp from 'node:fs/promises' import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' @@ -291,6 +291,59 @@ test('News: Leistungsbeschreibung folgt der Fassung (Adresse unverändert, k assert.equal(Object.getOwnPropertyDescriptor(n, 'leistungsbeschreibung').get, undefined) }) +// Webshop (Block A3): eigener Satz mit eigener Fassung config.shop.fassung; die Dateien legt Block J an +test('Webshop-Satz: Adressen, PDF-Namen und Funktionszuordnung folgen config.shop.fassung', async () => { + const S = config.shop.fassung + assert.match(S, /^\d{4}-\d{2}-\d{2}$/) + assert.deepEqual(legalPaths('shop', S), { + lb: `/rechtliches/leistungsbeschreibung-webshop-${S}.html`, + avv: `/rechtliches/avv-webshop-${S}.html`, + widerruf: `/rechtliches/widerrufsbelehrung-funktionen-${S}.html`, + }) + assert.deepEqual(Object.keys(legalPaths('shop', S)), ['lb', 'avv', 'widerruf']) + const f = legalPdfFiles('shop', S) + assert.equal(f.lb.filename, `Leistungsbeschreibung-Webshop-${S}.pdf`) + assert.equal(f.avv.filename, `AVV-Webshop-${S}.pdf`) + assert.equal(f.widerruf.filename, `Widerrufsbelehrung-Muster-Widerrufsformular-${S}.pdf`) + assert.equal(f.avv.path, new URL(`../public/rechtliches/avv-webshop-${S}.pdf`, import.meta.url).pathname) + assert.throws(() => legalPaths('shop', '2026-10-5'), /Fassung/) + + // die aktuelle Fassung des Satzes shop ist config.shop.fassung (nicht die der Termine) + assert.deepEqual(featureLegalPaths('shop'), legalPaths('shop', S)) + const alt = config.shop.fassung + config.shop.fassung = '2099-01-02' + try { + assert.deepEqual(featureLegalPaths('shop'), legalPaths('shop', '2099-01-02')) + assert.deepEqual(featureLegalPaths('termine'), legalPaths('termine', V), 'Termine bleiben bei ihrer Fassung') + await assert.rejects(featureLegalAttachments('shop')) + } finally { + config.shop.fassung = alt + } + assert.deepEqual(featureLegalPaths('shop', { fassung: '2026-10-01' }), legalPaths('shop', '2026-10-01')) + assert.deepEqual(featureLegalPaths('termine'), legalPaths('termine', V)) +}) + +test('featureLegalReady("shop") ist true genau dann, wenn alle sechs Dateien der Fassung da sind (bis Block J: false)', async () => { + const S = config.shop.fassung + const html = Object.values(legalPaths('shop', S)) + const pdfs = Object.values(legalPdfFiles('shop', S)).map((x) => x.path) + const alleDa = html.every((p) => existsSync(root(`public${p}`))) && pdfs.every((p) => existsSync(p)) + assert.equal(await featureLegalReady('shop'), alleDa) + assert.equal(await featureLegalReady('shop', { fassung: '1999-01-01' }), false) + // nie ein Wurf bei fehlenden Dateien; die Anhaenge werfen dagegen + if (!alleDa) await assert.rejects(featureLegalAttachments('shop')) +}) + +test('Register: leistungsbeschreibung, avv und widerruf des Webshops folgen config.shop.fassung (Drift-Wächter)', () => { + const s = featureConfig('shop') + const S = config.shop.fassung + const p = legalPaths('shop', S) + assert.deepEqual(s.leistungsbeschreibung, { version: S, url: p.lb }) + assert.deepEqual(s.avv, { version: S, url: p.avv }) + assert.deepEqual(s.widerruf, { version: S, url: p.widerruf }) + assert.equal(s.consent.version, S) +}) + test('config.js importiert legalDocs.js nicht (Adresse aus Slug und Version gebaut)', () => { const src = readFileSync(root('server/config.js'), 'utf8') assert.doesNotMatch(src, /^\s*(import\b[^\n]*|.*\bimport\()['"`][^'"`]*legalDocs/m) diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index 7bbad48..9f62b5f 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -185,17 +185,18 @@ test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => { aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' }) aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' }) aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' }) - assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine']) + assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop']) assert.deepEqual(sf.FEATURE_LABELS, { news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' }, + shop: { name: 'Webshop', keine: 'keinen Webshop' }, }) }) test('liste_zeigt_termine_je_projekt_ohne_dokument_als_off', async () => { const list = await sf.listCustomerFeatures('c1') const p = list.find((x) => x.projectId === 'pt1') - assert.deepEqual(Object.keys(p.features), ['news', 'termine']) + assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'shop']) assert.equal(p.features.termine.state, 'off') assert.equal(p.features.termine.siteKey, null) assert.equal(p.features.termine.ticketWoid, null) @@ -261,7 +262,7 @@ test('activateClosedTickets: Funktionen mit ticketFreischaltung; News verhaelt s const ids = ['f_setup_termine', 'f_setup_news', 'f_setup_analyse'] const reg = await import('../server/services/featureRegistry.js') try { - assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine']) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop']) // News wie bisher; Termine wartet auf Einstellung und Abo (Haken freischaltbar); Analyse hat keine ticketFreischaltung const done = await sf.activateClosedTickets({ customerId: 'c1' }) assert.deepEqual(done.map((d) => d.featureKey), ['news']) From 129f8de278636f9b0d8812f3333ebef1d2dd86d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:35:37 +0000 Subject: [PATCH 039/105] Shop C2: Einstellungen normalisieren, laden und speichern Co-Authored-By: Claude Opus 5.5 --- server/services/shop/einstellungen.js | 330 ++++++++++++++++++++++++++ test/shopEinstellungen.test.js | 268 +++++++++++++++++++++ 2 files changed, 598 insertions(+) create mode 100644 server/services/shop/einstellungen.js create mode 100644 test/shopEinstellungen.test.js diff --git a/server/services/shop/einstellungen.js b/server/services/shop/einstellungen.js new file mode 100644 index 0000000..109ea93 --- /dev/null +++ b/server/services/shop/einstellungen.js @@ -0,0 +1,330 @@ +import { config } from '../../config.js' +import { getDocument, updateDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' + +/** + * Shop-Einstellungen (Plan 2026-10-05-webshop.md, Task C2; shopConfig.einstellungen als JSON-Text). + * Normalisieren ist rein: Eingabe => { einstellungen } mit frischem, vollstaendigem Objekt oder { fehler: { 'feld.pfad': 'Text' } } + * (Texte in Du-Form, das Portal spricht den Haendler an). Leere Pflichtangaben sind beim Normalisieren erlaubt, damit der Haendler + * Schritt fuer Schritt speichern kann; ob alles da ist, sagt istVollstaendig(). Geld ist immer ganze Cent. + */ + +const ZAHLUNGSARTEN = ['card', 'klarna', 'sepa_debit'] +const LAENDER = ['DE', 'AT'] +const MAX_VERSAND_CENT = 100_000 +const MAX_CENT = 10_000_000 +const PLZ_RE = { DE: /^\d{5}$/, AT: /^\d{4}$/ } +const USTID_RE = /^(DE\d{9}|ATU\d{8})$/ +const AKZENT_RE = /^#[0-9a-fA-F]{6}$/ + +const tief = (wert) => { + if (Array.isArray(wert)) return Object.freeze(wert.map(tief)) + if (wert && typeof wert === 'object') return Object.freeze(Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, tief(v)]))) + return wert +} + +export const STANDARD_EINSTELLUNGEN = tief({ + shopname: '', + anbieter: { name: '', strasse: '', plz: '', ort: '', land: 'DE', email: '', telefon: '', ustId: '' }, + kleinunternehmer: true, + versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null }, + abholung: { aktiv: false, adresse: '', hinweis: '' }, + lieferzeit: '2–4 Werktage', + zahlungsarten: ['card'], + akzent: '#111111', + mindestbestellCent: null, + ruecksendekosten: 'kaeufer', +}) + +/** Tiefe, veraenderbare Kopie (JSON-tauglich). */ +const kopie = (wert) => JSON.parse(JSON.stringify(wert)) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +/** Nur eigene Schluessel lesen, nie aus der Prototypkette (zB __proto__ aus JSON.parse bleibt ein eigener Schluessel, wird aber nie gelesen). */ +const hole = (objekt, schluessel) => (istObjekt(objekt) && Object.hasOwn(objekt, schluessel) ? objekt[schluessel] : undefined) + +// Steuerzeichen und unsichtbare Formatzeichen (Zero-Width, Richtungsmarken, BOM, Soft Hyphen) sowie Zeilen-/Absatztrenner +const STEUERZEICHEN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu + +/** Bereinigter Text: ohne Steuerzeichen, getrimmt; einzeilig macht aus Umbruechen Leerzeichen, mehrzeilig behaelt einzelne Umbrueche. */ +function bereinige(wert, { mehrzeilig = false } = {}) { + if (typeof wert !== 'string') return '' + let s = wert.replace(/\r\n?/g, '\n').replace(/\t/g, ' ') + s = mehrzeilig ? s.replace(STEUERZEICHEN, (z) => (z === '\n' ? z : '')).replace(/[ ]*\n[ ]*/g, '\n').replace(/\n{3,}/g, '\n\n') : s.replace(/\n+/g, ' ').replace(STEUERZEICHEN, '').replace(/ {2,}/g, ' ') + return s.trim() +} + +const laenge = (s) => [...s].length + +/** Ein Postfach: kein Leerraum, genau ein @, keine ,;<>" und mindestens ein Punkt in der Domain, hoechstens 254 Zeichen. */ +function emailAdresse(wert) { + const s = bereinige(wert).toLowerCase() + if (s.length === 0 || s.length > 254) return null + return /^[^\s@,;<>"]+@[^\s@,;<>".]+(\.[^\s@,;<>".]+)+$/.test(s) ? s : null +} + +/** + * Pruefen und Normalisieren. Fehlende Felder bekommen die Standardwerte, unbekannte Schluessel werden ignoriert, + * das Ergebnis teilt keine Referenzen mit der Eingabe. + * @returns {{einstellungen: object} | {fehler: Object}} + */ +export function normalisiereEinstellungen(eingabe) { + if (!istObjekt(eingabe)) return { fehler: { einstellungen: 'Die Einstellungen fehlen oder sind unlesbar.' } } + const fehler = {} + const fehl = (pfad, text) => { + if (!(pfad in fehler)) fehler[pfad] = text + } + const std = STANDARD_EINSTELLUNGEN + + /** Textfeld bis max Zeichen; leer erlaubt, ausser min > 0 (dann leer oder zu kurz = Fehler nur bei nicht leerem Text). */ + const textFeld = (pfad, wert, { max, min = 0, mehrzeilig = false, vorgabe = '', name }) => { + if (wert === undefined || wert === null) return vorgabe + if (typeof wert !== 'string') { + fehl(pfad, `Bitte gib ${name} als Text an.`) + return vorgabe + } + const s = bereinige(wert, { mehrzeilig }) + if (laenge(s) > max) { + fehl(pfad, `${name[0].toUpperCase()}${name.slice(1)} darf höchstens ${max} Zeichen lang sein.`) + return vorgabe + } + if (s !== '' && laenge(s) < min) { + fehl(pfad, `${name[0].toUpperCase()}${name.slice(1)} muss mindestens ${min} Zeichen lang sein.`) + return vorgabe + } + return s + } + const wahrheit = (pfad, wert, vorgabe, name) => { + if (wert === undefined || wert === null) return vorgabe + if (typeof wert !== 'boolean') { + fehl(pfad, `Bitte wähle bei „${name}“ ja oder nein.`) + return vorgabe + } + return wert + } + /** null oder ganze Zahl 1..max (Cent). */ + const centOderNull = (pfad, wert, name) => { + if (wert === undefined || wert === null) return null + if (!Number.isSafeInteger(wert) || wert <= 0 || wert > MAX_CENT) { + fehl(pfad, `${name} muss ein Betrag über 0 € (in ganzen Cent) sein – oder leer, wenn es keinen geben soll.`) + return null + } + return wert + } + + const shopname = textFeld('shopname', hole(eingabe, 'shopname'), { max: 80, name: 'der Shopname' }) + + // Anbieter + const a = hole(eingabe, 'anbieter') + const land = (() => { + const w = hole(a, 'land') + if (w === undefined || w === null) return std.anbieter.land + if (!LAENDER.includes(w)) { + fehl('anbieter.land', 'Bitte wähle Deutschland oder Österreich als Land deines Unternehmens.') + return std.anbieter.land + } + return w + })() + const anbieter = { + name: textFeld('anbieter.name', hole(a, 'name'), { max: 120, name: 'der Name des Anbieters' }), + strasse: textFeld('anbieter.strasse', hole(a, 'strasse'), { max: 120, name: 'die Straße' }), + plz: '', + ort: textFeld('anbieter.ort', hole(a, 'ort'), { max: 80, name: 'der Ort' }), + land, + email: '', + telefon: textFeld('anbieter.telefon', hole(a, 'telefon'), { max: 40, name: 'die Telefonnummer' }), + ustId: '', + } + const plzRoh = hole(a, 'plz') + if (plzRoh !== undefined && plzRoh !== null) { + const plz = bereinige(plzRoh) + if (plz === '') anbieter.plz = '' + else if (PLZ_RE[land].test(plz)) anbieter.plz = plz + else fehl('anbieter.plz', land === 'AT' ? 'Bitte gib eine österreichische Postleitzahl mit 4 Ziffern an.' : 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.') + } + const emailRoh = hole(a, 'email') + if (emailRoh !== undefined && emailRoh !== null && bereinige(emailRoh) !== '') { + const mail = emailAdresse(emailRoh) + if (mail) anbieter.email = mail + else fehl('anbieter.email', 'Bitte gib genau eine gültige E-Mail-Adresse an (höchstens 254 Zeichen).') + } + const ustRoh = hole(a, 'ustId') + if (ustRoh !== undefined && ustRoh !== null) { + const ust = bereinige(ustRoh).replace(/\s+/g, '').toUpperCase() + if (ust === '') anbieter.ustId = '' + else if (USTID_RE.test(ust)) anbieter.ustId = ust + else fehl('anbieter.ustId', 'Die USt-IdNr. sieht so aus: DE123456789 oder ATU12345678. Lass das Feld leer, wenn du keine hast.') + } + + const kleinunternehmer = wahrheit('kleinunternehmer', hole(eingabe, 'kleinunternehmer'), std.kleinunternehmer, 'Kleinunternehmer') + + // Versand + const v = hole(eingabe, 'versand') + const versandAktiv = wahrheit('versand.aktiv', hole(v, 'aktiv'), std.versand.aktiv, 'Versand anbieten') + let laender = [...std.versand.laender] + const laenderRoh = hole(v, 'laender') + if (laenderRoh !== undefined && laenderRoh !== null) { + const menge = Array.isArray(laenderRoh) ? new Set(laenderRoh) : null + if (menge && menge.has('DE') && [...menge].every((l) => LAENDER.includes(l))) laender = LAENDER.filter((l) => menge.has(l)) + else fehl('versand.laender', 'Du kannst nach Deutschland oder nach Deutschland und Österreich liefern. Deutschland ist immer dabei.') + } + const kostenRoh = hole(v, 'kostenCent') + const kostenCent = {} + for (const l of laender) { + const w = hole(kostenRoh, l) + if (w === undefined || w === null) { + if (versandAktiv && l !== 'DE') fehl(`versand.kostenCent.${l}`, `Bitte gib die Versandkosten für ${l === 'AT' ? 'Österreich' : 'Deutschland'} an.`) + kostenCent[l] = l === 'DE' ? std.versand.kostenCent.DE : 990 + } else if (!Number.isSafeInteger(w) || w < 0 || w > MAX_VERSAND_CENT) { + fehl(`versand.kostenCent.${l}`, `Die Versandkosten für ${l === 'AT' ? 'Österreich' : 'Deutschland'} müssen zwischen 0 € und 1.000 € liegen (in ganzen Cent).`) + kostenCent[l] = l === 'DE' ? std.versand.kostenCent.DE : 990 + } else kostenCent[l] = w + } + const freiAbCent = centOderNull('versand.freiAbCent', hole(v, 'freiAbCent'), 'Die Grenze für versandkostenfreie Lieferung') + + // Abholung + const ab = hole(eingabe, 'abholung') + const abholungAktiv = wahrheit('abholung.aktiv', hole(ab, 'aktiv'), std.abholung.aktiv, 'Abholung anbieten') + const abholung = { + aktiv: abholungAktiv, + adresse: textFeld('abholung.adresse', hole(ab, 'adresse'), { max: 300, mehrzeilig: true, name: 'die Abholadresse' }), + hinweis: textFeld('abholung.hinweis', hole(ab, 'hinweis'), { max: 300, mehrzeilig: true, name: 'der Abholhinweis' }), + } + if (abholungAktiv && abholung.adresse === '' && !('abholung.adresse' in fehler)) fehl('abholung.adresse', 'Bitte gib an, wo die Ware abgeholt werden kann.') + if (!versandAktiv && !abholungAktiv && !('versand.aktiv' in fehler) && !('abholung.aktiv' in fehler)) { + fehl('versand.aktiv', 'Bitte biete Versand oder Abholung an – mindestens eins von beiden muss aktiv sein.') + } + + const lieferzeit = textFeld('lieferzeit', hole(eingabe, 'lieferzeit'), { max: 80, min: 3, vorgabe: std.lieferzeit, name: 'die Lieferzeit' }) + + // Zahlungsarten: card immer dabei + let zahlungsarten = ['card'] + const zahlRoh = hole(eingabe, 'zahlungsarten') + if (zahlRoh !== undefined && zahlRoh !== null) { + if (Array.isArray(zahlRoh) && zahlRoh.every((z) => ZAHLUNGSARTEN.includes(z))) { + const menge = new Set(zahlRoh) + zahlungsarten = ZAHLUNGSARTEN.filter((z) => z === 'card' || menge.has(z)) + } else fehl('zahlungsarten', 'Bitte wähle nur Karte, Klarna oder SEPA-Lastschrift als Zahlungsarten. Die Kartenzahlung ist immer dabei.') + } + + let akzent = std.akzent + const akzentRoh = hole(eingabe, 'akzent') + if (akzentRoh !== undefined && akzentRoh !== null) { + if (typeof akzentRoh === 'string' && AKZENT_RE.test(akzentRoh.trim())) akzent = akzentRoh.trim().toLowerCase() + else fehl('akzent', 'Bitte gib die Akzentfarbe als Hex-Wert an, zum Beispiel #111111.') + } + + const mindestbestellCent = centOderNull('mindestbestellCent', hole(eingabe, 'mindestbestellCent'), 'Der Mindestbestellwert') + + let ruecksendekosten = std.ruecksendekosten + const rueckRoh = hole(eingabe, 'ruecksendekosten') + if (rueckRoh !== undefined && rueckRoh !== null) { + if (rueckRoh === 'kaeufer' || rueckRoh === 'haendler') ruecksendekosten = rueckRoh + else fehl('ruecksendekosten', 'Bitte wähle, ob die Käuferin bzw. der Käufer oder du die Kosten der Rücksendung trägst.') + } + + if (Object.keys(fehler).length) return { fehler } + return { + einstellungen: { + shopname, + anbieter, + kleinunternehmer, + versand: { aktiv: versandAktiv, laender, kostenCent, freiAbCent }, + abholung, + lieferzeit, + zahlungsarten, + akzent, + mindestbestellCent, + ruecksendekosten, + }, + } +} + +/** Alles Noetige zum Verkaufen: Normalisierung ohne Fehler und Pflichtangaben vorhanden. */ +export function istVollstaendig(einstellungen) { + const r = normalisiereEinstellungen(einstellungen) + if (r.fehler) return false + const e = r.einstellungen + return [e.shopname, e.anbieter.name, e.anbieter.strasse, e.anbieter.plz, e.anbieter.ort, e.anbieter.email, e.lieferzeit].every((s) => s !== '') +} + +/** Standardwerte, darueber die bekannten Felder aus gespeichertem Stand (eine Ebene tief fuer die Unterobjekte). */ +function mitStandard(roh) { + const e = kopie(STANDARD_EINSTELLUNGEN) + if (!istObjekt(roh)) return e + for (const key of Object.keys(e)) { + const w = hole(roh, key) + if (w === undefined) continue + if (['anbieter', 'abholung'].includes(key)) { + if (istObjekt(w)) for (const k of Object.keys(e[key])) if (hole(w, k) !== undefined) e[key][k] = kopie(w[k]) + } else if (key === 'versand') { + if (istObjekt(w)) { + for (const k of Object.keys(e.versand)) if (hole(w, k) !== undefined) e.versand[k] = kopie(w[k]) + } + } else e[key] = kopie(w) + } + return e +} + +/** + * Einstellungen aus einem shopConfig-Dokument. Fehlt der JSON-Text oder ist er kaputt, gelten die Standardwerte. + * Ein lesbarer, aber ungueltiger Stand wird nicht verworfen, sondern mit den Standardwerten aufgefuellt zurueckgegeben + * (istVollstaendig() / normalisiereEinstellungen() melden dann die Fehler). + */ +export function ladeEinstellungen(shopDoc) { + let roh = null + try { + roh = typeof shopDoc?.einstellungen === 'string' && shopDoc.einstellungen !== '' ? JSON.parse(shopDoc.einstellungen) : null + } catch { + roh = null + } + const gefuellt = mitStandard(roh) + const r = normalisiereEinstellungen(gefuellt) + return r.einstellungen || gefuellt +} + +// ---------------------------------------------------------------- Speichern + +const hooks = new Set() + +/** Meldet eine Funktion an, die nach jedem Speichern mit der Shop-ID gerufen wird (zB Cache leeren). Rueckgabe: Abmelden. */ +export function nachSpeichern(fn) { + if (typeof fn !== 'function') throw new TypeError('nachSpeichern: Funktion erwartet') + hooks.add(fn) + return () => hooks.delete(fn) +} + +function rufeHooks(shopId) { + for (const fn of [...hooks]) { + try { + Promise.resolve(fn(shopId)).catch(() => {}) + } catch { + /* Fehler im Hook duerfen das Speichern nicht kippen */ + } + } +} + +const veraltet = () => + Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte lade die Seite neu.'), { status: 409, code: 'EINSTELLUNG_VERALTET' }) + +/** + * Speichert die Einstellungen eines Shops (Dokument-ID = shopId) und erhoeht `version`. + * Mit erwarteteVersion (ganze Zahl; 0 = noch nie gespeichert) wird gegen gleichzeitige Aenderungen geprueft: Abweichung => Fehler 409. + * Ungueltige Einstellungen => Fehler 400 mit `fehler` (Feld => Text), unbekannter Shop => 404 aus Appwrite. + * @returns {Promise<{einstellungen: object, version: number}>} + */ +export async function speichereEinstellungen(shopId, einstellungen, { erwarteteVersion } = {}) { + if (typeof shopId !== 'string' || shopId === '') throw new TypeError('shopId fehlt') + const r = normalisiereEinstellungen(einstellungen) + if (r.fehler) throw Object.assign(new Error('Die Einstellungen sind nicht gültig.'), { status: 400, fehler: r.fehler }) + const ergebnis = await withLocks([`shop-cfg:${shopId}`], async () => { + const col = config.collections.shopConfig + const doc = await getDocument(col, shopId) + const aktuell = Number.isInteger(doc?.version) && doc.version > 0 ? doc.version : 0 + if (erwarteteVersion !== undefined && erwarteteVersion !== null && erwarteteVersion !== aktuell) throw veraltet() + await updateDocument(col, shopId, { einstellungen: JSON.stringify(r.einstellungen), version: aktuell + 1 }) + return { einstellungen: r.einstellungen, version: aktuell + 1 } + }) + rufeHooks(shopId) + return ergebnis +} diff --git a/test/shopEinstellungen.test.js b/test/shopEinstellungen.test.js new file mode 100644 index 0000000..0028a28 --- /dev/null +++ b/test/shopEinstellungen.test.js @@ -0,0 +1,268 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Einstellungen des Shops: reines Normalisieren (Fehler je Feld) und Speichern mit Versionspruefung gegen den Appwrite-Nachbau. +const aw = await startAppwriteMock({ schema: { shopConfig: schemaKeys('shopConfig') }, datetimes: SHOP_DATETIMES }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { createDocument, getDocument } = await import('../server/services/appwriteAdmin.js') +const E = await import('../server/services/shop/einstellungen.js') +const { STANDARD_EINSTELLUNGEN, normalisiereEinstellungen, ladeEinstellungen, speichereEinstellungen, nachSpeichern, istVollstaendig } = E + +const komplett = (over = {}) => ({ + shopname: 'Seifenmanufaktur Mustermann', + anbieter: { + name: 'Erika Mustermann', + strasse: 'Musterstraße 1', + plz: '80331', + ort: 'München', + land: 'DE', + email: 'Erika@Example.com', + telefon: '089 123456', + ustId: '', + }, + kleinunternehmer: true, + versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null }, + abholung: { aktiv: false, adresse: '', hinweis: '' }, + lieferzeit: '2–4 Werktage', + zahlungsarten: ['card'], + akzent: '#111111', + mindestbestellCent: null, + ruecksendekosten: 'kaeufer', + ...over, +}) +const fehler = (eingabe) => normalisiereEinstellungen(eingabe).fehler +const ok = (eingabe) => { + const r = normalisiereEinstellungen(eingabe) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return r.einstellungen +} + +test('Standardwerte sind eingefroren, so wie im Plan und selbst gueltig', () => { + assert.ok(Object.isFrozen(STANDARD_EINSTELLUNGEN)) + assert.ok(Object.isFrozen(STANDARD_EINSTELLUNGEN.versand)) + assert.ok(Object.isFrozen(STANDARD_EINSTELLUNGEN.anbieter)) + assert.deepEqual(STANDARD_EINSTELLUNGEN, { + shopname: '', + anbieter: { name: '', strasse: '', plz: '', ort: '', land: 'DE', email: '', telefon: '', ustId: '' }, + kleinunternehmer: true, + versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null }, + abholung: { aktiv: false, adresse: '', hinweis: '' }, + lieferzeit: '2–4 Werktage', + zahlungsarten: ['card'], + akzent: '#111111', + mindestbestellCent: null, + ruecksendekosten: 'kaeufer', + }) + assert.deepEqual(ok(STANDARD_EINSTELLUNGEN), STANDARD_EINSTELLUNGEN) + assert.equal(istVollstaendig(STANDARD_EINSTELLUNGEN), false) +}) + +test('komplette Eingabe: normalisiert, E-Mail klein, ohne gemeinsame Referenzen', () => { + const eingabe = komplett() + const e = ok(eingabe) + assert.equal(e.anbieter.email, 'erika@example.com') + assert.notEqual(e.versand, eingabe.versand) + assert.notEqual(e.versand.laender, eingabe.versand.laender) + assert.notEqual(e.versand.kostenCent, eingabe.versand.kostenCent) + assert.notEqual(e.anbieter, eingabe.anbieter) + assert.notEqual(e.zahlungsarten, eingabe.zahlungsarten) + e.versand.laender.push('X') + assert.deepEqual(eingabe.versand.laender, ['DE']) + assert.deepEqual(STANDARD_EINSTELLUNGEN.versand.laender, ['DE']) + assert.equal(istVollstaendig(komplett()), true) +}) + +test('leere und fehlende Eingabe: Standardwerte; keine Objekte: Fehler', () => { + assert.deepEqual(ok({}), STANDARD_EINSTELLUNGEN) + for (const x of [null, undefined, 'x', 5, [], true]) assert.ok(fehler(x)?.einstellungen, String(x)) +}) + +test('unbekannte Schluessel werden ignoriert, auch __proto__', () => { + const e = ok(JSON.parse('{"foo":1,"__proto__":{"x":1},"anbieter":{"zusatz":"a"},"versand":{"y":2}}')) + assert.deepEqual(Object.keys(e).sort(), Object.keys(STANDARD_EINSTELLUNGEN).sort()) + assert.equal(e.anbieter.zusatz, undefined) + assert.equal(e.versand.y, undefined) + assert.equal({}.x, undefined) +}) + +test('Texte: getrimmt, Steuerzeichen entfernt, Laengengrenzen je Feld', () => { + const e = ok(komplett({ shopname: ' Laden\u0000​ Eins \n', abholung: { aktiv: true, adresse: 'Weg 1\r\n80331 München\u0007', hinweis: 'Klingeln' } })) + assert.equal(e.shopname, 'Laden Eins') + assert.equal(e.abholung.adresse, 'Weg 1\n80331 München') + const grenzen = [ + ['shopname', komplett({ shopname: 'a'.repeat(81) })], + ['anbieter.name', komplett({ anbieter: { ...komplett().anbieter, name: 'a'.repeat(121) } })], + ['anbieter.strasse', komplett({ anbieter: { ...komplett().anbieter, strasse: 'a'.repeat(121) } })], + ['anbieter.ort', komplett({ anbieter: { ...komplett().anbieter, ort: 'a'.repeat(81) } })], + ['anbieter.telefon', komplett({ anbieter: { ...komplett().anbieter, telefon: '1'.repeat(41) } })], + ['lieferzeit', komplett({ lieferzeit: 'a'.repeat(81) })], + ['abholung.hinweis', komplett({ abholung: { aktiv: true, adresse: 'Weg 1', hinweis: 'a'.repeat(301) } })], + ['abholung.adresse', komplett({ abholung: { aktiv: true, adresse: 'a'.repeat(301), hinweis: '' } })], + ] + for (const [pfad, eingabe] of grenzen) assert.ok(fehler(eingabe)?.[pfad], pfad) + ok(komplett({ shopname: 'a'.repeat(80), lieferzeit: 'abc' })) + assert.ok(fehler(komplett({ lieferzeit: 'ab' }))?.lieferzeit) + assert.ok(fehler(komplett({ shopname: 5 }))?.shopname) +}) + +test('Anbieter: E-Mail, PLZ je Land, Land, USt-IdNr.', () => { + const an = (over) => komplett({ anbieter: { ...komplett().anbieter, ...over } }) + for (const mail of ['x', 'a@b', 'a b@c.de', 'a@b.de, c@d.de', `${'a'.repeat(250)}@b.de`, 'a@@b.de', '']) { + assert.ok(fehler(an({ email: mail }))?.['anbieter.email'], mail) + } + assert.equal(ok(an({ email: ' A@B.de ' })).anbieter.email, 'a@b.de') + assert.ok(fehler(an({ plz: '8033' }))?.['anbieter.plz']) + assert.ok(fehler(an({ plz: '80a31' }))?.['anbieter.plz']) + assert.equal(ok(an({ land: 'AT', plz: '1010' })).anbieter.land, 'AT') + assert.ok(fehler(an({ land: 'AT', plz: '80331' }))?.['anbieter.plz']) + assert.ok(fehler(an({ land: 'FR' }))?.['anbieter.land']) + assert.equal(ok(an({ ustId: ' de123456789 ' })).anbieter.ustId, 'DE123456789') + assert.equal(ok(an({ ustId: 'ATU12345678' })).anbieter.ustId, 'ATU12345678') + for (const u of ['DE12345678', 'FR123456789', 'ATU1234567', 'DE1234567890']) assert.ok(fehler(an({ ustId: u }))?.['anbieter.ustId'], u) +}) + +test('Versand: Laender, Kosten je Land, Freigrenze', () => { + const vs = (over) => komplett({ versand: { ...komplett().versand, ...over } }) + const at = ok(vs({ laender: ['AT', 'DE'], kostenCent: { DE: 490, AT: 990, FR: 5 } })) + assert.deepEqual(at.versand.laender, ['DE', 'AT']) + assert.deepEqual(at.versand.kostenCent, { DE: 490, AT: 990 }) + assert.ok(fehler(vs({ laender: ['DE', 'AT'], kostenCent: { DE: 490 } }))?.['versand.kostenCent.AT']) + assert.ok(fehler(vs({ kostenCent: { DE: 4.9 } }))?.['versand.kostenCent.DE']) + assert.ok(fehler(vs({ kostenCent: { DE: '490' } }))?.['versand.kostenCent.DE']) + assert.ok(fehler(vs({ kostenCent: { DE: -1 } }))?.['versand.kostenCent.DE']) + assert.ok(fehler(vs({ kostenCent: { DE: 100001 } }))?.['versand.kostenCent.DE']) + assert.equal(ok(vs({ kostenCent: { DE: 0 } })).versand.kostenCent.DE, 0) + assert.equal(ok(vs({ kostenCent: { DE: 100000 } })).versand.kostenCent.DE, 100000) + for (const l of [['AT'], ['DE', 'FR'], [], 'DE', ['DE', 'DE', 'AT', 'CH']]) assert.ok(fehler(vs({ laender: l }))?.['versand.laender'], JSON.stringify(l)) + assert.equal(ok(vs({ freiAbCent: 5000 })).versand.freiAbCent, 5000) + assert.equal(ok(vs({ freiAbCent: null })).versand.freiAbCent, null) + for (const f of [0, -5, 12.5, '5000', Infinity]) assert.ok(fehler(vs({ freiAbCent: f }))?.['versand.freiAbCent'], String(f)) + assert.ok(fehler(vs({ aktiv: 'ja' }))?.['versand.aktiv']) +}) + +test('mindestens Versand oder Abholung; Abholadresse Pflicht', () => { + const nichts = fehler(komplett({ versand: { ...komplett().versand, aktiv: false } })) + assert.ok(nichts?.['versand.aktiv']) + const abholungNur = ok(komplett({ versand: { ...komplett().versand, aktiv: false }, abholung: { aktiv: true, adresse: 'Lager 3', hinweis: 'Mo–Fr' } })) + assert.equal(abholungNur.abholung.adresse, 'Lager 3') + assert.ok(fehler(komplett({ abholung: { aktiv: true, adresse: ' ', hinweis: '' } }))?.['abholung.adresse']) + // inaktive Abholung braucht keine Adresse + ok(komplett({ abholung: { aktiv: false, adresse: '', hinweis: '' } })) +}) + +test('Zahlungsarten: card immer dabei, nur bekannte, feste Reihenfolge', () => { + assert.deepEqual(ok(komplett({ zahlungsarten: ['sepa_debit', 'klarna'] })).zahlungsarten, ['card', 'klarna', 'sepa_debit']) + assert.deepEqual(ok(komplett({ zahlungsarten: ['card', 'card'] })).zahlungsarten, ['card']) + assert.deepEqual(ok(komplett({ zahlungsarten: [] })).zahlungsarten, ['card']) + assert.ok(fehler(komplett({ zahlungsarten: ['card', 'paypal'] }))?.zahlungsarten) + assert.ok(fehler(komplett({ zahlungsarten: 'card' }))?.zahlungsarten) +}) + +test('Akzent, Mindestbestellwert, Ruecksendekosten, Kleinunternehmer', () => { + assert.equal(ok(komplett({ akzent: '#AABBCC' })).akzent, '#aabbcc') + for (const a of ['AABBCC', '#abc', '#12345g', 'red', '#1234567']) assert.ok(fehler(komplett({ akzent: a }))?.akzent, a) + assert.equal(ok(komplett({ mindestbestellCent: 1000 })).mindestbestellCent, 1000) + for (const m of [0, -1, 1.5, '10']) assert.ok(fehler(komplett({ mindestbestellCent: m }))?.mindestbestellCent, String(m)) + assert.equal(ok(komplett({ ruecksendekosten: 'haendler' })).ruecksendekosten, 'haendler') + assert.ok(fehler(komplett({ ruecksendekosten: 'egal' }))?.ruecksendekosten) + assert.equal(ok(komplett({ kleinunternehmer: false })).kleinunternehmer, false) + assert.ok(fehler(komplett({ kleinunternehmer: 'nein' }))?.kleinunternehmer) +}) + +test('mehrere Fehler zugleich, alle Texte in Du-Form ohne Sie', () => { + const f = fehler(komplett({ akzent: 'x', lieferzeit: 'a', zahlungsarten: ['paypal'], anbieter: { ...komplett().anbieter, email: 'x', plz: '1' } })) + assert.deepEqual(Object.keys(f).sort(), ['akzent', 'anbieter.email', 'anbieter.plz', 'lieferzeit', 'zahlungsarten']) + for (const t of Object.values(f)) { + assert.equal(typeof t, 'string') + assert.doesNotMatch(t, /\bSie\b|\bIhre?[mnrs]?\b/) + } +}) + +test('istVollstaendig: Pflichtangaben und gueltige Normalisierung', () => { + assert.equal(istVollstaendig(komplett()), true) + for (const pfad of ['shopname', 'lieferzeit']) assert.equal(istVollstaendig(komplett({ [pfad]: '' })), false, pfad) + for (const feld of ['name', 'strasse', 'plz', 'ort', 'email']) { + assert.equal(istVollstaendig(komplett({ anbieter: { ...komplett().anbieter, [feld]: '' } })), false, feld) + } + assert.equal(istVollstaendig(komplett({ anbieter: { ...komplett().anbieter, telefon: '', ustId: '' } })), true) + assert.equal(istVollstaendig(komplett({ akzent: 'kaputt' })), false) + assert.equal(istVollstaendig(null), false) +}) + +test('ladeEinstellungen: kaputt oder fehlend ergibt Standard, Teilwerte werden aufgefuellt', () => { + for (const doc of [null, undefined, {}, { einstellungen: '' }, { einstellungen: '{kaputt' }, { einstellungen: '[1]' }, { einstellungen: 'null' }, { einstellungen: '"x"' }]) { + assert.deepEqual(ladeEinstellungen(doc), STANDARD_EINSTELLUNGEN) + } + const teil = ladeEinstellungen({ einstellungen: JSON.stringify({ shopname: 'Laden', anbieter: { ort: 'Ulm' }, versand: { freiAbCent: 5000 } }) }) + assert.equal(teil.shopname, 'Laden') + assert.equal(teil.anbieter.ort, 'Ulm') + assert.equal(teil.anbieter.land, 'DE') + assert.equal(teil.versand.freiAbCent, 5000) + assert.deepEqual(teil.versand.kostenCent, { DE: 490 }) + assert.equal(teil.lieferzeit, '2–4 Werktage') + // gespeicherte Werte werden nicht in die Standardwerte geschrieben + teil.versand.laender.push('AT') + assert.deepEqual(STANDARD_EINSTELLUNGEN.versand.laender, ['DE']) + assert.notEqual(ladeEinstellungen(null), STANDARD_EINSTELLUNGEN) +}) + +test('speichereEinstellungen: Version steigt, Versionspruefung, Hooks', async () => { + const id = 'f_p_shop0001_shop' + await createDocument(config.collections.shopConfig, { customerId: 'c1', projectId: 'p1', siteKey: 'abcdefghijklmnop' }, id) + const gerufen = [] + const abmelden = nachSpeichern((shopId) => gerufen.push(shopId)) + nachSpeichern(() => { + throw new Error('Hook kaputt') + }) + nachSpeichern(async () => { + throw new Error('Hook async kaputt') + }) + + const r1 = await speichereEinstellungen(id, komplett(), { erwarteteVersion: 0 }) + assert.equal(r1.version, 1) + assert.equal(r1.einstellungen.anbieter.email, 'erika@example.com') + let doc = await getDocument(config.collections.shopConfig, id) + assert.equal(doc.version, 1) + assert.equal(JSON.parse(doc.einstellungen).shopname, 'Seifenmanufaktur Mustermann') + assert.deepEqual(ladeEinstellungen(doc), r1.einstellungen) + assert.deepEqual(gerufen, [id]) + + const r2 = await speichereEinstellungen(id, komplett({ shopname: 'Neu' })) + assert.equal(r2.version, 2) + await assert.rejects(speichereEinstellungen(id, komplett({ shopname: 'Alt' }), { erwarteteVersion: 1 }), (err) => { + assert.equal(err.status, 409) + assert.equal(err.message, 'Die Einstellungen wurden inzwischen geändert. Bitte lade die Seite neu.') + return true + }) + doc = await getDocument(config.collections.shopConfig, id) + assert.equal(doc.version, 2) + assert.equal(JSON.parse(doc.einstellungen).shopname, 'Neu') + assert.equal(gerufen.length, 2) + + // ungueltige Einstellungen werden nicht gespeichert: 400 mit Feldfehlern + await assert.rejects(speichereEinstellungen(id, komplett({ akzent: 'x' })), (err) => err.status === 400 && Boolean(err.fehler.akzent)) + assert.equal((await getDocument(config.collections.shopConfig, id)).version, 2) + assert.equal(gerufen.length, 2) + + // gleichzeitige Speicherungen mit derselben erwarteten Version: genau eine gewinnt + const rennen = await Promise.allSettled([ + speichereEinstellungen(id, komplett({ shopname: 'A' }), { erwarteteVersion: 2 }), + speichereEinstellungen(id, komplett({ shopname: 'B' }), { erwarteteVersion: 2 }), + ]) + assert.equal(rennen.filter((x) => x.status === 'fulfilled').length, 1) + assert.equal((await getDocument(config.collections.shopConfig, id)).version, 3) + + abmelden() + await speichereEinstellungen(id, komplett({ shopname: 'C' })) + assert.equal(gerufen.length, 3) // A oder B war der dritte Aufruf, danach keiner mehr + + // unbekannter Shop + await assert.rejects(speichereEinstellungen('gibt-es-nicht', komplett()), (err) => err.status === 404) +}) From bea9f3cfba6e21fb082a46bb108fd655ef76e412 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:39:33 +0000 Subject: [PATCH 040/105] =?UTF-8?q?Shop=20C3:=20Produkte=20normalisieren,?= =?UTF-8?q?=20K=C3=A4ufer-Sicht=20und=20Speichern=20(shopProducts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/shop/produkte.js | 487 +++++++++++++++++++++++++++++++ test/shopProdukte.test.js | 398 +++++++++++++++++++++++++ 2 files changed, 885 insertions(+) create mode 100644 server/services/shop/produkte.js create mode 100644 test/shopProdukte.test.js diff --git a/server/services/shop/produkte.js b/server/services/shop/produkte.js new file mode 100644 index 0000000..524d30e --- /dev/null +++ b/server/services/shop/produkte.js @@ -0,0 +1,487 @@ +import { randomInt } from 'node:crypto' +import { config } from '../../config.js' +import { createDocument, getDocument, updateDocument, deleteDocument, listAllDocuments, Query } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { euro, grundpreisText } from './geld.js' + +/** + * Shop-Artikel (Plan 2026-10-05-webshop.md, Task C3; shopProducts.produkt als JSON-Text). + * Normalisieren ist rein: Eingabe => { produkt } mit frischem Objekt oder { fehler: { 'feld.pfad': 'Du-Text' } }. + * produktOeffentlich liefert nur Anzeigefelder fuer den Kaeufer (Sie-Form entsteht erst im Widget). Geld ist immer ganze Cent. + */ + +const MAX_CENT = 10_000_000 +const MAX_BESTAND = 1_000_000 +const STEUERSAETZE = [0, 7, 19] +const EINHEITEN = ['g', 'kg', 'ml', 'l', 'm', 'm2'] +const LIEFERARTEN = ['beides', 'abholung'] +const WIDERRUFE = ['gesetzlich', 'verderblich', 'individuell', 'hygiene'] +const WERT_ID_RE = /^[a-z0-9]{8}$/ +const BILD_ID_RE = /^[a-z0-9-]{1,64}$/ +const MAX_JSON = 30000 + +const WIDERRUF_TEXTE = Object.freeze({ + verderblich: 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware', + individuell: 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', + hygiene: 'Vom Widerruf ausgeschlossen: versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde', +}) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +/** Nur eigene Schluessel lesen, nie aus der Prototypkette. */ +const hole = (objekt, schluessel) => (istObjekt(objekt) && Object.hasOwn(objekt, schluessel) ? objekt[schluessel] : undefined) +const laenge = (s) => [...s].length + +// Steuerzeichen und unsichtbare Formatzeichen (Zero-Width, Richtungsmarken, BOM, Soft Hyphen) sowie Zeilen-/Absatztrenner +const STEUERZEICHEN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu + +/** Bereinigter Text: ohne Steuerzeichen, getrimmt; einzeilig macht aus Umbruechen Leerzeichen, mehrzeilig behaelt einzelne Umbrueche. */ +function bereinige(wert, { mehrzeilig = false } = {}) { + if (typeof wert !== 'string') return '' + let s = wert.replace(/\r\n?/g, '\n').replace(/\t/g, ' ') + s = mehrzeilig + ? s.replace(STEUERZEICHEN, (z) => (z === '\n' ? z : '')).replace(/[ ]*\n[ ]*/g, '\n').replace(/\n{3,}/g, '\n\n') + : s.replace(/\n+/g, ' ').replace(STEUERZEICHEN, '').replace(/ {2,}/g, ' ') + return s.trim() +} + +/** Zufaellige 8-stellige Wert-ID aus [a-z0-9] (kryptografisch, ohne Modulo-Verzerrung). */ +function neueWertId() { + const zeichen = 'abcdefghijklmnopqrstuvwxyz0123456789' + let id = '' + for (let i = 0; i < 8; i += 1) id += zeichen[randomInt(zeichen.length)] + return id +} + +/** Ganze Zahl aus Zahl oder reinem Ziffern-Text, sonst null. */ +function ganzeZahl(wert) { + if (typeof wert === 'number') return Number.isSafeInteger(wert) ? wert : null + if (typeof wert === 'string' && /^\d{1,12}$/.test(wert.trim())) return Number(wert.trim()) + return null +} + +const leer = (wert) => wert === undefined || wert === null || (typeof wert === 'string' && wert.trim() === '') + +/** + * Pruefen und Normalisieren eines Artikels. Unbekannte Schluessel werden ignoriert, das Ergebnis teilt keine Referenzen + * mit der Eingabe. einstellungen bestimmt nur die Steuer (Kleinunternehmer => immer 0 %). + * @returns {{produkt: object} | {fehler: Object}} + */ +export function normalisiereProdukt(eingabe, { einstellungen } = {}) { + if (!istObjekt(eingabe)) return { fehler: { produkt: 'Der Artikel fehlt oder ist unlesbar.' } } + const fehler = {} + const fehl = (pfad, text) => { + if (!(pfad in fehler)) fehler[pfad] = text + } + const kleinunternehmer = einstellungen?.kleinunternehmer === true + + const text = (pfad, wert, { max, min = 0, mehrzeilig = false, name }) => { + if (wert === undefined || wert === null) { + if (min > 0) fehl(pfad, `Bitte gib ${name} an.`) + return '' + } + if (typeof wert !== 'string') { + fehl(pfad, `Bitte gib ${name} als Text an.`) + return '' + } + const s = bereinige(wert, { mehrzeilig }) + if (s.length === 0 && min > 0) fehl(pfad, `Bitte gib ${name} an.`) + else if (laenge(s) > max) fehl(pfad, `${name[0].toUpperCase()}${name.slice(1)} darf höchstens ${max} Zeichen lang sein.`) + return s + } + + const cent = (pfad, wert, name) => { + const n = ganzeZahl(wert) + if (n === null || n <= 0 || n > MAX_CENT) { + fehl(pfad, `Bitte gib ${name} als Betrag über 0 € und höchstens 100.000 € an.`) + return null + } + return n + } + + const bestandFeld = (pfad, wert) => { + if (leer(wert)) return null + const n = ganzeZahl(wert) + if (n === null || n < 0 || n > MAX_BESTAND) { + fehl(pfad, 'Bitte gib den Bestand als ganze Zahl ab 0 an oder lass das Feld leer für unbegrenzt.') + return null + } + return n + } + + const name = text('name', hole(eingabe, 'name'), { max: 120, min: 1, name: 'einen Namen für den Artikel' }) + const kategorie = text('kategorie', hole(eingabe, 'kategorie'), { max: 40, name: 'die Kategorie' }) + const kurz = text('kurz', hole(eingabe, 'kurz'), { max: 300, name: 'die Kurzbeschreibung' }) + const beschreibung = text('beschreibung', hole(eingabe, 'beschreibung'), { max: 5000, mehrzeilig: true, name: 'die Beschreibung' }) + + const aktivRoh = hole(eingabe, 'aktiv') + let aktiv = true + if (aktivRoh !== undefined && aktivRoh !== null) { + if (typeof aktivRoh !== 'boolean') fehl('aktiv', 'Bitte wähle bei „Aktiv“ ja oder nein.') + else aktiv = aktivRoh + } + + // Steuer + let steuersatz + const steuerRoh = hole(eingabe, 'steuersatz') + if (kleinunternehmer) steuersatz = 0 + else if (leer(steuerRoh)) steuersatz = 19 + else { + const n = ganzeZahl(steuerRoh) + if (n === null || !STEUERSAETZE.includes(n)) { + fehl('steuersatz', 'Bitte wähle als Steuersatz 0 %, 7 % oder 19 %.') + steuersatz = 19 + } else steuersatz = n + } + + // Varianten (ohne Namen und ohne Werte = keine Varianten) + let varianten = null + const variantenRoh = hole(eingabe, 'varianten') + if (variantenRoh !== undefined && variantenRoh !== null) { + if (!istObjekt(variantenRoh)) fehl('varianten', 'Die Varianten sind unlesbar.') + else { + const wertRoh = hole(variantenRoh, 'werte') + const werteListe = Array.isArray(wertRoh) ? wertRoh : [] + const leerVariante = leer(hole(variantenRoh, 'name')) && werteListe.length === 0 + if (wertRoh !== undefined && wertRoh !== null && !Array.isArray(wertRoh)) fehl('varianten.werte', 'Die Werte der Variante sind unlesbar.') + else if (!leerVariante) { + const vName = text('varianten.name', hole(variantenRoh, 'name'), { max: 30, min: 1, name: 'einen Namen für die Variante (zum Beispiel „Größe“)' }) + const max = config.shop.maxVarianten + if (werteListe.length === 0) fehl('varianten.werte', 'Bitte lege mindestens einen Wert für die Variante an.') + else if (werteListe.length > max) fehl('varianten.werte', `Du kannst höchstens ${max} Werte je Variante anlegen.`) + const benutzt = new Set() + const namen = new Set() + const werte = [] + werteListe.slice(0, max).forEach((w, i) => { + const pf = `varianten.werte.${i}` + if (!istObjekt(w)) { + fehl(pf, 'Dieser Wert ist unlesbar.') + return + } + let id = hole(w, 'id') + if (typeof id !== 'string' || !WERT_ID_RE.test(id) || benutzt.has(id)) { + do id = neueWertId() + while (benutzt.has(id)) + } + benutzt.add(id) + const wName = text(`${pf}.name`, hole(w, 'name'), { max: 40, min: 1, name: 'einen Namen für den Wert' }) + if (wName && namen.has(wName.toLowerCase())) fehl(`${pf}.name`, `Den Wert „${wName}“ gibt es schon, bitte wähle einen anderen Namen.`) + namen.add(wName.toLowerCase()) + werte.push({ + id, + name: wName, + preisCent: cent(`${pf}.preisCent`, hole(w, 'preisCent'), 'den Preis des Werts'), + bestand: bestandFeld(`${pf}.bestand`, hole(w, 'bestand')), + }) + }) + varianten = { name: vName, werte } + } + } + } + + // Preis und Bestand: mit Varianten zaehlen nur die Werte + let preisCent = null + let bestand = null + if (!varianten) { + preisCent = cent('preisCent', hole(eingabe, 'preisCent'), 'den Preis') + bestand = bestandFeld('bestand', hole(eingabe, 'bestand')) + } + + // Grundpreis + let grundpreis = null + const gpRoh = hole(eingabe, 'grundpreis') + if (gpRoh !== undefined && gpRoh !== null) { + if (!istObjekt(gpRoh)) fehl('grundpreis', 'Der Grundpreis ist unlesbar.') + else if (!(leer(hole(gpRoh, 'menge')) && leer(hole(gpRoh, 'einheit')))) { + let mengeRoh = hole(gpRoh, 'menge') + if (typeof mengeRoh === 'string') mengeRoh = /^\d+([.,]\d+)?$/.test(mengeRoh.trim()) ? Number(mengeRoh.trim().replace(',', '.')) : NaN + const einheit = hole(gpRoh, 'einheit') + const mengeOk = typeof mengeRoh === 'number' && Number.isFinite(mengeRoh) && mengeRoh > 0 && mengeRoh <= 1_000_000 + const einheitOk = typeof einheit === 'string' && EINHEITEN.includes(einheit) + if (!mengeOk) fehl('grundpreis.menge', 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.') + if (!einheitOk) fehl('grundpreis.einheit', 'Bitte wähle als Einheit für den Grundpreis g, kg, ml, l, m oder m².') + if (mengeOk && einheitOk) { + if (grundpreisText({ preisCent: 100, menge: mengeRoh, einheit }) === '') fehl('grundpreis.menge', 'Diese Menge ist für den Grundpreis zu klein oder zu groß.') + else grundpreis = { menge: mengeRoh, einheit } + } + } + } + + // Bilder + const bilder = [] + const bilderRoh = hole(eingabe, 'bilder') + if (bilderRoh !== undefined && bilderRoh !== null) { + if (!Array.isArray(bilderRoh)) fehl('bilder', 'Die Bilder sind unlesbar.') + else if (bilderRoh.length > config.shop.maxBilder) fehl('bilder', `Du kannst höchstens ${config.shop.maxBilder} Bilder je Artikel anlegen.`) + else { + for (const id of bilderRoh) { + if (typeof id !== 'string' || !BILD_ID_RE.test(id)) { + fehl('bilder', 'Eines der Bilder ist ungültig, bitte lade es neu hoch.') + break + } + if (bilder.includes(id)) { + fehl('bilder', 'Du hast dasselbe Bild mehrfach hinzugefügt.') + break + } + bilder.push(id) + } + } + } + + // Lieferart und Widerruf + let lieferart = 'beides' + const lieferRoh = hole(eingabe, 'lieferart') + if (!leer(lieferRoh)) { + if (typeof lieferRoh === 'string' && LIEFERARTEN.includes(lieferRoh)) lieferart = lieferRoh + else fehl('lieferart', 'Bitte wähle als Lieferart „Versand und Abholung“ oder „Nur Abholung“.') + } + let widerruf = 'gesetzlich' + const widerrufRoh = hole(eingabe, 'widerruf') + if (!leer(widerrufRoh)) { + if (typeof widerrufRoh === 'string' && WIDERRUFE.includes(widerrufRoh)) widerruf = widerrufRoh + else fehl('widerruf', 'Bitte wähle eine der angebotenen Möglichkeiten beim Widerrufsrecht.') + } + + // Produktsicherheit (GPSR) + const siRoh = hole(eingabe, 'sicherheit') + const adresse = (pfad, wert, { pflicht, bezeichnung }) => { + if (!istObjekt(wert)) { + if (pflicht) fehl(pfad, `Bitte gib ${bezeichnung} mit Name, Anschrift und Kontakt an.`) + return null + } + const felder = { name: 'den Namen', anschrift: 'die Anschrift', kontakt: 'einen Kontakt (E-Mail oder Telefon)' } + const roh = Object.fromEntries(Object.keys(felder).map((k) => [k, hole(wert, k)])) + if (!pflicht && Object.values(roh).every(leer)) return null + const r = {} + for (const [k, was] of Object.entries(felder)) r[k] = text(`${pfad}.${k}`, roh[k], { max: k === 'anschrift' ? 300 : 120, min: 1, name: `${was} bei ${bezeichnung}` }) + return r + } + // Fehlt der Hersteller ganz, gilt „selbst“ (Standard des Formulars); ein Objekt braucht Name, Anschrift und Kontakt + let hersteller = 'selbst' + const hRoh = hole(siRoh, 'hersteller') + if (hRoh !== undefined && hRoh !== null && hRoh !== 'selbst') { + if (istObjekt(hRoh)) hersteller = adresse('sicherheit.hersteller', hRoh, { pflicht: true, bezeichnung: 'den Hersteller' }) + else fehl('sicherheit.hersteller', 'Bitte gib den Hersteller mit Name, Anschrift und Kontakt an oder wähle „Ich bin der Hersteller“.') + } + const euVerantwortlicher = adresse('sicherheit.euVerantwortlicher', hole(siRoh, 'euVerantwortlicher'), { pflicht: false, bezeichnung: 'den EU-Verantwortlichen' }) + const hinweise = text('sicherheit.hinweise', hole(siRoh, 'hinweise'), { max: 2000, mehrzeilig: true, name: 'die Sicherheitshinweise' }) + + // Lebensmittel + const lmRoh = hole(eingabe, 'lebensmittel') + const lmAktivRoh = hole(lmRoh, 'aktiv') + if (lmAktivRoh !== undefined && lmAktivRoh !== null && typeof lmAktivRoh !== 'boolean') fehl('lebensmittel.aktiv', 'Bitte wähle bei „Lebensmittel“ ja oder nein.') + const lmAktiv = lmAktivRoh === true + const pflichtangaben = text('lebensmittel.pflichtangaben', hole(lmRoh, 'pflichtangaben'), { max: 4000, mehrzeilig: true, min: lmAktiv ? 1 : 0, name: 'die Pflichtangaben für Lebensmittel (Zutaten, Allergene, Füllmenge)' }) + + if (Object.keys(fehler).length > 0) return { fehler } + return { + produkt: { + name, + kategorie, + kurz, + beschreibung, + preisCent, + steuersatz, + grundpreis, + bestand, + aktiv, + varianten, + bilder, + lieferart, + widerruf, + sicherheit: { hersteller, euVerantwortlicher, hinweise }, + lebensmittel: { aktiv: lmAktiv, pflichtangaben }, + }, + } +} + +/** Gespeicherten JSON-Text sicher lesen; null bei kaputten Daten. */ +function parseProdukt(doc) { + try { + const p = JSON.parse(doc?.produkt) + return istObjekt(p) ? p : null + } catch { + return null + } +} + +/** Menge, die noch bestellbar ist: null = unbegrenzt. */ +function frei(bestand, reserviert, schluessel) { + if (!Number.isInteger(bestand)) return null + const r = reserviert instanceof Map ? Number(reserviert.get(schluessel)) || 0 : 0 + return Math.max(0, bestand - r) +} + +/** + * Sicht des Kaeufers auf einen Artikel (Anzeigefelder, fertige Texte). Liefert null bei unlesbarem Datensatz. + * Inaktive Artikel filtert der Aufrufer (doc.aktiv / produkt.aktiv). + * reserviert: Map offener Bestellungen. + */ +export function produktOeffentlich(produktDoc, { einstellungen, reserviert = new Map(), siteKey } = {}) { + void einstellungen + const p = parseProdukt(produktDoc) + if (!p) return null + const id = produktDoc.$id + const gp = p.grundpreis && typeof p.grundpreis === 'object' ? p.grundpreis : null + const grundpreisFuer = (preisCent) => (gp && Number.isInteger(preisCent) ? grundpreisText({ preisCent, menge: gp.menge, einheit: gp.einheit }) : '') + + let preisText = '' + let preisAbText + let gpText = '' + let variantenOeffentlich = null + let verfuegbar + + const v = p.varianten && typeof p.varianten === 'object' && Array.isArray(p.varianten.werte) && p.varianten.werte.length > 0 ? p.varianten : null + if (v) { + const werte = v.werte.map((w) => { + const rest = frei(w.bestand, reserviert, `${id}:${w.id}`) + return { + id: w.id, + name: w.name, + preisText: Number.isInteger(w.preisCent) ? euro(w.preisCent) : '', + grundpreisText: grundpreisFuer(w.preisCent), + verfuegbar: rest === null || rest > 0, + } + }) + const preise = v.werte.map((w) => w.preisCent).filter(Number.isInteger) + const min = preise.length ? Math.min(...preise) : null + const max = preise.length ? Math.max(...preise) : null + if (min !== null) { + preisText = euro(min) + if (min !== max) preisAbText = `ab ${euro(min)}` + gpText = min === max ? grundpreisFuer(min) : '' + } + variantenOeffentlich = { name: v.name, werte } + verfuegbar = werte.some((w) => w.verfuegbar) + } else { + if (Number.isInteger(p.preisCent)) { + preisText = euro(p.preisCent) + gpText = grundpreisFuer(p.preisCent) + } + const rest = frei(p.bestand, reserviert, id) + verfuegbar = rest === null || rest > 0 + } + + const si = istObjekt(p.sicherheit) ? p.sicherheit : {} + const adr = (a) => (istObjekt(a) ? { name: a.name ?? '', anschrift: a.anschrift ?? '', kontakt: a.kontakt ?? '' } : null) + const key = encodeURIComponent(String(siteKey ?? '')) + const bilder = (Array.isArray(p.bilder) ? p.bilder : []) + .filter((b) => typeof b === 'string' && BILD_ID_RE.test(b)) + .map((b) => ({ klein: `/api/public/shop/v1/${key}/bild/${b}-s.webp`, gross: `/api/public/shop/v1/${key}/bild/${b}-l.webp` })) + const lm = istObjekt(p.lebensmittel) && p.lebensmittel.aktiv === true ? { pflichtangaben: p.lebensmittel.pflichtangaben ?? '' } : null + + const ergebnis = { + id, + name: p.name ?? '', + kategorie: p.kategorie ?? '', + kurz: p.kurz ?? '', + beschreibung: p.beschreibung ?? '', + preisText, + grundpreisText: gpText, + steuersatz: p.steuersatz ?? 0, + varianten: variantenOeffentlich, + bilder, + lieferart: p.lieferart === 'abholung' ? 'abholung' : 'beides', + lieferHinweis: p.lieferart === 'abholung' ? 'Nur Abholung' : '', + widerruf: p.widerruf ?? 'gesetzlich', + widerrufText: WIDERRUF_TEXTE[p.widerruf] ?? '', + sicherheit: { + hersteller: si.hersteller === 'selbst' ? null : adr(si.hersteller), + euVerantwortlicher: adr(si.euVerantwortlicher), + hinweise: si.hinweise ?? '', + }, + lebensmittel: lm, + verfuegbar, + ausverkauft: !verfuegbar, + } + if (preisAbText) ergebnis.preisAbText = preisAbText + return ergebnis +} + +// --------------------------------------------------------------------------------------------------------------------- +// Speichern (shopProducts) + +const fehlerMit = (status, message) => Object.assign(new Error(message), { status }) +const sperre = (shopId) => `shop-produkte:${shopId}` +const col = () => config.collections.shopProducts +const reihenfolge = (a, b) => (a.sort ?? 0) - (b.sort ?? 0) || String(a.$createdAt ?? '').localeCompare(String(b.$createdAt ?? '')) || String(a.$id).localeCompare(String(b.$id)) + +/** Alle Artikel-Dokumente eines Shops, sortiert nach sort, dann Anlegezeit. */ +export async function listeProdukte(shopId) { + const docs = await listAllDocuments(col(), [Query.equal('shopId', shopId)]) + return docs.sort(reihenfolge) +} + +/** Ein Artikel-Dokument oder null (nicht vorhanden oder gehoert zu einem anderen Shop). */ +export async function holeProdukt(shopId, id) { + if (typeof id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id)) return null + let doc + try { + doc = await getDocument(col(), id) + } catch (error) { + if (error?.status === 404) return null + throw error + } + return doc && doc.shopId === shopId ? doc : null +} + +/** + * Legt einen Artikel an (id = null, ans Ende sortiert, hoechstens config.shop.maxArtikel) oder aktualisiert ihn (Sortierung bleibt). + * produkt ist das Ergebnis von normalisiereProdukt. Fehler tragen .status (400/404/409) und einen deutschen Text. + */ +export async function speichereProdukt({ shopId, customerId }, id, produkt) { + if (!istObjekt(produkt) || typeof produkt.name !== 'string' || produkt.name === '') throw fehlerMit(400, 'Der Artikel ist unvollständig.') + const json = JSON.stringify(produkt) + if (json.length > MAX_JSON) throw fehlerMit(400, 'Der Artikel ist zu umfangreich. Bitte kürze die Beschreibung oder lege weniger Varianten an.') + const daten = { name: produkt.name, aktiv: produkt.aktiv !== false, produkt: json } + return withLocks([sperre(shopId)], async () => { + if (id) { + const vorhanden = await holeProdukt(shopId, id) + if (!vorhanden) throw fehlerMit(404, 'Diesen Artikel gibt es nicht (mehr).') + return updateDocument(col(), id, daten) + } + const alle = await listeProdukte(shopId) + const max = config.shop.maxArtikel + if (alle.length >= max) throw fehlerMit(409, `Du hast die Höchstzahl von ${max} Artikeln erreicht. Lösche einen Artikel, bevor du einen neuen anlegst.`) + const sort = alle.reduce((m, d) => Math.max(m, Number.isInteger(d.sort) ? d.sort : -1), -1) + 1 + return createDocument(col(), { shopId, customerId, sort, ...daten }) + }) +} + +/** Loescht einen Artikel; 404, wenn er nicht (mehr) zu diesem Shop gehoert. */ +export async function loescheProdukt(shopId, id) { + return withLocks([sperre(shopId)], async () => { + const vorhanden = await holeProdukt(shopId, id) + if (!vorhanden) throw fehlerMit(404, 'Diesen Artikel gibt es nicht (mehr).') + await deleteDocument(col(), id) + return true + }) +} + +/** Setzt die Reihenfolge: ids muessen genau die Artikel des Shops enthalten (jeder einmal), sonst 400. Schreibt sort 0..n-1. */ +export async function sortiere(shopId, ids) { + if (!Array.isArray(ids) || ids.some((i) => typeof i !== 'string')) throw fehlerMit(400, 'Die Reihenfolge ist ungültig.') + return withLocks([sperre(shopId)], async () => { + const alle = await listeProdukte(shopId) + const vorhanden = new Set(alle.map((d) => d.$id)) + if (new Set(ids).size !== ids.length || ids.length !== vorhanden.size || ids.some((i) => !vorhanden.has(i))) { + throw fehlerMit(400, 'Die Reihenfolge passt nicht zu deinen Artikeln. Bitte lade die Seite neu und versuche es noch einmal.') + } + const nachId = new Map(alle.map((d) => [d.$id, d])) + const neu = [] + for (const [index, docId] of ids.entries()) { + const doc = nachId.get(docId) + neu.push(doc.sort === index ? doc : await updateDocument(col(), docId, { sort: index })) + } + return neu + }) +} + +/** Alle Bild-IDs, die Artikel des Shops verwenden (Set), damit Aufraeumjobs keine benutzten Bilder loeschen. */ +export async function bildIdsInGebrauch(shopId) { + const ids = new Set() + for (const doc of await listeProdukte(shopId)) { + const p = parseProdukt(doc) + if (Array.isArray(p?.bilder)) for (const b of p.bilder) if (typeof b === 'string') ids.add(b) + } + return ids +} diff --git a/test/shopProdukte.test.js b/test/shopProdukte.test.js new file mode 100644 index 0000000..9671070 --- /dev/null +++ b/test/shopProdukte.test.js @@ -0,0 +1,398 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Artikel des Shops: reines Normalisieren, Kaeufer-Sicht und Speichern gegen den Appwrite-Nachbau. +const aw = await startAppwriteMock({ schema: { shopProducts: schemaKeys('shopProducts') }, datetimes: SHOP_DATETIMES }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const P = await import('../server/services/shop/produkte.js') +const { normalisiereProdukt, produktOeffentlich, listeProdukte, holeProdukt, speichereProdukt, loescheProdukt, sortiere, bildIdsInGebrauch } = P + +const klein = { kleinunternehmer: true } +const regel = { kleinunternehmer: false } +const basis = (over = {}) => ({ name: 'Lavendelseife', preisCent: 450, ...over }) +const ok = (eingabe, einstellungen = regel) => { + const r = normalisiereProdukt(eingabe, { einstellungen }) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return r.produkt +} +const fehler = (eingabe, einstellungen = regel) => { + const r = normalisiereProdukt(eingabe, { einstellungen }) + assert.equal(r.produkt, undefined) + return r.fehler +} + +test('Minimaler Artikel bekommt die Standardwerte', () => { + const p = ok(basis()) + assert.deepEqual(p, { + name: 'Lavendelseife', + kategorie: '', + kurz: '', + beschreibung: '', + preisCent: 450, + steuersatz: 19, + grundpreis: null, + bestand: null, + aktiv: true, + varianten: null, + bilder: [], + lieferart: 'beides', + widerruf: 'gesetzlich', + sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' }, + lebensmittel: { aktiv: false, pflichtangaben: '' }, + }) +}) + +test('Name, Preis und Laengen werden geprueft', () => { + assert.ok(fehler({ preisCent: 100 }).name) + assert.ok(fehler(basis({ name: ' ' })).name) + assert.ok(fehler(basis({ name: 'x'.repeat(121) })).name) + assert.ok(fehler(basis({ kategorie: 'x'.repeat(41) })).kategorie) + assert.ok(fehler(basis({ kurz: 'x'.repeat(301) })).kurz) + assert.ok(fehler(basis({ beschreibung: 'x'.repeat(5001) })).beschreibung) + for (const preis of [0, -5, 10_000_001, 4.5, '4,50', null, undefined]) assert.ok(fehler(basis({ preisCent: preis })).preisCent, String(preis)) + assert.equal(ok(basis({ preisCent: 10_000_000 })).preisCent, 10_000_000) + assert.ok(fehler('kaputt').produkt) + assert.ok(fehler(null).produkt) + assert.ok(fehler([]).produkt) +}) + +test('Texte: getrimmt, Steuerzeichen entfernt, Umbrueche nur in Langtexten', () => { + const p = ok(basis({ name: ' Seife\u0000​ mit\nUmbruch ', kurz: 'a\nb', beschreibung: 'Zeile 1\r\nZeile\u0007 2', sicherheit: { hinweise: 'Nicht\nessen\u0000' } })) + assert.equal(p.name, 'Seife mit Umbruch') + assert.equal(p.kurz, 'a b') + assert.equal(p.beschreibung, 'Zeile 1\nZeile 2') + assert.equal(p.sicherheit.hinweise, 'Nicht\nessen') +}) + +test('Steuersatz: 0, 7, 19; Kleinunternehmer erzwingt 0', () => { + assert.equal(ok(basis({ steuersatz: 7 })).steuersatz, 7) + assert.equal(ok(basis({ steuersatz: 0 })).steuersatz, 0) + assert.equal(ok(basis({ steuersatz: 19 })).steuersatz, 19) + assert.ok(fehler(basis({ steuersatz: 16 })).steuersatz) + assert.ok(fehler(basis({ steuersatz: 'viel' })).steuersatz) + assert.equal(ok(basis({ steuersatz: 19 }), klein).steuersatz, 0) + assert.equal(ok(basis({ steuersatz: 7 }), klein).steuersatz, 0) + assert.equal(ok(basis(), klein).steuersatz, 0) +}) + +test('Bestand: ganze Zahl ab 0 oder unbegrenzt', () => { + assert.equal(ok(basis({ bestand: 0 })).bestand, 0) + assert.equal(ok(basis({ bestand: 12 })).bestand, 12) + assert.equal(ok(basis({ bestand: '7' })).bestand, 7) + assert.equal(ok(basis({ bestand: null })).bestand, null) + assert.equal(ok(basis({ bestand: '' })).bestand, null) + for (const b of [-1, 1.5, 'viele']) assert.ok(fehler(basis({ bestand: b })).bestand, String(b)) +}) + +test('Grundpreis: Menge und Einheit', () => { + assert.deepEqual(ok(basis({ grundpreis: { menge: 100, einheit: 'g' } })).grundpreis, { menge: 100, einheit: 'g' }) + assert.deepEqual(ok(basis({ grundpreis: { menge: '0,75', einheit: 'l' } })).grundpreis, { menge: 0.75, einheit: 'l' }) + assert.equal(ok(basis({ grundpreis: null })).grundpreis, null) + assert.equal(ok(basis({ grundpreis: { menge: '', einheit: '' } })).grundpreis, null) + assert.ok(fehler(basis({ grundpreis: { menge: 0, einheit: 'g' } })).grundpreis?.menge ?? fehler(basis({ grundpreis: { menge: 0, einheit: 'g' } }))['grundpreis.menge']) + assert.ok(fehler(basis({ grundpreis: { menge: 100, einheit: 'stueck' } }))['grundpreis.einheit']) + assert.ok(fehler(basis({ grundpreis: { menge: 'viel', einheit: 'g' } }))['grundpreis.menge']) + assert.ok(fehler(basis({ grundpreis: 'x' })).grundpreis) +}) + +test('aktiv, Lieferart, Widerruf', () => { + assert.equal(ok(basis({ aktiv: false })).aktiv, false) + assert.ok(fehler(basis({ aktiv: 'ja' })).aktiv) + assert.equal(ok(basis({ lieferart: 'abholung' })).lieferart, 'abholung') + assert.ok(fehler(basis({ lieferart: 'drohne' })).lieferart) + for (const w of ['gesetzlich', 'verderblich', 'individuell', 'hygiene']) assert.equal(ok(basis({ widerruf: w })).widerruf, w) + assert.ok(fehler(basis({ widerruf: 'nie' })).widerruf) +}) + +test('Varianten: Preis und Bestand nur an den Werten, IDs vom Server', () => { + const p = ok( + basis({ + preisCent: 999, + bestand: 5, + varianten: { + name: 'Duft', + werte: [ + { name: 'Lavendel', preisCent: 350, bestand: 3 }, + { id: 'abcd1234', name: 'Rose', preisCent: 450 }, + { id: 'ZU-KURZ', name: 'Minze', preisCent: 400, bestand: '0' }, + { id: 'abcd1234', name: 'Zitrone', preisCent: 400 }, + ], + }, + }), + ) + assert.equal(p.preisCent, null) + assert.equal(p.bestand, null) + assert.equal(p.varianten.name, 'Duft') + const ids = p.varianten.werte.map((w) => w.id) + assert.equal(ids[1], 'abcd1234') + for (const id of ids) assert.match(id, /^[a-z0-9]{8}$/) + assert.equal(new Set(ids).size, 4) + assert.deepEqual(p.varianten.werte.map((w) => w.bestand), [3, null, 0, null]) + assert.deepEqual(p.varianten.werte.map((w) => w.preisCent), [350, 450, 400, 400]) +}) + +test('Varianten: Fehler je Wert', () => { + const f = fehler(basis({ varianten: { name: '', werte: [{ name: 'A', preisCent: 0 }, { name: '', preisCent: 100 }, { name: 'a', preisCent: 100 }] } })) + assert.ok(f['varianten.name']) + assert.ok(f['varianten.werte.0.preisCent']) + assert.ok(f['varianten.werte.1.name']) + assert.ok(f['varianten.werte.2.name']) + assert.ok(fehler(basis({ varianten: { name: 'Größe', werte: [] } }))['varianten.werte']) + const zuViele = Array.from({ length: config.shop.maxVarianten + 1 }, (_, i) => ({ name: `W${i}`, preisCent: 100 })) + assert.ok(fehler(basis({ varianten: { name: 'Größe', werte: zuViele } }))['varianten.werte']) + const genau = zuViele.slice(0, config.shop.maxVarianten) + assert.equal(ok(basis({ varianten: { name: 'Größe', werte: genau } })).varianten.werte.length, config.shop.maxVarianten) + // leere Variante = keine Varianten, dann zaehlt der Artikelpreis + assert.equal(ok(basis({ varianten: { name: '', werte: [] } })).varianten, null) + assert.equal(ok(basis({ varianten: null })).varianten, null) +}) + +test('Bilder: Bild-IDs, hoechstens maxBilder, eindeutig', () => { + assert.deepEqual(ok(basis({ bilder: ['abc-123', 'def'] })).bilder, ['abc-123', 'def']) + assert.ok(fehler(basis({ bilder: ['a', 'b', 'c', 'd', 'e'] })).bilder) + assert.equal(ok(basis({ bilder: ['a', 'b', 'c', 'd'] })).bilder.length, config.shop.maxBilder) + assert.ok(fehler(basis({ bilder: ['a', 'a'] })).bilder) + assert.ok(fehler(basis({ bilder: ['../x'] })).bilder) + assert.ok(fehler(basis({ bilder: ['A'] })).bilder) + assert.ok(fehler(basis({ bilder: [''] })).bilder) + assert.ok(fehler(basis({ bilder: [1] })).bilder) + assert.ok(fehler(basis({ bilder: 'abc' })).bilder) +}) + +test('Lebensmittel braucht Pflichtangaben', () => { + const f = fehler(basis({ lebensmittel: { aktiv: true, pflichtangaben: ' ' } })) + assert.ok(f['lebensmittel.pflichtangaben']) + assert.deepEqual(ok(basis({ lebensmittel: { aktiv: true, pflichtangaben: 'Zutaten: Mehl. Allergene: Gluten.' } })).lebensmittel, { aktiv: true, pflichtangaben: 'Zutaten: Mehl. Allergene: Gluten.' }) + assert.ok(fehler(basis({ lebensmittel: { aktiv: true, pflichtangaben: 'x'.repeat(4001) } }))['lebensmittel.pflichtangaben']) + assert.equal(ok(basis({ lebensmittel: { aktiv: false } })).lebensmittel.aktiv, false) +}) + +test('Produktsicherheit: Hersteller selbst oder vollstaendig, EU-Verantwortlicher optional aber vollstaendig', () => { + const herst = { name: 'Seifen GmbH', anschrift: 'Weg 1, 12345 Ort', kontakt: 'info@seifen.example' } + assert.equal(ok(basis({ sicherheit: { hersteller: 'selbst' } })).sicherheit.hersteller, 'selbst') + assert.deepEqual(ok(basis({ sicherheit: { hersteller: herst } })).sicherheit.hersteller, herst) + const f = fehler(basis({ sicherheit: { hersteller: { name: 'X' } } })) + assert.ok(f['sicherheit.hersteller.anschrift']) + assert.ok(f['sicherheit.hersteller.kontakt']) + assert.ok(fehler(basis({ sicherheit: { hersteller: 'jemand' } }))['sicherheit.hersteller']) + assert.ok(fehler(basis({ sicherheit: { hersteller: 42 } }))['sicherheit.hersteller']) + assert.deepEqual(ok(basis({ sicherheit: { hersteller: herst, euVerantwortlicher: herst } })).sicherheit.euVerantwortlicher, herst) + assert.equal(ok(basis({ sicherheit: { hersteller: herst, euVerantwortlicher: { name: '', anschrift: '', kontakt: '' } } })).sicherheit.euVerantwortlicher, null) + assert.ok(fehler(basis({ sicherheit: { hersteller: herst, euVerantwortlicher: { name: 'EU' } } }))['sicherheit.euVerantwortlicher.anschrift']) + assert.ok(fehler(basis({ sicherheit: { hinweise: 'x'.repeat(2001) } }))['sicherheit.hinweise']) +}) + +test('Normalisieren teilt keine Referenzen und ignoriert unbekannte Felder und __proto__', () => { + const eingabe = JSON.parse('{"name":"A","preisCent":100,"bilder":["a"],"__proto__":{"preisCent":1},"boese":1}') + const p = ok(eingabe) + p.bilder.push('x') + assert.deepEqual(eingabe.bilder, ['a']) + assert.equal(p.boese, undefined) + assert.equal(p.preisCent, 100) +}) + +// ---- Kaeufer-Sicht ------------------------------------------------------------------------------------------------- + +const doc = (produkt, id = 'p1') => ({ $id: id, produkt: JSON.stringify(produkt) }) +const oeff = (eingabe, extra = {}, einstellungen = regel) => { + const d = doc(ok(eingabe, einstellungen), extra.id) + return produktOeffentlich(d, { einstellungen, siteKey: 'abc123', ...extra }) +} + +test('produktOeffentlich: Einzelartikel', () => { + const o = oeff(basis({ kurz: 'Handgemacht', grundpreis: { menge: 100, einheit: 'g' }, bilder: ['bild-1', 'bild2'], steuersatz: 7, bestand: 3, kategorie: 'Seife' })) + assert.equal(o.id, 'p1') + assert.equal(o.name, 'Lavendelseife') + assert.equal(o.kategorie, 'Seife') + assert.equal(o.kurz, 'Handgemacht') + assert.equal(o.preisText, '4,50\u00a0€') + assert.equal(o.preisAbText, undefined) + assert.equal(o.grundpreisText, '(45,00\u00a0€ / 1 kg)') + assert.equal(o.steuersatz, 7) + assert.equal(o.varianten, null) + assert.deepEqual(o.bilder, [ + { klein: '/api/public/shop/v1/abc123/bild/bild-1-s.webp', gross: '/api/public/shop/v1/abc123/bild/bild-1-l.webp' }, + { klein: '/api/public/shop/v1/abc123/bild/bild2-s.webp', gross: '/api/public/shop/v1/abc123/bild/bild2-l.webp' }, + ]) + assert.equal(o.lieferart, 'beides') + assert.equal(o.lieferHinweis, '') + assert.equal(o.widerruf, 'gesetzlich') + assert.equal(o.widerrufText, '') + assert.deepEqual(o.sicherheit, { hersteller: null, euVerantwortlicher: null, hinweise: '' }) + assert.equal(o.lebensmittel, null) + assert.equal(o.verfuegbar, true) + assert.equal(o.ausverkauft, false) + assert.equal('bestand' in o, false) + assert.equal('bestandText' in o, false) + assert.equal('preisCent' in o, false) +}) + +test('produktOeffentlich: Lieferart, Widerrufstexte, Hersteller, Lebensmittel', () => { + assert.equal(oeff(basis({ lieferart: 'abholung' })).lieferHinweis, 'Nur Abholung') + assert.equal(oeff(basis({ widerruf: 'verderblich' })).widerrufText, 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware') + assert.match(oeff(basis({ widerruf: 'individuell' })).widerrufText, /nach Kundenwunsch angefertigt/) + assert.match(oeff(basis({ widerruf: 'hygiene' })).widerrufText, /versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde/) + const herst = { name: 'Seifen GmbH', anschrift: 'Weg 1', kontakt: 'a@b.de' } + const o = oeff(basis({ sicherheit: { hersteller: herst, hinweise: 'Nicht essen' }, lebensmittel: { aktiv: true, pflichtangaben: 'Zutaten: Salz' } })) + assert.deepEqual(o.sicherheit.hersteller, herst) + assert.equal(o.sicherheit.hinweise, 'Nicht essen') + assert.deepEqual(o.lebensmittel, { pflichtangaben: 'Zutaten: Salz' }) +}) + +test('produktOeffentlich: Verfuegbarkeit mit Bestand und Reservierung', () => { + const eingabe = basis({ bestand: 2 }) + assert.equal(oeff(eingabe).verfuegbar, true) + assert.equal(oeff(eingabe, { reserviert: new Map([['p1', 1]]) }).verfuegbar, true) + const aus = oeff(eingabe, { reserviert: new Map([['p1', 2]]) }) + assert.equal(aus.verfuegbar, false) + assert.equal(aus.ausverkauft, true) + assert.equal(oeff(basis({ bestand: 0 })).ausverkauft, true) + assert.equal(oeff(basis({ bestand: null }), { reserviert: new Map([['p1', 99]]) }).verfuegbar, true) + assert.equal(oeff(eingabe, { reserviert: new Map([['p1', 5]]) }).verfuegbar, false) + assert.equal(oeff(eingabe, { reserviert: undefined }).verfuegbar, true) +}) + +test('produktOeffentlich: Varianten mit "ab"-Preis und Verfuegbarkeit je Wert', () => { + const eingabe = basis({ + varianten: { + name: 'Duft', + werte: [ + { id: 'aaaaaaaa', name: 'Lavendel', preisCent: 350, bestand: 1 }, + { id: 'bbbbbbbb', name: 'Rose', preisCent: 450 }, + ], + }, + }) + const o = oeff(eingabe, { reserviert: new Map([['p1:aaaaaaaa', 1], ['p1', 50]]) }) + assert.equal(o.preisText, '3,50\u00a0€') + assert.equal(o.preisAbText, 'ab 3,50\u00a0€') + assert.equal(o.varianten.name, 'Duft') + assert.deepEqual(o.varianten.werte.map((w) => [w.id, w.name, w.preisText, w.verfuegbar]), [ + ['aaaaaaaa', 'Lavendel', '3,50\u00a0€', false], + ['bbbbbbbb', 'Rose', '4,50\u00a0€', true], + ]) + assert.equal(o.verfuegbar, true) + const alle = oeff(eingabe, { reserviert: new Map([['p1:aaaaaaaa', 1]]) }) + assert.equal(alle.verfuegbar, true) + const keine = oeff(basis({ varianten: { name: 'Duft', werte: [{ id: 'aaaaaaaa', name: 'L', preisCent: 350, bestand: 0 }] } })) + assert.equal(keine.ausverkauft, true) + // gleiche Preise: kein "ab" + const gleich = oeff(basis({ varianten: { name: 'Farbe', werte: [{ name: 'Rot', preisCent: 500 }, { name: 'Blau', preisCent: 500 }] }, grundpreis: { menge: 50, einheit: 'g' } })) + assert.equal(gleich.preisText, '5,00\u00a0€') + assert.equal(gleich.preisAbText, undefined) + assert.equal(gleich.grundpreisText, '(100,00\u00a0€ / 1 kg)') +}) + +test('produktOeffentlich: kaputter Datensatz liefert null, Fremdtext bleibt Text', () => { + assert.equal(produktOeffentlich({ $id: 'x', produkt: '{kaputt' }, { siteKey: 's' }), null) + assert.equal(produktOeffentlich({ $id: 'x', produkt: '"text"' }, { siteKey: 's' }), null) + assert.equal(produktOeffentlich({ $id: 'x' }, { siteKey: 's' }), null) + const o = oeff(basis({ name: '' })) + assert.equal(o.name, '') +}) + +// ---- Speichern ----------------------------------------------------------------------------------------------------- + +const SHOP = { shopId: 'shop-a', customerId: 'cust-a' } +const fremd = { shopId: 'shop-b', customerId: 'cust-b' } + +test('speichern: anlegen sortiert ans Ende, Update behaelt sort, JSON in produkt', async () => { + const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'A' }))) + const b = await speichereProdukt(SHOP, null, ok(basis({ name: 'B' }))) + const c = await speichereProdukt(SHOP, null, ok(basis({ name: 'C', aktiv: false }))) + assert.deepEqual([a.sort, b.sort, c.sort], [0, 1, 2]) + assert.equal(a.shopId, 'shop-a') + assert.equal(a.customerId, 'cust-a') + assert.equal(c.aktiv, false) + assert.equal(JSON.parse(a.produkt).name, 'A') + const neu = await speichereProdukt(SHOP, b.$id, ok(basis({ name: 'B2', preisCent: 999 }))) + assert.equal(neu.sort, 1) + assert.equal(neu.name, 'B2') + assert.equal(JSON.parse(neu.produkt).preisCent, 999) + const liste = await listeProdukte('shop-a') + assert.deepEqual(liste.map((d) => d.name), ['A', 'B2', 'C']) +}) + +test('Shops sind getrennt: Liste, Holen, Aendern, Loeschen, Sortieren', async () => { + const x = await speichereProdukt(fremd, null, ok(basis({ name: 'Fremd' }))) + assert.equal(x.sort, 0) + assert.deepEqual((await listeProdukte('shop-b')).map((d) => d.name), ['Fremd']) + assert.equal(await holeProdukt('shop-a', x.$id), null) + assert.equal((await holeProdukt('shop-b', x.$id)).name, 'Fremd') + assert.equal(await holeProdukt('shop-a', 'gibt-es-nicht'), null) + assert.equal(await holeProdukt('shop-a', '../etc'), null) + await assert.rejects(speichereProdukt(SHOP, x.$id, ok(basis())), (e) => e.status === 404) + await assert.rejects(loescheProdukt('shop-a', x.$id), (e) => e.status === 404) + await assert.rejects(sortiere('shop-a', [x.$id]), (e) => e.status === 400) + assert.equal((await holeProdukt('shop-b', x.$id)).name, 'Fremd') +}) + +test('speichern: unvollstaendiger oder zu grosser Artikel => 400', async () => { + await assert.rejects(speichereProdukt(SHOP, null, {}), (e) => e.status === 400 && /unvollständig/.test(e.message)) + await assert.rejects(speichereProdukt(SHOP, null, null), (e) => e.status === 400) + const riesig = ok(basis({ sicherheit: { hinweise: 'ä'.repeat(2000) }, beschreibung: 'ä'.repeat(5000), lebensmittel: { aktiv: true, pflichtangaben: 'ä'.repeat(4000) }, varianten: { name: 'V', werte: Array.from({ length: 30 }, (_, i) => ({ name: `Wert-${i}-${'x'.repeat(30)}`, preisCent: 100 })) } })) + riesig.kurz = 'ü'.repeat(20000) + await assert.rejects(speichereProdukt(SHOP, null, riesig), (e) => e.status === 400) +}) + +test('sortieren: genau die Artikel des Shops, sonst 400', async () => { + const liste = await listeProdukte('shop-a') + const ids = liste.map((d) => d.$id) + const neu = [ids[2], ids[0], ids[1]] + const r = await sortiere('shop-a', neu) + assert.deepEqual(r.map((d) => [d.$id, d.sort]), neu.map((id, i) => [id, i])) + assert.deepEqual((await listeProdukte('shop-a')).map((d) => d.$id), neu) + await assert.rejects(sortiere('shop-a', ids.slice(0, 2)), (e) => e.status === 400) + await assert.rejects(sortiere('shop-a', [...ids, 'noch-einer']), (e) => e.status === 400) + await assert.rejects(sortiere('shop-a', [ids[0], ids[0], ids[1]]), (e) => e.status === 400) + await assert.rejects(sortiere('shop-a', 'abc'), (e) => e.status === 400) + await assert.rejects(sortiere('shop-a', [1, 2, 3]), (e) => e.status === 400) + assert.deepEqual((await listeProdukte('shop-a')).map((d) => d.$id), neu) +}) + +test('loeschen und neu anlegen: sort = max + 1', async () => { + const liste = await listeProdukte('shop-a') + assert.equal(await loescheProdukt('shop-a', liste[0].$id), true) + assert.equal(await holeProdukt('shop-a', liste[0].$id), null) + await assert.rejects(loescheProdukt('shop-a', liste[0].$id), (e) => e.status === 404) + const neu = await speichereProdukt(SHOP, null, ok(basis({ name: 'Spaeter' }))) + assert.equal(neu.sort, 3) +}) + +test('Bild-IDs in Gebrauch', async () => { + await speichereProdukt(SHOP, null, ok(basis({ name: 'Mit Bildern', bilder: ['bild-x', 'bild-y'] }))) + await speichereProdukt(fremd, null, ok(basis({ name: 'Fremd2', bilder: ['bild-fremd'] }))) + const ids = await bildIdsInGebrauch('shop-a') + assert.ok(ids instanceof Set) + assert.deepEqual([...ids].sort(), ['bild-x', 'bild-y']) + assert.equal((await bildIdsInGebrauch('shop-leer')).size, 0) +}) + +test('Obergrenze maxArtikel => 409, Update bleibt moeglich', async () => { + const alt = config.shop.maxArtikel + const vorhanden = (await listeProdukte('shop-a')).length + config.shop.maxArtikel = vorhanden + try { + await assert.rejects(speichereProdukt(SHOP, null, ok(basis({ name: 'Zu viel' }))), (e) => e.status === 409 && /Höchstzahl/.test(e.message)) + const erster = (await listeProdukte('shop-a'))[0] + await speichereProdukt(SHOP, erster.$id, ok(basis({ name: 'Noch erlaubt' }))) + // anderer Shop hat eigenes Limit + config.shop.maxArtikel = (await listeProdukte('shop-b')).length + 1 + await speichereProdukt(fremd, null, ok(basis({ name: 'Passt gerade' }))) + await assert.rejects(speichereProdukt(fremd, null, ok(basis({ name: 'Zu viel' }))), (e) => e.status === 409) + } finally { + config.shop.maxArtikel = alt + } +}) + +test('parallele Anlage vergibt verschiedene sort-Werte', async () => { + const shop = { shopId: 'shop-par', customerId: 'c' } + const docs = await Promise.all(Array.from({ length: 5 }, (_, i) => speichereProdukt(shop, null, ok(basis({ name: `P${i}` }))))) + assert.deepEqual(docs.map((d) => d.sort).sort(), [0, 1, 2, 3, 4]) +}) From 0063c04d8ab9e54d9d82bdffbfcbebcc11bfc9d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:40:23 +0000 Subject: [PATCH 041/105] Shop C5: Muster-Rechtstexte (Widerrufsbelehrung, Formular, AGB), Versand/Zahlung, Gewaehrleistungshinweis, Normalisierung und Hash Widerrufsbelehrung nach Anlage 1 (Stand 2026, mit Hinweis auf die Widerrufsfunktion), Formular nach Anlage 2, Muster-AGB ohne OS-Plattform, erzeugter Versand-/Zahlungstext, Gewaehrleistungshinweis (Wortlaut zu pruefen), Muster-Markdown fuer die Kanzlei und Tests inkl. Abgleich Markdown/Code. Co-Authored-By: Claude Opus 5.5 --- docs/funktionen/shop-muster-rechtstexte.md | 150 ++++++++++ server/services/shop/rechtstexte.js | 312 +++++++++++++++++++++ test/shopRechtstexte.test.js | 311 ++++++++++++++++++++ 3 files changed, 773 insertions(+) create mode 100644 docs/funktionen/shop-muster-rechtstexte.md create mode 100644 server/services/shop/rechtstexte.js create mode 100644 test/shopRechtstexte.test.js diff --git a/docs/funktionen/shop-muster-rechtstexte.md b/docs/funktionen/shop-muster-rechtstexte.md new file mode 100644 index 0000000..01d53d3 --- /dev/null +++ b/docs/funktionen/shop-muster-rechtstexte.md @@ -0,0 +1,150 @@ +# Shop: Muster-Rechtstexte (Widerrufsbelehrung, Widerrufsformular, AGB) + +**Muster – keine Rechtsberatung.** Diese Texte sind Muster, die das Kundenportal im Webshop vorbefüllt (`server/services/shop/rechtstexte.js`, dort mit den Angaben aus den Shop-Einstellungen). Der Händler prüft sie, passt sie an und bestätigt sie selbst; WEBklar berät nicht rechtlich. Diese Datei enthält dieselben Texte mit Platzhaltern in eckigen Klammern zur Prüfung durch eine Kanzlei (Plan 2026-10-05, Task C5; Offene Punkte 5 und 9 der Recherche). + +Stand: 2026-10-05. + +## Quellen und Fundstellen + +- Widerrufsbelehrung: Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB, Fassung 2026 mit dem Baustein zur Widerrufsfunktion (https://www.gesetze-im-internet.de/bgbeg/BJNR006049896.html). Ein richtig ausgefülltes Muster erfüllt die Informationspflicht (Art. 246a § 1 Abs. 2 S. 3 EGBGB). +- Elektronische Widerrufsfunktion: § 356a BGB (seit 19.06.2026), Hinweispflicht in der Belehrung nach Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB. +- Widerrufsformular: Anlage 2 zu Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB. +- Ausschluss und vorzeitiges Erlöschen: § 312g Abs. 2 Nr. 1, 2, 3 BGB; Information nach Art. 246a § 1 Abs. 3 EGBGB. +- Gewährleistungshinweis: Art. 246a § 1 Abs. 1 S. 1 Nr. 11 EGBGB i. V. m. Durchführungsverordnung (EU) 2025/1960 Anhang I (seit 27.09.2026). +- Streitbeilegung: § 36 VSBG. Die EU-Plattform zur Online-Streitbeilegung wurde am 20.07.2025 eingestellt; es gibt keinen Link darauf. +- Button und Vertragsschluss: § 312j Abs. 3 und 4 BGB, § 312i BGB. Recherche: `/root/reports/webshop-2026-10-05/recherche-recht-stripe.md` (B.1 bis B.10). + +## Hinweise zur Prüfung (nicht Teil der Texte) + +- Fristbeginn: Die Belehrung enthält beide gesetzlichen Alternativen (eine Ware bzw. die Waren; mehrere Waren einer Bestellung, getrennt geliefert: die letzte Ware), weil der Warenkorb beides erzeugen kann. Teilsendungen und regelmäßige Lieferung sind nicht vorgesehen. +- Rücksendekosten: Standard ist „Käufer trägt“. Variante „Händler trägt“ steht unten. +- Abholung: Bietet der Shop Abholung an, kommt der Satz „Haben Sie die Waren bei uns abgeholt …“ hinzu. +- Ausschlussgründe: Der letzte Absatz der Belehrung nennt § 312g Abs. 2 Nr. 1, 2 und 3 BGB in allgemeiner Form. Ob und für welche Waren er zutrifft, entscheidet der Händler; Einordnung von Lieferung und Abholung von Speisen (§ 312g Abs. 2 Nr. 9, § 312 Abs. 2 Nr. 8 BGB) ist offen (Recherche, Offene Punkte 6). +- Wortlaut des Gewährleistungshinweises: im Code als „Wortlaut prüfen (Kanzlei)“ markiert; der amtliche deutsche Wortlaut von Anhang I ist einzusetzen. +- Widerrufsfunktion: Erreichbarkeit ohne Login bei Gastbestellungen, Identifikation der Bestellung und Zeitstempel mit der Kanzlei abstimmen (Recherche, Offene Punkte 7). + +## Widerrufsbelehrung + +Widerrufsbelehrung + +Widerrufsrecht + +Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. + +Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat. + +Haben Sie mehrere Waren im Rahmen einer einheitlichen Bestellung bestellt und werden diese getrennt geliefert, beträgt die Widerrufsfrist vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Ware in Besitz genommen haben bzw. hat. + +Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ([Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. + +Sie können Ihr Widerrufsrecht auch online unter [Internetadresse der Widerrufsfunktion im Shop] ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. + +Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden. + +Folgen des Widerrufs + +Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. + +Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist. + +Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns ([Name des Händlers], [Straße Nr.], [PLZ] [Ort]) zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden. + +Sie tragen die unmittelbaren Kosten der Rücksendung der Waren. + +Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist. + +Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts + +Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB), z. B. Waren mit Wunschaufdruck oder Maßanfertigungen. +Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB), z. B. frische Lebensmittel. +Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB). + +Ende der Widerrufsbelehrung + +### Variante: Händler trägt die Rücksendekosten + +Statt „Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.“ steht: + +Wir tragen die Kosten der Rücksendung der Waren. + +### Variante: Abholung angeboten + +Nach dem Absatz zur Rücksendung folgt zusätzlich: + +Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: [Abholadresse]. + +## Widerrufsformular + +Muster-Widerrufsformular + +(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.) + +An [Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]: + +Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*) + +– Bestellt am (*)/erhalten am (*) +– Name des/der Verbraucher(s) +– Anschrift des/der Verbraucher(s) +– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier) +– Datum + +(*) Unzutreffendes streichen. + +## Allgemeine Geschäftsbedingungen + +Die Kopfzeile „Muster – bitte prüfen und anpassen“ steht im vorbefüllten Text und wird vom Händler nach der Prüfung entfernt. Die Zahlungsarten, Länder und Abholung werden aus den Shop-Einstellungen eingesetzt; die Platzhalter unten zeigen den Standardfall. + +Muster – bitte prüfen und anpassen + +Allgemeine Geschäftsbedingungen + +§ 1 Geltung und Anbieter + +Diese Allgemeinen Geschäftsbedingungen gelten für alle Bestellungen von Verbrauchern in unserem Online-Shop. Anbieter und Vertragspartner ist [Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]. + +§ 2 Vertragsschluss + +Die Darstellung der Waren im Shop ist kein verbindliches Angebot. Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab. Wir bestätigen den Eingang der Bestellung unverzüglich per E-Mail. Der Vertrag kommt zustande, sobald wir Ihnen nach erfolgreicher Zahlung die Bestellbestätigung per E-Mail senden. Bestellungen, die nicht bezahlt werden, werden nach Ablauf der Zahlungsfrist verworfen. + +§ 3 Preise und Versandkosten + +Es gelten die zum Zeitpunkt der Bestellung im Shop angegebenen Preise in Euro. Alle Preise enthalten die gesetzliche Umsatzsteuer. Versandkosten und etwaige weitere Kosten werden vor Abgabe der Bestellung gesondert ausgewiesen und richten sich nach dem Zielland. Einzelheiten finden Sie unter „Versand & Zahlung“. + +§ 4 Zahlung + +Die Zahlung erfolgt über den Zahlungsdienstleister Stripe. Es stehen folgende Zahlungsarten zur Verfügung: Kreditkarte, Apple Pay, Google Pay, Klarna, SEPA-Lastschrift. Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung. + +§ 5 Lieferung und Abholung + +Wir liefern in folgende Länder: Deutschland. Die Lieferzeit beträgt [Lieferzeit], gerechnet ab Zahlungseingang. Ist eine Ware nicht verfügbar, informieren wir Sie unverzüglich. + +§ 6 Eigentumsvorbehalt + +Die Waren bleiben bis zur vollständigen Bezahlung unser Eigentum. + +§ 7 Widerrufsrecht + +Verbrauchern steht ein gesetzliches Widerrufsrecht zu. Einzelheiten ergeben sich aus der Widerrufsbelehrung, die Sie unter „Widerrufsbelehrung“ im Shop finden. + +§ 8 Gewährleistung + +Es gelten die gesetzlichen Mängelhaftungsrechte. Der Hinweis auf das gesetzliche Gewährleistungsrecht steht vor Abgabe der Bestellung im Shop. + +§ 9 Vertragstext und Sprache + +Wir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar. Ihre Bestelldaten, diese Allgemeinen Geschäftsbedingungen und die Widerrufsbelehrung erhalten Sie mit der Bestellbestätigung per E-Mail; bitte bewahren Sie diese E-Mail auf. Die Vertragssprache ist Deutsch. + +§ 10 Streitbeilegung + +[Name des Händlers] ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG). + +Bei Kleinunternehmern steht in § 3 statt „Alle Preise enthalten die gesetzliche Umsatzsteuer.“ der Satz „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.“ + +## Hinweis auf das gesetzliche Gewährleistungsrecht (Kasse und Bestellbestätigung) + +Wortlaut prüfen (Kanzlei): sinngemäße Fassung, bis der amtliche Wortlaut von Anhang I eingesetzt ist. + +Gesetzliches Gewährleistungsrecht + +Für Waren besteht ein gesetzliches Gewährleistungsrecht von mindestens zwei Jahren ab Lieferung der Ware. Ist die Ware nicht vertragsgemäß, haben Sie zunächst Anspruch auf kostenlose Nachbesserung (Reparatur) oder Ersatzlieferung; unter den gesetzlichen Voraussetzungen können Sie stattdessen den Preis mindern oder vom Vertrag zurücktreten. Diese Rechte bestehen unabhängig von einer Garantie. diff --git a/server/services/shop/rechtstexte.js b/server/services/shop/rechtstexte.js new file mode 100644 index 0000000..54c276e --- /dev/null +++ b/server/services/shop/rechtstexte.js @@ -0,0 +1,312 @@ +import { createHash } from 'node:crypto' +import { euro } from './geld.js' +import { STANDARD_EINSTELLUNGEN } from './einstellungen.js' + +/** + * Muster-Rechtstexte des Haendlers (Plan 2026-10-05-webshop.md, Task C5; Spec 4.6). + * Alles rein: Einstellungen rein, Klartext raus (Absaetze mit '\n\n' getrennt), Sie-Form fuer Verbraucher. + * Das sind MUSTER, keine Rechtsberatung. Der Haendler prueft, passt an und bestaetigt selbst (BESTAETIGUNG_TEXT). + * Quellen: Anlage 1 und 2 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB (Stand 2026, mit Baustein zur Widerrufsfunktion, § 356a BGB), + * § 312g Abs. 2 BGB, § 36 VSBG, VO (EU) 2025/1960 Anhang I; siehe /root/reports/webshop-2026-10-05/recherche-recht-stripe.md + * (B.2, B.3, B.3a, B.8). Dieselben Texte mit Platzhaltern: docs/funktionen/shop-muster-rechtstexte.md. + */ + +export const BESTAETIGUNG_TEXT = + 'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.' + +export const MAX_AGB = 30000 +export const MAX_WIDERRUFSBELEHRUNG = 20000 +export const MAX_WIDERRUFSFORMULAR = 5000 +const MAX_URL = 500 + +const LANDNAMEN = { DE: 'Deutschland', AT: 'Österreich' } +const ZAHLUNGSARTEN_REIHENFOLGE = ['card', 'klarna', 'sepa_debit'] +const ZAHLUNGSARTEN_TEXT = { card: 'Kreditkarte, Apple Pay, Google Pay', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' } + +/** Einstellungen mit Standardwerten auffuellen (Unterobjekte eine Ebene tief), damit Teilobjekte nicht werfen. */ +function mitStandard(e) { + const std = STANDARD_EINSTELLUNGEN + const roh = e && typeof e === 'object' ? e : {} + const sub = (k) => ({ ...std[k], ...(roh[k] && typeof roh[k] === 'object' && !Array.isArray(roh[k]) ? roh[k] : {}) }) + return { ...std, ...roh, anbieter: sub('anbieter'), versand: sub('versand'), abholung: sub('abholung') } +} + +const text = (s) => (typeof s === 'string' ? s.trim() : '') + +function anschrift(a) { + const ort = [text(a.plz), text(a.ort)].filter(Boolean).join(' ') + const land = text(a.land) && a.land !== 'DE' ? (LANDNAMEN[a.land] ?? a.land) : '' + return [text(a.strasse), ort, land].filter(Boolean).join(', ') || '[Anschrift]' +} + +function anbieterZeile(a) { + return [text(a.name) || '[Name]', anschrift(a), text(a.telefon) ? `Telefon: ${text(a.telefon)}` : '', `E-Mail: ${text(a.email) || '[E-Mail-Adresse]'}`] + .filter(Boolean) + .join(', ') +} + +function laenderListe(e) { + const laender = Array.isArray(e.versand.laender) ? e.versand.laender : [] + return laender.map((l) => LANDNAMEN[l] ?? l).join(', ') +} + +function steuerSatz(e) { + return e.kleinunternehmer + ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.' + : 'Alle Preise enthalten die gesetzliche Umsatzsteuer.' +} + +/** 'Kreditkarte, Apple Pay, Google Pay, Klarna': aktive Zahlungsarten in fester Reihenfolge. Auch fuer die Widget-Konfiguration. */ +export function zahlungsartenText(e) { + const arten = Array.isArray(e?.zahlungsarten) ? e.zahlungsarten : [] + return ZAHLUNGSARTEN_REIHENFOLGE.filter((a) => arten.includes(a)) + .map((a) => ZAHLUNGSARTEN_TEXT[a]) + .join(', ') +} + +/** + * Widerrufsbelehrung fuer Waren: gesetzliches Muster (Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB, Stand 2026) mit Baustein + * zur Widerrufsfunktion (§ 356a BGB). + * Gestaltungshinweise, so gewaehlt: Fristbeginn = Alternative "Kaufvertrag" (Waren in Besitz genommen) UND Alternative "mehrere + * Waren einer einheitlichen Bestellung, getrennt geliefert" (letzte Ware), beide als eigene Saetze, weil der Warenkorb beides + * erzeugen kann. Teilsendungen/regelmaessige Lieferung entfallen (kommt im Shop nicht vor). Nicht abgeholt: "Wir holen die Waren ab" + * entfaellt, dafuer die Rueckgabe-Alternative (zurueckzusenden oder zu uebergeben) samt Rueckzahlungsverweigerungs-Satz. + * Rueckkosten nach e.ruecksendekosten ('kaeufer' = Standard, 'haendler'). + * @param {object} e Einstellungen + * @param {{ widerrufUrl?: string }} [opts] Adresse der Widerrufsfunktion im Shop + */ +export function musterWiderrufsbelehrung(e, { widerrufUrl = '' } = {}) { + e = mitStandard(e) + const a = e.anbieter + const kontakt = anbieterZeile(a) + const url = text(widerrufUrl) || '[Internetadresse der Widerrufsfunktion]' + const rueckadresse = `${text(a.name) || '[Name]'}, ${anschrift(a)}` + const kosten = e.ruecksendekosten === 'haendler' ? 'Wir tragen die Kosten der Rücksendung der Waren.' : 'Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.' + const abholung = e.abholung.aktiv + ? `Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: ${text(e.abholung.adresse) || rueckadresse}.` + : '' + return [ + 'Widerrufsbelehrung', + 'Widerrufsrecht', + 'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.', + 'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.', + 'Haben Sie mehrere Waren im Rahmen einer einheitlichen Bestellung bestellt und werden diese getrennt geliefert, beträgt die Widerrufsfrist vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Ware in Besitz genommen haben bzw. hat.', + `Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${kontakt}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.`, + `Sie können Ihr Widerrufsrecht auch online unter ${url} ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.`, + 'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.', + 'Folgen des Widerrufs', + 'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.', + 'Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist.', + `Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns (${rueckadresse}) zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden.`, + ...(abholung ? [abholung] : []), + kosten, + 'Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.', + 'Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts', + [ + 'Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB), z. B. Waren mit Wunschaufdruck oder Maßanfertigungen.', + 'Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB), z. B. frische Lebensmittel.', + 'Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB).', + ].join('\n'), + 'Ende der Widerrufsbelehrung', + ].join('\n\n') +} + +/** Muster-Widerrufsformular (Anlage 2 zu Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB), Unternehmer eingesetzt. */ +export function musterWiderrufsformular(e) { + e = mitStandard(e) + const a = e.anbieter + return [ + 'Muster-Widerrufsformular', + '(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)', + `An ${text(a.name) || '[Name]'}, ${anschrift(a)}, E-Mail: ${text(a.email) || '[E-Mail-Adresse]'}:`, + 'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)', + ['– Bestellt am (*)/erhalten am (*)', '– Name des/der Verbraucher(s)', '– Anschrift des/der Verbraucher(s)', '– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)', '– Datum'].join('\n'), + '(*) Unzutreffendes streichen.', + ].join('\n\n') +} + +/** Kurze Muster-AGB fuer kleine Shops. Kopfzeile kennzeichnet sie als Muster; der Haendler loescht sie nach der Pruefung. */ +export function musterAgb(e) { + e = mitStandard(e) + const a = e.anbieter + const name = text(a.name) || '[Name]' + const arten = Array.isArray(e.zahlungsarten) ? e.zahlungsarten : [] + const zahlung = zahlungsartenText(e) + const abs = [] + abs.push('Muster – bitte prüfen und anpassen') + abs.push('Allgemeine Geschäftsbedingungen') + abs.push( + `§ 1 Geltung und Anbieter\n\nDiese Allgemeinen Geschäftsbedingungen gelten für alle Bestellungen von Verbrauchern in unserem Online-Shop. Anbieter und Vertragspartner ist ${anbieterZeile(a)}${text(a.ustId) ? `, USt-IdNr.: ${text(a.ustId)}` : ''}.` + ) + abs.push( + '§ 2 Vertragsschluss\n\nDie Darstellung der Waren im Shop ist kein verbindliches Angebot. Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab. Wir bestätigen den Eingang der Bestellung unverzüglich per E-Mail. Der Vertrag kommt zustande, sobald wir Ihnen nach erfolgreicher Zahlung die Bestellbestätigung per E-Mail senden. Bestellungen, die nicht bezahlt werden, werden nach Ablauf der Zahlungsfrist verworfen.' + ) + abs.push( + `§ 3 Preise und Versandkosten\n\nEs gelten die zum Zeitpunkt der Bestellung im Shop angegebenen Preise in Euro. ${steuerSatz(e)} Versandkosten und etwaige weitere Kosten werden vor Abgabe der Bestellung gesondert ausgewiesen und richten sich nach dem Zielland. Einzelheiten finden Sie unter „Versand & Zahlung“.` + ) + let z = `§ 4 Zahlung\n\nDie Zahlung erfolgt über den Zahlungsdienstleister Stripe. Es stehen folgende Zahlungsarten zur Verfügung: ${zahlung || '[Zahlungsarten]'}.` + if (arten.includes('sepa_debit')) z += ' Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung.' + abs.push(z) + let l = '§ 5 Lieferung und Abholung\n\n' + const teile = [] + if (e.versand.aktiv) teile.push(`Wir liefern in folgende Länder: ${laenderListe(e) || '[Länder]'}.`) + if (e.abholung.aktiv) teile.push(`Sie können die Waren auch bei uns abholen: ${text(e.abholung.adresse) || '[Abholadresse]'}.${text(e.abholung.hinweis) ? ` ${text(e.abholung.hinweis)}` : ''}`) + if (!teile.length) teile.push('Angaben zur Lieferung und Abholung: [bitte ergänzen].') + l += teile.join(' ') + l += ` Die Lieferzeit beträgt ${text(e.lieferzeit) || '[Lieferzeit]'}, gerechnet ab Zahlungseingang. Ist eine Ware nicht verfügbar, informieren wir Sie unverzüglich.` + abs.push(l) + abs.push(`§ 6 Eigentumsvorbehalt\n\nDie Waren bleiben bis zur vollständigen Bezahlung unser Eigentum.`) + abs.push('§ 7 Widerrufsrecht\n\nVerbrauchern steht ein gesetzliches Widerrufsrecht zu. Einzelheiten ergeben sich aus der Widerrufsbelehrung, die Sie unter „Widerrufsbelehrung“ im Shop finden.') + abs.push( + '§ 8 Gewährleistung\n\nEs gelten die gesetzlichen Mängelhaftungsrechte. Der Hinweis auf das gesetzliche Gewährleistungsrecht steht vor Abgabe der Bestellung im Shop.' + ) + abs.push( + '§ 9 Vertragstext und Sprache\n\nWir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar. Ihre Bestelldaten, diese Allgemeinen Geschäftsbedingungen und die Widerrufsbelehrung erhalten Sie mit der Bestellbestätigung per E-Mail; bitte bewahren Sie diese E-Mail auf. Die Vertragssprache ist Deutsch.' + ) + abs.push( + `§ 10 Streitbeilegung\n\n${name} ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).` + ) + return abs.join('\n\n') +} + +/** Aus den Einstellungen erzeugter Text zu Versand und Zahlung (nicht frei editierbar, damit er stimmt). */ +export function versandUndZahlung(e) { + e = mitStandard(e) + const abs = ['Versand und Zahlung'] + if (e.versand.aktiv) { + abs.push(`Liefergebiet: ${laenderListe(e) || '–'}`) + const laender = Array.isArray(e.versand.laender) ? e.versand.laender : [] + const zeilen = laender + .filter((l) => Number.isInteger(e.versand.kostenCent?.[l])) + .map((l) => `${LANDNAMEN[l] ?? l}: ${euro(e.versand.kostenCent[l])}`) + if (zeilen.length) abs.push(`Versandkosten:\n${zeilen.join('\n')}`) + if (Number.isInteger(e.versand.freiAbCent)) abs.push(`Versandkostenfrei ab einem Bestellwert von ${euro(e.versand.freiAbCent)}.`) + } else { + abs.push('Versand: Wir versenden derzeit nicht.') + } + if (e.abholung.aktiv) { + abs.push(`Abholung: ${text(e.abholung.adresse) || '–'}${text(e.abholung.hinweis) ? `\n${text(e.abholung.hinweis)}` : ''}`) + } + abs.push(`Lieferzeit: ${text(e.lieferzeit) || '–'}`) + abs.push(`Zahlungsarten: ${zahlungsartenText(e) || '–'}`) + if (Number.isInteger(e.mindestbestellCent)) abs.push(`Mindestbestellwert: ${euro(e.mindestbestellCent)}`) + abs.push(e.kleinunternehmer ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.' : 'Alle Preise inkl. gesetzlicher USt.') + return abs.join('\n\n') +} + +/** + * Harmonisierter Hinweis auf das gesetzliche Gewährleistungsrecht (Art. 246a § 1 Abs. 1 S. 1 Nr. 11 EGBGB, VO (EU) 2025/1960 Anhang I, + * seit 27.09.2026), in Kasse hervorgehoben. + * Wortlaut prüfen (Kanzlei): Der amtliche deutsche Wortlaut von Anhang I stand in der Recherche nicht vor (EUR-Lex nicht geladen); + * dies ist eine sinngemaesse Fassung (Mindestdauer zwei Jahre, wichtigste Elemente). Grafik/Symbol und Farbe kommen spaeter. + */ +export function gewaehrleistungsHinweis() { + return [ + 'Gesetzliches Gewährleistungsrecht', + 'Für Waren besteht ein gesetzliches Gewährleistungsrecht von mindestens zwei Jahren ab Lieferung der Ware. Ist die Ware nicht vertragsgemäß, haben Sie zunächst Anspruch auf kostenlose Nachbesserung (Reparatur) oder Ersatzlieferung; unter den gesetzlichen Voraussetzungen können Sie stattdessen den Preis mindern oder vom Vertrag zurücktreten. Diese Rechte bestehen unabhängig von einer Garantie.', + ].join('\n\n') +} + +// --- Normalisierung -------------------------------------------------------------------------------------------------------- + +// Steuerzeichen (ausser Zeilenumbruch) und unsichtbare Formatzeichen raus +const STEUER = /[\p{Cc}\p{Cf}\u2028\u2029]/gu + +function bereinigeText(s) { + return s + .replace(/\r\n?/g, '\n') + .replace(/\t/g, ' ') + .replace(STEUER, (z) => (z === '\n' ? z : '')) + .replace(/[ ]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function pruefeUrl(wert) { + if (wert === undefined || wert === null) return { wert: '' } + if (typeof wert !== 'string') return { fehler: 'Bitte gib die Adresse als Text an.' } + const s = wert.trim() + if (s === '') return { wert: '' } + if (s.length > MAX_URL) return { fehler: `Die Adresse darf höchstens ${MAX_URL} Zeichen lang sein.` } + if (/[\s\p{Cc}\p{Cf}]/u.test(s)) return { fehler: 'Die Adresse darf keine Leerzeichen oder Sonderzeichen enthalten.' } + let u + try { + u = new URL(s) + } catch { + return { fehler: 'Bitte gib eine vollständige Adresse an, die mit https:// beginnt.' } + } + if (u.protocol !== 'https:' || !u.hostname || !/^https:\/\//i.test(s)) return { fehler: 'Die Adresse muss mit https:// beginnen.' } + if (u.username || u.password) return { fehler: 'Die Adresse darf keine Zugangsdaten enthalten.' } + return { wert: s } +} + +/** + * Pruefen und Normalisieren der Haendler-Rechtstexte. Texte duerfen im Entwurf leer sein; URLs muessen https sein oder leer. + * @returns {{rechtstexte: {agb, widerrufsbelehrung, widerrufsformular, impressumUrl, datenschutzUrl}} | {fehler: Object}} + */ +export function normalisiereRechtstexte(eingabe) { + if (!eingabe || typeof eingabe !== 'object' || Array.isArray(eingabe)) return { fehler: { rechtstexte: 'Die Rechtstexte fehlen oder sind unlesbar.' } } + const fehler = {} + const out = {} + const texte = [ + ['agb', 'AGB', MAX_AGB], + ['widerrufsbelehrung', 'Widerrufsbelehrung', MAX_WIDERRUFSBELEHRUNG], + ['widerrufsformular', 'Widerrufsformular', MAX_WIDERRUFSFORMULAR], + ] + for (const [key, name, max] of texte) { + const w = Object.hasOwn(eingabe, key) ? eingabe[key] : undefined + if (w === undefined || w === null) { + out[key] = '' + continue + } + if (typeof w !== 'string') { + fehler[key] = `Bitte gib die ${name} als Text an.` + out[key] = '' + continue + } + const s = bereinigeText(w) + if ([...s].length > max) { + fehler[key] = `Die ${name} darf höchstens ${max} Zeichen lang sein.` + out[key] = '' + } else out[key] = s + } + for (const [key, name] of [['impressumUrl', 'Impressum'], ['datenschutzUrl', 'Datenschutzerklärung']]) { + const r = pruefeUrl(Object.hasOwn(eingabe, key) ? eingabe[key] : undefined) + if (r.fehler) { + fehler[key] = `${name}: ${r.fehler}` + out[key] = '' + } else out[key] = r.wert + } + if (Object.keys(fehler).length) return { fehler } + return { + rechtstexte: { + agb: out.agb, + widerrufsbelehrung: out.widerrufsbelehrung, + widerrufsformular: out.widerrufsformular, + impressumUrl: out.impressumUrl, + datenschutzUrl: out.datenschutzUrl, + }, + } +} + +/** Alle drei Texte gefuellt und beide Adressen gesetzt: Voraussetzung fuer die Bestaetigung. */ +export function vollstaendig(rechtstexte) { + const r = rechtstexte && typeof rechtstexte === 'object' ? rechtstexte : {} + return ['agb', 'widerrufsbelehrung', 'widerrufsformular', 'impressumUrl', 'datenschutzUrl'].every((k) => typeof r[k] === 'string' && r[k].trim() !== '') +} + +/** sha256 (hex) ueber kanonisches JSON der fuenf Felder plus erzeugtem Versand-/Zahlungstext und Gewaehrleistungshinweis. */ +export function rechtstexteHash(rechtstexte, einstellungen) { + const r = rechtstexte && typeof rechtstexte === 'object' ? rechtstexte : {} + const s = (k) => (typeof r[k] === 'string' ? r[k] : '') + const kanonisch = JSON.stringify([ + ['agb', s('agb')], + ['widerrufsbelehrung', s('widerrufsbelehrung')], + ['widerrufsformular', s('widerrufsformular')], + ['impressumUrl', s('impressumUrl')], + ['datenschutzUrl', s('datenschutzUrl')], + ['versandUndZahlung', versandUndZahlung(einstellungen)], + ['gewaehrleistungsHinweis', gewaehrleistungsHinweis()], + ]) + return createHash('sha256').update(kanonisch).digest('hex') +} diff --git a/test/shopRechtstexte.test.js b/test/shopRechtstexte.test.js new file mode 100644 index 0000000..cc82444 --- /dev/null +++ b/test/shopRechtstexte.test.js @@ -0,0 +1,311 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { + musterWiderrufsbelehrung, + musterWiderrufsformular, + musterAgb, + versandUndZahlung, + gewaehrleistungsHinweis, + zahlungsartenText, + normalisiereRechtstexte, + vollstaendig, + rechtstexteHash, + BESTAETIGUNG_TEXT, +} from '../server/services/shop/rechtstexte.js' +import { STANDARD_EINSTELLUNGEN } from '../server/services/shop/einstellungen.js' + +const NB = '\u00a0' +const basis = () => { + const e = JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)) + e.anbieter = { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123456', ustId: 'DE123456789' } + return e +} +const WIDERRUF_URL = 'https://brot.example/shop#widerruf' + +test('Widerrufsbelehrung: gesetzliche Kernsaetze und eingesetzte Anbieterdaten', () => { + const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL }) + for (const s of [ + 'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.', + 'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.', + 'die letzte Ware in Besitz genommen haben bzw. hat.', + 'mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail)', + 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.', + 'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.', + 'unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist.', + 'in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.', + 'Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist.', + 'Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.', + ]) { + assert.ok(t.includes(s), `fehlt: ${s}`) + } + assert.ok(t.includes('(Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern, Telefon: 0631 123456, E-Mail: shop@brot.example)')) + assert.ok(t.includes('an uns (Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern) zurückzusenden oder zu übergeben')) + assert.ok(!t.includes('['), 'keine offenen Platzhalter bei vollen Angaben') + assert.ok(t.split('\n\n').length > 10) +}) + +test('Widerrufsbelehrung: Hinweis auf die Widerrufsfunktion mit Adresse', () => { + const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL }) + assert.ok(t.includes(`Sie können Ihr Widerrufsrecht auch online unter ${WIDERRUF_URL} ausüben.`)) + assert.ok( + t.includes( + 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.' + ) + ) + // ohne Adresse: sichtbarer Platzhalter statt leerer Stelle + assert.ok(musterWiderrufsbelehrung(basis()).includes('[Internetadresse der Widerrufsfunktion]')) +}) + +test('Widerrufsbelehrung: Variante Ruecksendekosten', () => { + const e = basis() + assert.ok(musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.')) + e.ruecksendekosten = 'haendler' + const t = musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }) + assert.ok(t.includes('Wir tragen die Kosten der Rücksendung der Waren.')) + assert.ok(!t.includes('Sie tragen die unmittelbaren Kosten')) +}) + +test('Widerrufsbelehrung: Abholung nur wenn angeboten', () => { + const e = basis() + assert.ok(!musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('abgeholt')) + e.abholung = { aktiv: true, adresse: 'Lager, Bahnhofstraße 1, 67655 Kaiserslautern', hinweis: '' } + assert.ok(musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: Lager, Bahnhofstraße 1, 67655 Kaiserslautern.')) +}) + +test('Widerrufsbelehrung: Ausschluss und vorzeitiges Erlöschen nach § 312g Abs. 2 Nr. 1, 2, 3', () => { + const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL }) + assert.ok(t.includes('Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts')) + assert.ok(t.includes('die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB)')) + assert.ok(t.includes('Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB)')) + assert.ok(t.includes('Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB)')) +}) + +test('Widerrufsbelehrung: leere Anbieterdaten ergeben Platzhalter, kein Absturz', () => { + const t = musterWiderrufsbelehrung(STANDARD_EINSTELLUNGEN, { widerrufUrl: WIDERRUF_URL }) + assert.ok(t.includes('[Name]')) + assert.ok(t.includes('[Anschrift]')) + assert.ok(t.includes('[E-Mail-Adresse]')) + assert.doesNotThrow(() => musterWiderrufsbelehrung(undefined)) + assert.doesNotThrow(() => musterAgb({})) + assert.doesNotThrow(() => versandUndZahlung(null)) +}) + +test('Widerrufsformular nach Anlage 2 mit Anschrift', () => { + const t = musterWiderrufsformular(basis()) + assert.ok(t.includes('An Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern, E-Mail: shop@brot.example:')) + assert.ok(t.includes('Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)')) + assert.ok(t.includes('– Bestellt am (*)/erhalten am (*)')) + assert.ok(t.includes('– Name des/der Verbraucher(s)')) + assert.ok(t.includes('– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)')) + assert.ok(t.includes('(*) Unzutreffendes streichen.')) + assert.ok(t.startsWith('Muster-Widerrufsformular')) +}) + +test('AGB: Kopf, Paragraphen, Vertragsschluss, Zahlung, Streitbeilegung ohne OS-Plattform', () => { + const e = basis() + e.zahlungsarten = ['card', 'klarna', 'sepa_debit'] + e.versand.laender = ['DE', 'AT'] + e.kleinunternehmer = false + const t = musterAgb(e) + assert.ok(t.startsWith('Muster – bitte prüfen und anpassen')) + for (let i = 1; i <= 10; i += 1) assert.ok(new RegExp(`^§ ${i} `, 'm').test(t), `§ ${i} fehlt`) + assert.ok(t.includes('Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab.')) + assert.ok(t.includes('Der Vertrag kommt zustande, sobald wir Ihnen nach erfolgreicher Zahlung die Bestellbestätigung per E-Mail senden.')) + assert.ok(t.includes('Die Zahlung erfolgt über den Zahlungsdienstleister Stripe.')) + assert.ok(t.includes('Kreditkarte, Apple Pay, Google Pay, Klarna, SEPA-Lastschrift')) + assert.ok(t.includes('Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung.')) + assert.ok(t.includes('Wir liefern in folgende Länder: Deutschland, Österreich.')) + assert.ok(t.includes('Die Lieferzeit beträgt 2–4 Werktage')) + assert.ok(t.includes('Die Waren bleiben bis zur vollständigen Bezahlung unser Eigentum.')) + assert.ok(t.includes('Es gelten die gesetzlichen Mängelhaftungsrechte.')) + assert.ok(t.includes('Wir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar.')) + assert.ok(t.includes('Die Vertragssprache ist Deutsch.')) + assert.ok(t.includes('ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG)')) + assert.ok(t.includes('Alle Preise enthalten die gesetzliche Umsatzsteuer.')) + assert.ok(t.includes('USt-IdNr.: DE123456789')) + assert.doesNotMatch(t, /ec\.europa\.eu|OS-Plattform|ODR/i) +}) + +test('AGB: Varianten Kleinunternehmer, nur Karte, Abholung, kein Versand', () => { + const e = basis() + e.kleinunternehmer = true + e.abholung = { aktiv: true, adresse: 'Laden, Hauptstraße 5', hinweis: 'Di–Fr 9–17 Uhr' } + e.versand.aktiv = false + const t = musterAgb(e) + assert.ok(t.includes('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.')) + assert.ok(!t.includes('SEPA'), 'SEPA-Satz nur bei SEPA') + assert.ok(!t.includes('Wir liefern in folgende Länder')) + assert.ok(t.includes('Sie können die Waren auch bei uns abholen: Laden, Hauptstraße 5. Di–Fr 9–17 Uhr')) +}) + +test('versandUndZahlung: Liefergebiet, Kosten je Land, frei ab, Abholung, Zahlungsarten, Mindestbestellwert', () => { + const e = basis() + e.versand = { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 } + e.abholung = { aktiv: true, adresse: 'Laden, Hauptstraße 5', hinweis: 'Di–Fr 9–17 Uhr' } + e.zahlungsarten = ['card', 'klarna'] + e.mindestbestellCent = 1000 + e.kleinunternehmer = false + const t = versandUndZahlung(e) + assert.ok(t.includes('Liefergebiet: Deutschland, Österreich')) + assert.ok(t.includes(`Deutschland: 4,90${NB}€`)) + assert.ok(t.includes(`Österreich: 9,90${NB}€`)) + assert.ok(t.includes(`Versandkostenfrei ab einem Bestellwert von 50,00${NB}€.`)) + assert.ok(t.includes('Abholung: Laden, Hauptstraße 5\nDi–Fr 9–17 Uhr')) + assert.ok(t.includes('Lieferzeit: 2–4 Werktage')) + assert.ok(t.includes('Zahlungsarten: Kreditkarte, Apple Pay, Google Pay, Klarna')) + assert.ok(t.includes(`Mindestbestellwert: 10,00${NB}€`)) + assert.ok(t.includes('Alle Preise inkl. gesetzlicher USt.')) + e.kleinunternehmer = true + e.mindestbestellCent = null + e.versand.freiAbCent = null + e.abholung.aktiv = false + const k = versandUndZahlung(e) + assert.ok(k.includes('§ 19 UStG')) + assert.ok(!k.includes('Mindestbestellwert')) + assert.ok(!k.includes('Versandkostenfrei')) + assert.ok(!k.includes('Abholung')) + e.versand.aktiv = false + assert.ok(versandUndZahlung(e).includes('Wir versenden derzeit nicht.')) +}) + +test('zahlungsartenText: feste Reihenfolge, nur aktive Arten', () => { + assert.equal(zahlungsartenText({ zahlungsarten: ['card'] }), 'Kreditkarte, Apple Pay, Google Pay') + assert.equal(zahlungsartenText({ zahlungsarten: ['klarna', 'card'] }), 'Kreditkarte, Apple Pay, Google Pay, Klarna') + assert.equal(zahlungsartenText({ zahlungsarten: ['sepa_debit', 'klarna'] }), 'Klarna, SEPA-Lastschrift') + assert.equal(zahlungsartenText({ zahlungsarten: ['paypal', 'card'] }), 'Kreditkarte, Apple Pay, Google Pay') + assert.equal(zahlungsartenText({}), '') + assert.equal(zahlungsartenText(undefined), '') +}) + +test('Gewaehrleistungshinweis: zwei Jahre, stabil, ohne Platzhalter', () => { + const t = gewaehrleistungsHinweis() + assert.ok(t.startsWith('Gesetzliches Gewährleistungsrecht')) + assert.ok(t.includes('mindestens zwei Jahren')) + assert.equal(gewaehrleistungsHinweis(), t) +}) + +test('BESTAETIGUNG_TEXT genau nach Spec 4.6', () => { + assert.equal(BESTAETIGUNG_TEXT, 'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.') +}) + +test('normalisiereRechtstexte: gueltig, Entwurf mit leeren Texten, Bereinigung', () => { + const r = normalisiereRechtstexte({ + agb: ' Zeile 1\r\nZeile 2​ \n\n\n\nZeile 3 ', + widerrufsbelehrung: 'B', + widerrufsformular: 'F', + impressumUrl: ' https://brot.example/impressum ', + datenschutzUrl: 'https://brot.example/datenschutz?x=1#a', + unbekannt: 'wird ignoriert', + }) + assert.deepEqual(r, { + rechtstexte: { + agb: 'Zeile 1\nZeile 2\n\nZeile 3', + widerrufsbelehrung: 'B', + widerrufsformular: 'F', + impressumUrl: 'https://brot.example/impressum', + datenschutzUrl: 'https://brot.example/datenschutz?x=1#a', + }, + }) + const leer = normalisiereRechtstexte({}) + assert.deepEqual(leer, { rechtstexte: { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' } }) + assert.equal(vollstaendig(leer.rechtstexte), false) +}) + +test('normalisiereRechtstexte: Laengen', () => { + assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30000) }).rechtstexte) + assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30001) }).fehler.agb) + assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20000) }).rechtstexte) + assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20001) }).fehler.widerrufsbelehrung) + assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5000) }).rechtstexte) + assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5001) }).fehler.widerrufsformular) +}) + +test('normalisiereRechtstexte: URLs nur https ohne Zugangsdaten und Leerraum', () => { + for (const u of ['http://x.example/a', 'javascript:alert(1)', 'ftp://x.example', '//x.example/a', 'x.example/impressum', 'https://user:pw@x.example/a', 'https://user@x.example/a', 'https://x.example/a b', 'https://', 'data:text/html,hi']) { + const r = normalisiereRechtstexte({ impressumUrl: u }) + assert.ok(r.fehler?.impressumUrl, `muss abgelehnt werden: ${u}`) + } + assert.ok(normalisiereRechtstexte({ datenschutzUrl: 'https://' + 'a'.repeat(500) + '.example' }).fehler.datenschutzUrl) + assert.ok(normalisiereRechtstexte({ datenschutzUrl: 'https://x.example/datenschutz' }).rechtstexte) + assert.equal(normalisiereRechtstexte({ impressumUrl: '' }).rechtstexte.impressumUrl, '') +}) + +test('normalisiereRechtstexte: falsche Typen und Nicht-Objekte', () => { + for (const v of [null, undefined, 'x', 5, [], true]) assert.ok(normalisiereRechtstexte(v).fehler.rechtstexte) + const r = normalisiereRechtstexte({ agb: 5, widerrufsbelehrung: {}, impressumUrl: 7 }) + assert.ok(r.fehler.agb && r.fehler.widerrufsbelehrung && r.fehler.impressumUrl) + assert.ok(!('rechtstexte' in r)) + // Prototyp-Schluessel werden nie gelesen + const p = normalisiereRechtstexte(JSON.parse('{"__proto__":{"agb":"x"}}')) + assert.equal(p.rechtstexte.agb, '') +}) + +test('vollstaendig: alle drei Texte und beide URLs', () => { + const voll = { agb: 'a', widerrufsbelehrung: 'b', widerrufsformular: 'c', impressumUrl: 'https://x.example/i', datenschutzUrl: 'https://x.example/d' } + assert.equal(vollstaendig(voll), true) + for (const k of Object.keys(voll)) { + assert.equal(vollstaendig({ ...voll, [k]: '' }), false, k) + assert.equal(vollstaendig({ ...voll, [k]: ' ' }), false, `${k} nur Leerraum`) + } + assert.equal(vollstaendig(null), false) + assert.equal(vollstaendig(undefined), false) +}) + +test('rechtstexteHash: stabil, sha256 hex, reagiert auf jedes Feld und die Einstellungen', () => { + const e = basis() + const r = { agb: 'a', widerrufsbelehrung: 'b', widerrufsformular: 'c', impressumUrl: 'https://x.example/i', datenschutzUrl: 'https://x.example/d' } + const h = rechtstexteHash(r, e) + assert.match(h, /^[0-9a-f]{64}$/) + assert.equal(rechtstexteHash({ ...r }, JSON.parse(JSON.stringify(e))), h) + // Reihenfolge der Schluessel spielt keine Rolle + assert.equal(rechtstexteHash({ datenschutzUrl: r.datenschutzUrl, impressumUrl: r.impressumUrl, widerrufsformular: 'c', widerrufsbelehrung: 'b', agb: 'a' }, e), h) + // zusaetzliche Felder zaehlen nicht + assert.equal(rechtstexteHash({ ...r, extra: 'x' }, e), h) + for (const k of Object.keys(r)) assert.notEqual(rechtstexteHash({ ...r, [k]: `${r[k]}x` }, e), h, k) + // Versand-/Zahlungstext fliesst ein + const e2 = JSON.parse(JSON.stringify(e)) + e2.versand.kostenCent.DE = 590 + assert.notEqual(rechtstexteHash(r, e2), h) + const e3 = JSON.parse(JSON.stringify(e)) + e3.zahlungsarten = ['card', 'klarna'] + assert.notEqual(rechtstexteHash(r, e3), h) + // Felder ohne Einfluss auf den erzeugten Text aendern den Hash nicht + const e4 = JSON.parse(JSON.stringify(e)) + e4.akzent = '#ff0000' + assert.equal(rechtstexteHash(r, e4), h) + // fehlende Felder wie leere Texte + assert.equal(rechtstexteHash({}, e), rechtstexteHash({ agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }, e)) +}) + +test('Markdown-Muster und Code teilen die Kernsaetze', () => { + const md = readFileSync(new URL('../docs/funktionen/shop-muster-rechtstexte.md', import.meta.url), 'utf8') + assert.ok(md.includes('Muster – keine Rechtsberatung')) + const e = basis() + e.zahlungsarten = ['card', 'klarna', 'sepa_debit'] + e.kleinunternehmer = false + // Absaetze ohne Haendlerdaten muessen wortgleich im Markdown stehen + const absaetze = [ + ...musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).split('\n\n'), + ...musterAgb(e).split('\n\n'), + ...musterWiderrufsformular(e).split('\n\n'), + ...gewaehrleistungsHinweis().split('\n\n'), + ] + const ohneDaten = absaetze.filter((a) => !/Brot & Mehr|brot\.example|Hauptstra|Kaiserslautern|2–4 Werktage|0631|DE123456789/.test(a)) + assert.ok(ohneDaten.length >= 25, `zu wenige vergleichbare Absaetze: ${ohneDaten.length}`) + const mdFlach = md.replace(/ {2}\n/g, '\n') + for (const a of ohneDaten) assert.ok(mdFlach.includes(a), `im Markdown fehlt: ${a.slice(0, 80)}`) + // Absaetze mit Haendlerdaten: Kernsaetze davon + for (const s of [ + 'Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab.', + 'Der Vertrag kommt zustande, sobald wir Ihnen nach erfolgreicher Zahlung die Bestellbestätigung per E-Mail senden.', + 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.', + 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung', + 'ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG)', + 'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag', + ]) { + assert.ok(md.includes(s), `Markdown: ${s}`) + } + assert.ok(md.includes('Wir tragen die Kosten der Rücksendung der Waren.')) + assert.doesNotMatch(md, /ec\.europa\.eu\/consumers\/odr/) +}) From 83605975b045b4d247a44f25409f544489500e4a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:40:24 +0000 Subject: [PATCH 042/105] Webshop B1: Anfrage-Dienst, Kundenroute /api/shop und Team-Mail T21 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anfrage stellen, zurückziehen und Stand lesen (ensureShopConfig, normalisiereAnfrage, stelleAnfrage, zieheZurueck, anfrageAnsicht), Router /api/shop mit Rate-Limit 20/h und Sperre in der Kundenansicht, Vorlage T21 Neue Webshop-Anfrage mit Tests. Co-Authored-By: Claude Opus 5.5 --- server/index.js | 2 + .../T21 Neue Webshop-Anfrage.html | 48 +++ server/routes/shop.js | 114 +++++ server/services/shop/anfrage.js | 227 +++++++++- test/kundenansichtGuard.test.js | 3 + test/mailTemplates.test.js | 24 +- test/shopAnfrage.test.js | 400 ++++++++++++++++++ 7 files changed, 801 insertions(+), 17 deletions(-) create mode 100644 server/mail-templates/T21 Neue Webshop-Anfrage.html create mode 100644 server/routes/shop.js create mode 100644 test/shopAnfrage.test.js diff --git a/server/index.js b/server/index.js index cdfa158..7c83540 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,7 @@ import chatRoutes from './routes/chat.js' import aboRoutes from './routes/abo.js' import funktionenRoutes from './routes/funktionen.js' import termineRoutes from './routes/termine.js' +import shopRoutes from './routes/shop.js' import takeoverCreditRoutes from './routes/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' @@ -79,6 +80,7 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo', aboRoutes) app.use('/api/funktionen', funktionenRoutes) app.use('/api/termine', termineRoutes) +app.use('/api/shop', shopRoutes) app.use('/api/team', teamRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/website-projects', adminPreviewRoutes) diff --git a/server/mail-templates/T21 Neue Webshop-Anfrage.html b/server/mail-templates/T21 Neue Webshop-Anfrage.html new file mode 100644 index 0000000..0c18a59 --- /dev/null +++ b/server/mail-templates/T21 Neue Webshop-Anfrage.html @@ -0,0 +1,48 @@ + + + + + + + + +[Webshop] Neue Anfrage: {{kunde_name}} + + + + +{{kunde_name}} hat eine Webshop-Anfrage gestellt (Projekt {{projekt}}). ͏ ͏ ͏ + + + + + diff --git a/server/routes/shop.js b/server/routes/shop.js new file mode 100644 index 0000000..80a6629 --- /dev/null +++ b/server/routes/shop.js @@ -0,0 +1,114 @@ +import { Router } from 'express' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' +import { getDocument } from '../services/appwriteAdmin.js' +import { config } from '../config.js' +import { isKundenansicht } from '../services/kundenansicht.js' +import { createWriteLimiter } from '../services/writeLimiter.js' +import { anfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js' +import { shopFuerKunde } from '../services/shop/schalter.js' + +/** + * Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Dieser Stand (Task B1) enthaelt + * die Anfrage: GET /status, POST /anfrage, POST /anfrage/zurueckziehen. Weitere Routen kommen mit C7, D2 und E5. + * + * Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Schreib-Routen sind in der Kundenansicht gesperrt + * (kundenansichtGuard: nicht auf der Erlaubnisliste; hier zusaetzlich 403) und zaehlen gegen 20 Aufrufe je Kunde und Stunde. + * Fehler: { error: , fehler?: { : Text }, code? }; Texte von Appwrite oder unbekannten Fehlern verlassen den Server nie. + */ + +const router = Router() + +const MAX_WRITES_PER_HOUR = 20 +const MELDUNG = { + projekt: 'Bitte wähle ein Projekt.', + fremd: 'Kein Zugriff auf dieses Projekt.', + kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.', + zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.', +} + +/** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */ +function fail(res, err) { + const own = ((err?.status >= 400 && err.status < 500) || err?.status === 503) && !err.type + if (!own) { + console.error(`[shop] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim()) + return res.status(500).json({ error: 'Interner Serverfehler' }) + } + const body = { ...err.body, error: err.message } + if (err.fehler && typeof err.fehler === 'object') body.fehler = err.fehler + return res.status(err.status).json(body) +} + +const route = (fn) => async (req, res, next) => { + try { + await fn(req, res, next) + } catch (err) { + fail(res, err) + } +} + +const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR }) +const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) + +export function resetShopRoutesForTests() { + limiter.reset() +} + +const nurKunde = (req, res, next) => { + if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ error: MELDUNG.kundenansicht }) + return next() +} + +const person = (req) => ({ name: req.session?.name || '', email: req.session?.email || '' }) +const projectIdOf = (req) => { + const raw = req.query.projectId ?? req.body?.projectId + return typeof raw === 'string' ? raw : '' +} + +router.use((_req, res, next) => { + res.set('Cache-Control', 'no-store') + next() +}) + +/** + * Stand des Webshops fuer ein Projekt: { verfuegbar, state, anfrage }. verfuegbar = Schalter fuer diesen Kunden; + * state = Zustand der Funktion (off, wenn es noch kein Dokument gibt); anfrage = anfrageAnsicht oder null. Legt nichts an. + */ +router.get('/status', requireSession, route(async (req, res) => { + const projectId = projectIdOf(req) + if (!projectId) throw Object.assign(new Error(MELDUNG.projekt), { status: 400 }) + const customerId = getSessionCustomerId(req) + try { + await assertOwnProject(customerId, projectId) + } catch (err) { + throw err?.status === 403 || err?.status === 400 ? Object.assign(new Error(MELDUNG.fremd), { status: 403 }) : err + } + const verfuegbar = shopFuerKunde(req.session?.email) + const feature = await getFeatureDoc(customerId, projectId, 'shop') + let shop = null + if (feature) { + try { + shop = await getDocument(config.collections.shopConfig, feature.$id) + } catch (err) { + if (!(err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found')) throw err + } + } + res.json({ verfuegbar, state: feature?.state || 'off', anfrage: shop ? anfrageAnsicht(shop) : null }) +})) + +router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req, res) => { + const anfrage = await stelleAnfrage({ + customerId: getSessionCustomerId(req), + projectId: projectIdOf(req), + eingabe: req.body?.anfrage, + person: person(req), + }) + res.json({ anfrage }) +})) + +router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitWrites, route(async (req, res) => { + const anfrage = await zieheZurueck({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), person: person(req) }) + res.json({ anfrage }) +})) + +export default router diff --git a/server/services/shop/anfrage.js b/server/services/shop/anfrage.js index 6ebabf2..196835f 100644 --- a/server/services/shop/anfrage.js +++ b/server/services/shop/anfrage.js @@ -1,15 +1,39 @@ import { config } from '../../config.js' -import { getDocument } from '../appwriteAdmin.js' -import { getFeatureDoc } from '../siteFeatures.js' +import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { sendAdminNotice } from '../mailer.js' +import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js' +import { shopFuerKunde } from './schalter.js' /** - * Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Dieser Stand ist der Rumpf aus - * Task A3: nur die Bestellpruefung; Block B (B1, B2) baut Anfrage stellen, zurueckziehen und die Team-Entscheidung aus. - * Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des portalFeatures-Dokuments des Projekts ist. + * Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Der Kunde stellt eine Anfrage, das Team + * entscheidet (B2: annehmen oder ablehnen); erst eine angenommene Anfrage laesst die Bestellung der Funktion zu + * (anfrageOffenePunkte, Haken bestellpruefung). Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des + * portalFeatures-Dokuments des Projekts ist. anfrageStatus: '' (keine) | offen | angenommen | abgelehnt | zurueckgezogen. + * Fehler an den Kunden tragen status (4xx) und einen deutschen Text (Du-Form), optional err.body.code. */ export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigegeben.' +export const WARENARTEN = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst', sonstiges: 'Sonstiges' } +export const ARTIKELZAHLEN = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' } + +const BESCHREIBUNG_MIN = 10 +const BESCHREIBUNG_MAX = 1500 +const NACHRICHT_MAX = 1000 + +const MELDUNG = { + projekt: 'Bitte wähle ein Projekt.', + nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.', + vorhanden: 'Für dieses Projekt gibt es schon einen Webshop.', + laeuft: 'Für dieses Projekt gibt es schon eine Webshop-Anfrage.', + nichtOffen: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.', + ungueltig: 'Ungültige Eingabe.', +} + +const stop = (status, text, code) => Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}) }) +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + /** * Offene Punkte der Bestellpruefung (featureHaken('shop').bestellpruefung): [] wenn die Anfrage des Projekts vom Team angenommen * ist (anfrageStatus 'angenommen'), sonst ein Punkt. Ohne portalFeatures-Dokument, ohne shopConfig-Dokument oder bei jedem anderen @@ -18,12 +42,191 @@ export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigege export async function anfrageOffenePunkte({ customerId, projectId }) { const doc = await getFeatureDoc(customerId, projectId, 'shop') if (!doc) return [ANFRAGE_OFFEN_TEXT] - let shop - try { - shop = await getDocument(config.collections.shopConfig, doc.$id) - } catch (err) { - if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return [ANFRAGE_OFFEN_TEXT] - throw err - } + const shop = await leseShopConfig(doc.$id) return shop?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT] } + +/** shopConfig-Dokument zur ID oder null, wenn es (noch) keines gibt. Fehlt die Collection selbst, wird der Fehler weitergereicht. */ +async function leseShopConfig(id) { + try { + return await getDocument(config.collections.shopConfig, id) + } catch (err) { + if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return null + throw err + } +} + +/** + * shopConfig-Dokument zum portalFeatures-Dokument holen oder anlegen (ID gleich, siteKey kopiert, Anfrage leer, Version 0). + * Zwei gleichzeitige Anlagen: die zweite bekommt 409 und liest das Dokument der ersten. + */ +export async function ensureShopConfig(featureDoc) { + const vorhanden = await leseShopConfig(featureDoc.$id) + if (vorhanden) return vorhanden + try { + return await createDocument( + config.collections.shopConfig, + { + customerId: featureDoc.customerId, + projectId: featureDoc.projectId, + siteKey: featureDoc.siteKey, + anfrageStatus: '', + version: 0, + }, + featureDoc.$id + ) + } catch (err) { + if (err?.status === 409) { + const nochmal = await leseShopConfig(featureDoc.$id) + if (nochmal) return nochmal + } + throw err + } +} + +/** + * Eingabe der Anfrage pruefen => { anfrage } (nur bekannte Felder, bereinigt) oder { fehler: { feld: text } }. + * warenart, beschreibung (10-1500 Zeichen), artikelzahl, versand/abholung (mindestens eins), unternehmer und keineAusgeschlossenen + * muessen true sein, nachricht (optional, bis 1000 Zeichen). + */ +export function normalisiereAnfrage(eingabe) { + const fehler = {} + const e = istObjekt(eingabe) ? eingabe : {} + const text = (wert) => (typeof wert === 'string' ? wert.toWellFormed().trim() : '') + + const warenart = typeof e.warenart === 'string' && Object.hasOwn(WARENARTEN, e.warenart) ? e.warenart : '' + if (!warenart) fehler.warenart = 'Bitte wähle, was du verkaufen möchtest.' + + const beschreibung = text(e.beschreibung) + if (beschreibung.length < BESCHREIBUNG_MIN) fehler.beschreibung = `Bitte beschreibe kurz, was du verkaufen möchtest (mindestens ${BESCHREIBUNG_MIN} Zeichen).` + else if (beschreibung.length > BESCHREIBUNG_MAX) fehler.beschreibung = `Die Beschreibung darf höchstens ${BESCHREIBUNG_MAX} Zeichen lang sein.` + + const artikelzahl = typeof e.artikelzahl === 'string' && Object.hasOwn(ARTIKELZAHLEN, e.artikelzahl) ? e.artikelzahl : '' + if (!artikelzahl) fehler.artikelzahl = 'Bitte wähle, wie viele Artikel du etwa anbieten möchtest.' + + if (e.versand !== undefined && typeof e.versand !== 'boolean') fehler.versand = MELDUNG.ungueltig + if (e.abholung !== undefined && typeof e.abholung !== 'boolean') fehler.abholung = MELDUNG.ungueltig + const versand = e.versand === true + const abholung = e.abholung === true + if (!fehler.versand && !fehler.abholung && !versand && !abholung) fehler.versand = 'Bitte wähle Versand, Abholung oder beides.' + + if (e.unternehmer !== true) fehler.unternehmer = 'Bitte bestätige, dass du als Unternehmer verkaufst.' + if (e.keineAusgeschlossenen !== true) fehler.keineAusgeschlossenen = 'Bitte bestätige, dass du keine ausgeschlossenen Waren verkaufst.' + + let nachricht = '' + if (e.nachricht !== undefined && e.nachricht !== null && e.nachricht !== '') { + if (typeof e.nachricht !== 'string') fehler.nachricht = MELDUNG.ungueltig + else { + nachricht = text(e.nachricht) + if (nachricht.length > NACHRICHT_MAX) fehler.nachricht = `Die Nachricht darf höchstens ${NACHRICHT_MAX} Zeichen lang sein.` + } + } + + if (Object.keys(fehler).length) return { fehler } + return { anfrage: { warenart, beschreibung, artikelzahl, versand, abholung, unternehmer: true, keineAusgeschlossenen: true, nachricht } } +} + +/** Anfrage fuer den Browser: { status, anfrage, at, entschiedenAt, begruendung } oder null, solange es keine gab. Unlesbares JSON => anfrage null. */ +export function anfrageAnsicht(shopDoc) { + const status = shopDoc?.anfrageStatus || '' + if (!status) return null + let anfrage = null + try { + const roh = typeof shopDoc.anfrage === 'string' && shopDoc.anfrage ? JSON.parse(shopDoc.anfrage) : null + anfrage = istObjekt(roh) ? roh : null + } catch { + anfrage = null + } + return { + status, + anfrage, + at: shopDoc.anfrageAt || null, + entschiedenAt: shopDoc.anfrageEntschiedenAt || null, + begruendung: shopDoc.anfrageBegruendung || '', + } +} + +/** Projekt des Kunden (Appwrite-Fehler 400 bei unbrauchbarer ID zaehlt als fremdes Projekt) und Schalter des Kunden. */ +async function pruefeZugang({ customerId, projectId, person, neu }) { + if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt) + let project + try { + project = await assertOwnProject(customerId, projectId, { forNewOrder: neu }) + } catch (err) { + throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err + } + if (!shopFuerKunde(person?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') + return project +} + +/** + * Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Eingabe (400 mit `fehler`), dann unter Sperre + * je Projekt: Funktion muss `off` sein (409), Anfrage-Status '' | abgelehnt | zurueckgezogen (409). Speichert JSON, Status `offen` + * und anfrageAt, loescht eine fruehere Entscheidung und schickt T21 ans Team (ein Fehler dabei wird nur geloggt). + * @returns {Promise} anfrageAnsicht des neuen Standes + */ +export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date() }) { + const project = await pruefeZugang({ customerId, projectId, person, neu: true }) + const { anfrage, fehler } = normalisiereAnfrage(eingabe) + if (fehler) throw Object.assign(stop(400, Object.values(fehler)[0]), { fehler }) + + const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => { + const feature = await ensureFeatureDoc(customerId, projectId, 'shop') + if (feature.state !== 'off') throw stop(409, MELDUNG.vorhanden, 'vorhanden') + const shop = await ensureShopConfig(feature) + if (!['', 'abgelehnt', 'zurueckgezogen'].includes(shop.anfrageStatus || '')) throw stop(409, MELDUNG.laeuft, 'anfrage_laeuft') + return updateDocument(config.collections.shopConfig, shop.$id, { + anfrage: JSON.stringify(anfrage), + anfrageStatus: 'offen', + anfrageAt: now.toISOString(), + anfrageEntschiedenAt: null, + anfrageEntschiedenVon: '', + anfrageBegruendung: '', + version: (Number.isInteger(shop.version) ? shop.version : 0) + 1, + }) + }) + + await meldeTeam({ customerId, projectId, project, anfrage, person }) + return anfrageAnsicht(gespeichert) +} + +/** T21 "Neue Webshop-Anfrage" an das Team; sendAdminNotice wirft nie, ein Fehlschlag steht nur im Log (die Anfrage ist gespeichert). */ +async function meldeTeam({ customerId, projectId, project, anfrage, person }) { + try { + let kundeName = String(person?.name || '').trim() + if (!kundeName) kundeName = String((await customerOf(customerId))?.name || '').trim() + const lieferung = [anfrage.versand ? 'Versand' : '', anfrage.abholung ? 'Abholung' : ''].filter(Boolean).join(' und ') + const result = await sendAdminNotice({ + template: 'T21', + values: { + kunde_name: kundeName || 'Kunde', + email: person?.email || '', + projekt: project?.projectName || projectId, + warenart: WARENARTEN[anfrage.warenart], + beschreibung: anfrage.beschreibung, + artikelzahl: ARTIKELZAHLEN[anfrage.artikelzahl], + lieferung, + nachricht: anfrage.nachricht || '–', + woms_url: `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId)}?tab=funktionen`, + }, + }) + if (!result?.sent) console.warn(`[shop] Team-Mail T21 nicht gesendet: ${result?.reason || 'unbekannt'}`) + } catch (err) { + console.warn(`[shop] Team-Mail T21 nicht gesendet: ${err?.message}`) + } +} + +/** Offene Anfrage zurueckziehen (Status `zurueckgezogen`); aus jedem anderen Zustand 409. @returns {Promise} anfrageAnsicht */ +export async function zieheZurueck({ customerId, projectId, person = {} }) { + await pruefeZugang({ customerId, projectId, person, neu: false }) + const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => { + const feature = await getFeatureDoc(customerId, projectId, 'shop') + const shop = feature ? await leseShopConfig(feature.$id) : null + if (!shop || shop.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtOffen, 'nicht_offen') + return updateDocument(config.collections.shopConfig, shop.$id, { + anfrageStatus: 'zurueckgezogen', + version: (Number.isInteger(shop.version) ? shop.version : 0) + 1, + }) + }) + return anfrageAnsicht(gespeichert) +} diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 7929da4..218ab52 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -38,6 +38,7 @@ const MOUNTS = [ ['/api/abo', (await import('../server/routes/abo.js')).default], ['/api/funktionen', (await import('../server/routes/funktionen.js')).default], ['/api/termine', termineRouter], + ['/api/shop', (await import('../server/routes/shop.js')).default], ] // Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist const oeffentlich = (await import('../server/routes/publicTermine.js')).default @@ -140,6 +141,8 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L ['PUT', '/api/termine/einstellungen'], ['PATCH', '/api/termine/buchungen/abc'], ['PATCH', '/api/termine/einstellungen/x'], ['DELETE', '/api/termine/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'], ['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'], + // Webshop: Anfrage stellen und zurueckziehen sind in der Kundenansicht gesperrt (nicht auf der Liste) + ['POST', '/api/shop/anfrage'], ['POST', '/api/shop/anfrage/zurueckziehen'], ]) { assert.ok(!allowed(m, p), `${m} ${p}`) } diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 072637b..da9d5da 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. const DIR = new URL('../server/mail-templates/', import.meta.url) // G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb; -// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) +// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) -const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20'] +const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20', 'T21'] const GUEST = ids('G', 8) const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) @@ -24,8 +24,8 @@ for (const id of EXPECTED) { const source = readFileSync(new URL(tpl.file, DIR), 'utf8') const title = source.match(/([\s\S]*?)<\/title>/)[1] assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') - // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang) oder [Terminplaner] (Betriebsmeldung) - assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen] oder [Terminplaner] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)') + // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang), [Terminplaner] (Betriebsmeldung) oder [Webshop] (neue Anfrage) + assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner] oder [Webshop] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)') const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') @@ -70,6 +70,20 @@ test('T20: Betreff [Terminplaner], Platzhalter vorfall/details/zeit, keine Blöc for (const line of ['Mailkontingent erreicht', 'Zeile 1\nZeile <2>', '04.10.2026, 10:00']) assert.ok(mail.text.includes(line), `Text enthält: ${line}\n---\n${mail.text}`) }) +// Webshop B1: Team-Hinweis auf eine neue Anfrage (Versand ueber sendAdminNotice, services/shop/anfrage.js) +test('T21: Betreff [Webshop], Platzhalter der Anfrage, keine Blöcke, Knopf zum Kunden im Ticketsystem', () => { + const tpl = loadTemplate('T21') + assert.equal(tpl.file, 'T21 Neue Webshop-Anfrage.html') + assert.equal(tpl.subject, '[Webshop] Neue Anfrage: {{kunde_name}}') + assert.deepEqual(tpl.placeholders, ['kunde_name', 'email', 'projekt', 'warenart', 'beschreibung', 'artikelzahl', 'lieferung', 'nachricht', 'woms_url']) + assert.deepEqual(tpl.blocks, []) + const mail = renderMail('T21', { ...sampleValues(tpl), beschreibung: 'Zeile 1\nZeile <2>', woms_url: 'https://ticket.webklar.com/customers/c1?tab=funktionen' }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, '[Webshop] Neue Anfrage: Wert-kunde_name') + assert.match(mail.html, /Zeile 1<br>Zeile <2>/) + assert.match(mail.html, /<a href="https:\/\/ticket\.webklar\.com\/customers\/c1\?tab=funktionen"/) +}) + // Terminplaner TP2 (B9a): K15 und K16 gelten für jede Funktion des Registers; der Wortlaut je Funktion kommt aus den Platzhaltern test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text', () => { const k15 = loadTemplate('K15') @@ -699,7 +713,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => { }) // Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem -const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20'] +const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21'] test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => { for (const id of EXPECTED) { diff --git a/test/shopAnfrage.test.js b/test/shopAnfrage.test.js new file mode 100644 index 0000000..2006163 --- /dev/null +++ b/test/shopAnfrage.test.js @@ -0,0 +1,400 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Webshop B1: Anfrage stellen, zurueckziehen, Stand lesen (services/shop/anfrage.js, routes/shop.js), T21 ans Team, +// Sperre in der Kundenansicht (kundenansichtGuard, Router) +const aw = await startAppwriteMock({ + schema: { portalFeatures: schemaKeys('portalFeatures'), shopConfig: schemaKeys('shopConfig'), featureConsents: schemaKeys('featureConsents') }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, send, get, post } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js') +const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const anfrage = await import('../server/services/shop/anfrage.js') +const sf = await import('../server/services/siteFeatures.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { featureHaken } = await import('../server/services/featureRegistry.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes }) +const mails = mailSink() +mails.versandFehler = null +const sinkMail = mails.sendMail.bind(mails) +mails.sendMail = async (mail) => { + if (mails.versandFehler) throw new Error('SMTP nicht erreichbar') + return sinkMail(mail) +} +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) + +seedFixtures(aw) +aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Töpferei', archived: false }) +aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' }) +aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' }) +aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Altprojekt', archived: true }) + +const cookie = customerCookie('c1') +const GUELTIG = { + warenart: 'kunst', + beschreibung: 'Handgemachte Keramik und Tassen aus eigener Werkstatt.', + artikelzahl: 'bis20', + versand: true, + abholung: false, + unternehmer: true, + keineAusgeschlossenen: true, + nachricht: 'Wir würden gern vor Weihnachten starten.', +} +const stelle = (body = {}, opts = { cookie }) => post('/api/shop/anfrage', { projectId: 'pt1', anfrage: GUELTIG, ...body }, opts) +const zurueck = (projectId = 'pt1', opts = { cookie }) => post('/api/shop/anfrage/zurueckziehen', { projectId }, opts) +const status = (projectId = 'pt1', opts = { cookie }) => send('GET', `/api/shop/status?projectId=${projectId}`, opts) +const t21 = () => mails.sent.filter((m) => /^\[Webshop\]/.test(m.subject)) +const shopDoc = (projectId = 'pt1') => aw.docs('shopConfig').get(`${projectId}`) ?? null + +let origin +beforeEach(() => { + origin ??= { mode: config.shop.mode, pilotEmails: config.shop.pilotEmails } + config.shop.mode = 'pilot' + config.shop.pilotEmails = [customers.c1.email] + mails.versandFehler = null + resetShopRoutesForTests() +}) + +async function leereAlles() { + for (const col of ['portalFeatures', 'shopConfig']) for (const id of aw.docs(col).keys()) await deleteDocument(config.collections[col], id) + mails.sent.length = 0 +} + +test('normalisiereAnfrage: gueltige Eingabe, bereinigt; Fehler je Feld', () => { + const { anfrage: ok, fehler } = anfrage.normalisiereAnfrage({ ...GUELTIG, beschreibung: ' Handgemachte Tassen und mehr ', extra: 'x' }) + assert.equal(fehler, undefined) + assert.deepEqual(Object.keys(ok).sort(), ['abholung', 'artikelzahl', 'beschreibung', 'keineAusgeschlossenen', 'nachricht', 'unternehmer', 'versand', 'warenart']) + assert.equal(ok.beschreibung, 'Handgemachte Tassen und mehr') + + const f = (aenderung) => anfrage.normalisiereAnfrage({ ...GUELTIG, ...aenderung }).fehler + assert.ok(f({ warenart: 'drogen' }).warenart) + assert.ok(f({ warenart: 'constructor' }).warenart, 'kein Treffer ueber die Prototypkette') + assert.ok(f({ beschreibung: 'kurz' }).beschreibung) + assert.ok(f({ beschreibung: 'x'.repeat(1501) }).beschreibung) + assert.equal(f({ beschreibung: 'x'.repeat(1500) }), undefined) + assert.ok(f({ artikelzahl: 'viele' }).artikelzahl) + assert.ok(f({ versand: false, abholung: false }).versand, 'mindestens eins von Versand und Abholung') + assert.equal(f({ versand: false, abholung: true }), undefined) + assert.ok(f({ versand: 'ja' }).versand) + assert.ok(f({ unternehmer: 'true' }).unternehmer) + assert.ok(f({ unternehmer: false }).unternehmer) + assert.ok(f({ keineAusgeschlossenen: undefined }).keineAusgeschlossenen) + assert.ok(f({ nachricht: 'n'.repeat(1001) }).nachricht) + assert.equal(f({ nachricht: undefined }), undefined) + assert.equal(anfrage.normalisiereAnfrage({ ...GUELTIG, nachricht: undefined }).anfrage.nachricht, '') + assert.ok(anfrage.normalisiereAnfrage(null).fehler.warenart) + assert.ok(anfrage.normalisiereAnfrage([]).fehler.beschreibung) +}) + +test('GET /status: ohne Dokumente off und leer, legt nichts an; Schalter, fremdes Projekt, ohne Projekt', async () => { + await leereAlles() + const res = await status() + assert.equal(res.status, 200) + assert.deepEqual(res.body, { verfuegbar: true, state: 'off', anfrage: null }) + assert.equal(res.headers.get('cache-control'), 'no-store') + assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0, 'GET legt nichts an') + + config.shop.mode = 'off' + assert.equal((await status()).body.verfuegbar, false) + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['jemand-anderes@example.invalid'] + assert.equal((await status()).body.verfuegbar, false, 'Pilot: nur gelistete Adressen') + + assert.equal((await status('pt3')).status, 403) + assert.equal((await status('gibtsnicht')).status, 403) + assert.equal((await send('GET', '/api/shop/status', { cookie })).status, 400) + assert.equal((await send('GET', '/api/shop/status?projectId=pt1')).status, 401) +}) + +test('POST /anfrage: Schalter aus => 409, nichts angelegt', async () => { + await leereAlles() + config.shop.mode = 'off' + const res = await stelle() + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Der Webshop ist noch nicht verfügbar.') + assert.equal(res.body.code, 'nicht_verfuegbar') + config.shop.mode = 'pilot' + config.shop.pilotEmails = [] + assert.equal((await stelle()).status, 409) + assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0) + assert.equal(t21().length, 0) +}) + +test('POST /anfrage: fremdes oder archiviertes Projekt, ohne Projekt, ungueltige Eingabe => nichts angelegt, keine Mail', async () => { + await leereAlles() + assert.equal((await stelle({ projectId: 'pt3' })).status, 403) + assert.equal((await stelle({ projectId: 'unbekannt' })).status, 403) + assert.equal((await stelle({ projectId: 'pt4' })).status, 409, 'archiviert') + const ohne = await post('/api/shop/anfrage', { anfrage: GUELTIG }, { cookie }) + assert.equal(ohne.status, 400) + assert.equal(ohne.body.error, 'Bitte wähle ein Projekt.') + const schlecht = await stelle({ anfrage: { ...GUELTIG, beschreibung: 'kurz', unternehmer: false } }) + assert.equal(schlecht.status, 400) + assert.ok(schlecht.body.fehler.beschreibung && schlecht.body.fehler.unternehmer) + assert.equal(schlecht.body.error, Object.values(schlecht.body.fehler)[0]) + assert.equal((await stelle({ anfrage: 'text' })).status, 400) + assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0) + assert.equal(t21().length, 0) + assert.equal((await post('/api/shop/anfrage', { projectId: 'pt1', anfrage: GUELTIG })).status, 401) +}) + +test('POST /anfrage: speichert, Status offen, shopConfig-ID = portalFeatures-ID, T21 ans Team', async () => { + await leereAlles() + const res = await stelle() + assert.equal(res.status, 200) + assert.equal(res.body.anfrage.status, 'offen') + assert.deepEqual(res.body.anfrage.anfrage, { ...GUELTIG }) + assert.match(res.body.anfrage.at, /^\d{4}-\d\d-\d\dT/) + assert.equal(res.body.anfrage.begruendung, '') + + const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop') + assert.equal(feature.state, 'off', 'Funktion bleibt off, die Anfrage ist keine Bestellung') + const shop = aw.docs('shopConfig').get(feature.$id) + assert.ok(shop, 'shopConfig mit der ID des portalFeatures-Dokuments') + assert.deepEqual([shop.customerId, shop.projectId, shop.siteKey, shop.anfrageStatus, shop.version], ['c1', 'pt1', feature.siteKey, 'offen', 1]) + assert.deepEqual(JSON.parse(shop.anfrage), GUELTIG) + assert.equal(aw.docs('shopConfig').size, 1) + + // GET zeigt dasselbe + const stand = await status() + assert.deepEqual([stand.body.verfuegbar, stand.body.state, stand.body.anfrage.status], [true, 'off', 'offen']) + assert.deepEqual(stand.body.anfrage.anfrage, GUELTIG) + + // T21 + assert.equal(t21().length, 1) + const mail = t21()[0] + assert.equal(mail.subject, '[Webshop] Neue Anfrage: Kunde Eins') + assert.ok(mail.to.length > 0 && mail.to.every((x) => config.chatNotify.emails.includes(x.address)), 'nur ans Team') + for (const teil of ['k@example.invalid', 'Töpferei', 'Kunst', 'bis 20', 'Versand', 'Handgemachte Keramik', 'vor Weihnachten']) assert.ok(mail.text.includes(teil), `T21 nennt ${teil}\n${mail.text}`) + assert.ok(!/Abholung/.test(mail.text.split('Lieferung')[1].split('\n')[0]), 'Lieferung: nur Versand') + assert.ok(mail.html.includes(`${config.womsBaseUrl}/customers/c1?tab=funktionen`), 'Link zum Kunden im Ticketsystem') +}) + +test('POST /anfrage: Lieferung beides; ohne Nachricht steht ein Strich; Rohtext wird maskiert', async () => { + await leereAlles() + const res = await stelle({ anfrage: { ...GUELTIG, versand: true, abholung: true, nachricht: undefined, beschreibung: 'Tassen <b>fett</b> & mehr' } }) + assert.equal(res.status, 200) + const mail = t21()[0] + assert.ok(mail.text.includes('Versand und Abholung')) + assert.ok(mail.html.includes('Tassen <b>fett</b> & mehr')) + assert.ok(!mail.html.includes('<b>fett</b>')) + assert.match(mail.text, /Nachricht:\s*–/) +}) + +test('zweite Anfrage bei offener => 409, keine zweite Mail; zurueckziehen, dann wieder stellen (Version zaehlt hoch)', async () => { + await leereAlles() + assert.equal((await stelle()).status, 200) + const doppelt = await stelle() + assert.equal(doppelt.status, 409) + assert.equal(doppelt.body.code, 'anfrage_laeuft') + assert.equal(t21().length, 1) + + const zu = await zurueck() + assert.equal(zu.status, 200) + assert.equal(zu.body.anfrage.status, 'zurueckgezogen') + assert.equal(shopDoc(), null, 'ID ist die des portalFeatures-Dokuments, nicht die Projekt-ID') + const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop') + assert.equal(aw.docs('shopConfig').get(feature.$id).version, 2) + const nochmal = await zurueck() + assert.equal(nochmal.status, 409) + assert.equal(nochmal.body.code, 'nicht_offen') + assert.equal(nochmal.body.error, 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.') + + const neu = await stelle({ anfrage: { ...GUELTIG, warenart: 'kleidung' } }) + assert.equal(neu.status, 200) + assert.equal(neu.body.anfrage.status, 'offen') + assert.equal(neu.body.anfrage.anfrage.warenart, 'kleidung') + assert.equal(aw.docs('shopConfig').get(feature.$id).version, 3) + assert.equal(aw.docs('shopConfig').size, 1, 'immer dasselbe Dokument') + assert.equal(t21().length, 2) +}) + +test('zurueckziehen ohne Anfrage, bei fremdem Projekt, bei ausgeschaltetem Schalter', async () => { + await leereAlles() + assert.equal((await zurueck()).status, 409, 'es gibt keine Anfrage') + assert.equal((await zurueck('pt3')).status, 403) + assert.equal((await post('/api/shop/anfrage/zurueckziehen', {}, { cookie })).status, 400) + assert.equal(aw.docs('portalFeatures').size, 0, 'zurueckziehen legt nichts an') + config.shop.mode = 'off' + const aus = await zurueck() + assert.equal(aus.status, 409) + assert.equal(aus.body.code, 'nicht_verfuegbar') +}) + +test('abgelehnt erlaubt eine neue Anfrage und leert die Entscheidung; angenommen und andere Zustaende nicht', async () => { + await leereAlles() + assert.equal((await stelle()).status, 200) + const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop') + const id = feature.$id + const setze = (daten) => updateDocument(config.collections.shopConfig, id, daten) + + await setze({ anfrageStatus: 'abgelehnt', anfrageEntschiedenAt: new Date().toISOString(), anfrageEntschiedenVon: 'Andrej <andrej@webklar.com>', anfrageBegruendung: 'Alkohol ist ausgeschlossen.' }) + const abgelehnt = await status() + assert.equal(abgelehnt.body.anfrage.status, 'abgelehnt') + assert.equal(abgelehnt.body.anfrage.begruendung, 'Alkohol ist ausgeschlossen.') + assert.match(abgelehnt.body.anfrage.entschiedenAt, /^\d{4}-\d\d-\d\dT/) + assert.equal((await zurueck()).status, 409, 'abgelehnt laesst sich nicht zurueckziehen') + const neu = await stelle() + assert.equal(neu.status, 200) + assert.equal(neu.body.anfrage.begruendung, '') + assert.equal(neu.body.anfrage.entschiedenAt, null) + assert.equal(aw.docs('shopConfig').get(id).anfrageEntschiedenVon, '') + + await setze({ anfrageStatus: 'angenommen' }) + const angenommen = await stelle() + assert.equal(angenommen.status, 409) + assert.equal(angenommen.body.code, 'anfrage_laeuft') + assert.equal((await zurueck()).status, 409) + + // Funktion nicht mehr off (bestellt): keine neue Anfrage, auch wenn der Status es erlauben wuerde + await setze({ anfrageStatus: 'abgelehnt' }) + await updateDocument(config.collections.portalFeatures, id, { state: 'ordered' }) + const bestellt = await stelle() + assert.equal(bestellt.status, 409) + assert.equal(bestellt.body.code, 'vorhanden') + assert.equal(bestellt.body.error, 'Für dieses Projekt gibt es schon einen Webshop.') + assert.equal((await status()).body.state, 'ordered') +}) + +test('Mailfehler: Anfrage ist trotzdem gespeichert (200), Fehler nur im Log', async () => { + await leereAlles() + mails.versandFehler = true + const warnungen = [] + const warn = console.warn + console.warn = (...a) => warnungen.push(a.join(' ')) + const error = console.error + console.error = () => {} + try { + const res = await stelle() + assert.equal(res.status, 200) + assert.equal(res.body.anfrage.status, 'offen') + } finally { + console.warn = warn + console.error = error + } + assert.equal(t21().length, 0) + assert.ok(warnungen.some((w) => w.includes('T21')), 'Log nennt T21') + assert.ok(!warnungen.some((w) => w.includes('k@example.invalid') || w.includes('Handgemachte')), 'keine Kundendaten im Log') + assert.equal([...aw.docs('shopConfig').values()][0].anfrageStatus, 'offen') +}) + +test('gleichzeitige Anfragen: genau eine gewinnt, eine Mail, ein shopConfig', async () => { + await leereAlles() + const antworten = await Promise.all([stelle(), stelle(), stelle()]) + assert.deepEqual(antworten.map((a) => a.status).sort(), [200, 409, 409]) + assert.equal(aw.docs('shopConfig').size, 1) + assert.equal(t21().length, 1) +}) + +test('ensureShopConfig: legt an, gleichzeitige Aufrufe teilen sich das Dokument (409 -> lesen)', async () => { + await leereAlles() + const feature = await sf.ensureFeatureDoc('c1', 'pt2', 'shop') + const [a, b, c] = await Promise.all([anfrage.ensureShopConfig(feature), anfrage.ensureShopConfig(feature), anfrage.ensureShopConfig(feature)]) + assert.equal(a.$id, feature.$id) + assert.deepEqual([b.$id, c.$id], [feature.$id, feature.$id]) + assert.equal(aw.docs('shopConfig').size, 1) + const doc = aw.docs('shopConfig').get(feature.$id) + assert.deepEqual([doc.customerId, doc.projectId, doc.siteKey, doc.anfrageStatus, doc.version], ['c1', 'pt2', feature.siteKey, '', 0]) + assert.equal(anfrage.anfrageAnsicht(doc), null, 'ohne Anfrage keine Ansicht') + // vorhandenes Dokument bleibt unveraendert + await updateDocument(config.collections.shopConfig, feature.$id, { version: 7 }) + assert.equal((await anfrage.ensureShopConfig(feature)).version, 7) +}) + +test('anfrageAnsicht: unlesbares JSON gibt anfrage null', () => { + assert.deepEqual(anfrage.anfrageAnsicht({ anfrageStatus: 'offen', anfrage: '{kaputt', anfrageAt: '2026-10-05T10:00:00.000+00:00' }), { + status: 'offen', + anfrage: null, + at: '2026-10-05T10:00:00.000+00:00', + entschiedenAt: null, + begruendung: '', + }) + assert.equal(anfrage.anfrageAnsicht(null), null) +}) + +test('anfrageOffenePunkte (Haken bestellpruefung): nur angenommen gibt []', async () => { + await leereAlles() + const punkte = () => featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }) + assert.deepEqual(await punkte(), [anfrage.ANFRAGE_OFFEN_TEXT], 'ohne Dokumente') + assert.equal((await stelle()).status, 200) + assert.deepEqual(await punkte(), [anfrage.ANFRAGE_OFFEN_TEXT], 'offen') + const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop') + await updateDocument(config.collections.shopConfig, feature.$id, { anfrageStatus: 'angenommen' }) + assert.deepEqual(await punkte(), []) + assert.deepEqual(await anfrage.anfrageOffenePunkte({ customerId: 'c2', projectId: 'pt1' }), [anfrage.ANFRAGE_OFFEN_TEXT], 'fremder Kunde') +}) + +test('Rate-Limit: 20 Schreibaufrufe je Stunde und Kunde, dann 429; GET zaehlt nicht; anderer Kunde frei', async () => { + await leereAlles() + for (let i = 0; i < 20; i += 1) assert.equal((await stelle({ projectId: 'gibtsnicht' })).status, 403, `Aufruf ${i + 1}`) + const zuViele = await stelle({ projectId: 'gibtsnicht' }) + assert.equal(zuViele.status, 429) + assert.match(zuViele.body.error, /Zu viele/) + assert.equal((await zurueck()).status, 429, 'zurueckziehen zaehlt mit') + assert.equal((await status()).status, 200) + assert.equal((await stelle({ projectId: 'pt3' }, { cookie: customerCookie('c2') })).status, 409, 'c2: nicht im Pilot, aber nicht begrenzt') +}) + +// Kundenansicht: die Schreib-Routen stehen nicht auf der Erlaubnisliste des Waechters; der Router sperrt zusaetzlich selbst +test('Kundenansicht: POSTs gesperrt (Waechter view_blocked), GET /status erlaubt', async () => { + await leereAlles() + const kv = { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } } + const a = await stelle({}, kv) + assert.equal(a.status, 403) + assert.equal(a.body.code, 'view_blocked') + const z = await zurueck('pt1', kv) + assert.equal(z.status, 403) + assert.equal(z.body.code, 'view_blocked') + const lesen = await status('pt1', kv) + assert.equal(lesen.status, 200) + assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0) + assert.equal(t21().length, 0) +}) + +test('Kundenansicht: der Router sperrt selbst, auch wenn der Waechter fehlt (403 mit eigenem Text)', async () => { + await leereAlles() + const express = (await import('express')).default + const { sessionMiddleware } = await import('../server/middleware/session.js') + const roh = express() + roh.use(sessionMiddleware()) + roh.use(express.json()) + roh.use('/api/shop', shopRoutes) + const server = await new Promise((resolve) => { + const s = roh.listen(0, '127.0.0.1', () => resolve(s)) + }) + try { + const url = `http://127.0.0.1:${server.address().port}` + for (const [pfad, body] of [['/api/shop/anfrage', { projectId: 'pt1', anfrage: GUELTIG }], ['/api/shop/anfrage/zurueckziehen', { projectId: 'pt1' }]]) { + for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) { + const res = await fetch(url + pfad, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify(body) }) + assert.equal(res.status, 403, pfad) + assert.equal((await res.json()).error, 'In der Kundenansicht kann das nicht geändert werden.') + } + } + } finally { + await new Promise((resolve) => server.close(resolve)) + } + assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0) +}) + +test('normaler Kunde im Pilot wird vom Waechter nicht beruehrt (Gegenprobe)', async () => { + await leereAlles() + const res = await stelle() + assert.equal(res.status, 200) + assert.equal((await get('/api/shop/status?projectId=pt1', { cookie })).anfrage.status, 'offen') +}) From 1febdfaf5384ceef3b4d8633d806196ef6ca2b2a Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Mon, 5 Oct 2026 08:41:57 +0000 Subject: [PATCH 043/105] Webshop C4: Warenkorb-Berechnung (preise.js) mit Tests Reine Funktion berechne(): Positionen zusammenfassen, Bestand und Reservierung, Lieferart und Land, Mindestbestellwert, Versandkosten, enthaltene USt je Satz (Versand anteilig) und Kleinunternehmer-Hinweis. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- server/services/shop/preise.js | 202 ++++++++++++++++++ test/shopPreise.test.js | 379 +++++++++++++++++++++++++++++++++ 2 files changed, 581 insertions(+) create mode 100644 server/services/shop/preise.js create mode 100644 test/shopPreise.test.js diff --git a/server/services/shop/preise.js b/server/services/shop/preise.js new file mode 100644 index 0000000..67c126c --- /dev/null +++ b/server/services/shop/preise.js @@ -0,0 +1,202 @@ +import { euro } from './geld.js' + +/** + * Warenkorb-Berechnung fuer den Shop (Plan 2026-10-05-webshop.md, Task C4). Rein und deterministisch: kein I/O, kein Datum. + * Geld ist immer ganze Cent; Steuer und Versandanteile werden nur mit ganzen Zahlen gerechnet. + * Fehlertexte sind fuer Kaeufer geschrieben (Sie-Form). + */ + +const MAX_POSITIONEN = 50 +const MAX_MENGE = 99 +const KLEINUNTERNEHMER_HINWEIS = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const fehler = (code, text, produktId) => ({ ok: false, fehler: produktId === undefined ? { code, text } : { code, text, produktId } }) + +/** Enthaltene USt aus Brutto, kaufmaennisch (halb auf) gerundet: brutto * satz / (100 + satz), nur mit ganzen Zahlen. */ +function enthalteneSteuer(bruttoCent, satz) { + if (satz === 0 || bruttoCent === 0) return 0 + const teiler = 100 + satz + return Math.floor((2 * bruttoCent * satz + teiler) / (2 * teiler)) +} + +/** Gesamtbetrag der Positionen je Steuersatz (Brutto), als Map satz => Cent in Einfuegereihenfolge. */ +function nachSteuersatz(zeilen, wert) { + const summen = new Map() + for (const z of zeilen) summen.set(z.steuersatz, (summen.get(z.steuersatz) ?? 0) + wert(z)) + return summen +} + +/** + * @param {object} p + * @param {Map<string, object>} p.produkte id => { id, aktiv, ...normalisiertes Produkt } + * @param {Array<{produktId: string, varianteId?: string, menge: number}>} p.positionen + * @param {'versand'|'abholung'} p.lieferart + * @param {string} [p.land] 'DE' | 'AT' (nur bei Versand) + * @param {object} p.einstellungen normalisierte Shop-Einstellungen + * @param {Map<string, number>} [p.reserviert] Schluessel produktId bzw. `${produktId}:${varianteId}` => reservierte Menge + */ +export function berechne({ produkte, positionen, lieferart, land, einstellungen, reserviert = new Map() } = {}) { + const e = einstellungen ?? {} + + // 1. Eingabe pruefen und gleiche Artikel (Produkt + Variante) zusammenfassen + if (!Array.isArray(positionen) || positionen.length === 0) return fehler('leer', 'Ihr Warenkorb ist leer.') + const zusammen = new Map() + for (const pos of positionen) { + const produktId = istObjekt(pos) && typeof pos.produktId === 'string' ? pos.produktId : '' + if (produktId === '') return fehler('nicht_verfuegbar', 'Ein Artikel in Ihrem Warenkorb ist leider nicht mehr verfügbar.') + const menge = pos.menge + if (!Number.isInteger(menge) || menge < 1 || menge > MAX_MENGE) { + return fehler('menge', `Bitte wählen Sie eine Menge von 1 bis ${MAX_MENGE}.`, produktId) + } + const varianteId = typeof pos.varianteId === 'string' && pos.varianteId !== '' ? pos.varianteId : null + const schluessel = `${produktId}\u0000${varianteId ?? ''}` + const vorhanden = zusammen.get(schluessel) + if (vorhanden) vorhanden.menge += menge + else zusammen.set(schluessel, { produktId, varianteId, menge }) + } + if (zusammen.size > MAX_POSITIONEN) { + return fehler('zu_viele', `Ihr Warenkorb enthält zu viele verschiedene Artikel. Bitte bestellen Sie höchstens ${MAX_POSITIONEN} Positionen auf einmal.`) + } + + // 2. Jede Position gegen Produkt, Variante und Bestand pruefen + const zeilen = [] + for (const { produktId, varianteId, menge } of zusammen.values()) { + const produkt = produkte instanceof Map ? produkte.get(produktId) : undefined + if (!istObjekt(produkt) || produkt.aktiv !== true) { + const name = istObjekt(produkt) && typeof produkt.name === 'string' ? `„${produkt.name}“` : 'Ein Artikel in Ihrem Warenkorb' + return fehler('nicht_verfuegbar', `${name} ist leider nicht mehr verfügbar.`, produktId) + } + const hatVarianten = istObjekt(produkt.varianten) && Array.isArray(produkt.varianten.werte) && produkt.varianten.werte.length > 0 + let wert = null + if (hatVarianten) { + wert = varianteId === null ? null : (produkt.varianten.werte.find((w) => w.id === varianteId) ?? null) + if (wert === null) { + const text = varianteId === null + ? `Bitte wählen Sie für „${produkt.name}“ eine Variante (${produkt.varianten.name}) aus.` + : `Die gewählte Variante von „${produkt.name}“ gibt es leider nicht (mehr). Bitte wählen Sie eine andere (${produkt.varianten.name}).` + return fehler('variante', text, produktId) + } + } else if (varianteId !== null) { + return fehler('variante', `Für „${produkt.name}“ gibt es keine Varianten. Bitte entfernen Sie die Auswahl.`, produktId) + } + if (menge > MAX_MENGE) return fehler('menge', `Bitte wählen Sie von „${produkt.name}“ höchstens ${MAX_MENGE} Stück.`, produktId) + + const einzelCent = wert ? wert.preisCent : produkt.preisCent + if (!Number.isSafeInteger(einzelCent) || einzelCent < 0) { + return fehler('nicht_verfuegbar', `„${produkt.name}“ ist leider nicht mehr verfügbar.`, produktId) + } + const varianteText = wert ? `${produkt.varianten.name}: ${wert.name}` : '' + const anzeige = varianteText ? `${produkt.name} (${varianteText})` : produkt.name + + const bestand = wert ? wert.bestand : produkt.bestand + if (bestand !== null && bestand !== undefined) { + const reservierungKey = wert ? `${produktId}:${varianteId}` : produktId + const reservierteMenge = reserviert instanceof Map ? (reserviert.get(reservierungKey) ?? 0) : 0 + const frei = Math.max(0, bestand - reservierteMenge) + if (frei < menge) { + const text = frei === 0 + ? `„${anzeige}“ ist leider ausverkauft.` + : `Von „${anzeige}“ sind leider nur noch ${frei} Stück verfügbar. Bitte verringern Sie die Menge.` + return fehler('ausverkauft', text, produktId) + } + } + + zeilen.push({ + produktId, + varianteId: wert ? varianteId : null, + name: produkt.name, + varianteText, + menge, + einzelCent, + summeCent: einzelCent * menge, + steuersatz: produkt.steuersatz ?? 0, + widerruf: produkt.widerruf ?? 'gesetzlich', + nurAbholung: produkt.lieferart === 'abholung', + anzeige, + }) + } + + // 3. Lieferart + const versand = e.versand ?? {} + const abholung = e.abholung ?? {} + if (lieferart === 'versand') { + if (versand.aktiv !== true) return fehler('lieferart', 'Versand ist in diesem Shop leider nicht möglich.') + if (typeof land !== 'string' || !Array.isArray(versand.laender) || !versand.laender.includes(land)) { + return fehler('land', 'Wir liefern leider nicht in das gewählte Land.') + } + const nur = zeilen.find((z) => z.nurAbholung) + if (nur) return fehler('nur_abholung', `„${nur.anzeige}“ kann nicht versendet werden, sondern nur abgeholt werden.`, nur.produktId) + } else if (lieferart === 'abholung') { + if (abholung.aktiv !== true) return fehler('lieferart', 'Abholung ist in diesem Shop leider nicht möglich.') + } else { + return fehler('lieferart', 'Bitte wählen Sie Versand oder Abholung.') + } + + // 4. Summen und Mindestbestellwert + const zwischensummeCent = zeilen.reduce((s, z) => s + z.summeCent, 0) + if (Number.isInteger(e.mindestbestellCent) && zwischensummeCent < e.mindestbestellCent) { + return fehler('mindestbestellwert', `Der Mindestbestellwert beträgt ${euro(e.mindestbestellCent)}. Bitte legen Sie weitere Artikel in den Warenkorb.`) + } + + let versandCent = 0 + if (lieferart === 'versand') { + const kosten = versand.kostenCent && Object.hasOwn(versand.kostenCent, land) ? versand.kostenCent[land] : 0 + const kostenlos = Number.isInteger(versand.freiAbCent) && zwischensummeCent >= versand.freiAbCent + versandCent = kostenlos || !Number.isInteger(kosten) || kosten < 0 ? 0 : kosten + } + const gesamtCent = zwischensummeCent + versandCent + + // 5. Steuer: je Satz ueber die Summe der Positionen, Versand anteilig nach Bruttowert (Rest auf den hoechsten Satz) + let steuer = [] + let steuerHinweis + if (e.kleinunternehmer === true) { + steuerHinweis = KLEINUNTERNEHMER_HINWEIS + } else { + const brutto = nachSteuersatz(zeilen, (z) => z.summeCent) + const saetze = [...brutto.keys()].sort((a, b) => b - a) + if (versandCent > 0 && zwischensummeCent > 0) { + let verteilt = 0 + for (const satz of saetze) { + const anteil = Math.floor((versandCent * brutto.get(satz)) / zwischensummeCent) + verteilt += anteil + brutto.set(satz, brutto.get(satz) + anteil) + } + brutto.set(saetze[0], brutto.get(saetze[0]) + (versandCent - verteilt)) + } + steuer = saetze + .map((satz) => ({ satz, betragCent: enthalteneSteuer(brutto.get(satz), satz) })) + .filter((s) => s.betragCent > 0) + .map((s) => ({ ...s, text: `inkl. ${s.satz} % USt: ${euro(s.betragCent)}` })) + steuerHinweis = steuer.map((s) => s.text).join('; ') + } + + return { + ok: true, + positionen: zeilen.map((z) => ({ + produktId: z.produktId, + varianteId: z.varianteId, + name: z.name, + varianteText: z.varianteText, + menge: z.menge, + einzelCent: z.einzelCent, + summeCent: z.summeCent, + steuersatz: z.steuersatz, + widerruf: z.widerruf, + einzelText: euro(z.einzelCent), + summeText: euro(z.summeCent), + })), + zwischensummeCent, + versandCent, + gesamtCent, + steuer, + texte: { + zwischensumme: euro(zwischensummeCent), + versand: lieferart === 'abholung' ? 'Abholung – kostenlos' : versandCent === 0 ? 'kostenlos' : euro(versandCent), + gesamt: euro(gesamtCent), + steuerHinweis, + }, + lieferart, + land: lieferart === 'versand' ? land : null, + } +} diff --git a/test/shopPreise.test.js b/test/shopPreise.test.js new file mode 100644 index 0000000..fd6e4ff --- /dev/null +++ b/test/shopPreise.test.js @@ -0,0 +1,379 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { berechne } from '../server/services/shop/preise.js' +import { euro } from '../server/services/shop/geld.js' + +const basis = { + aktiv: true, kurz: '', beschreibung: '', steuersatz: 19, grundpreis: null, bestand: null, varianten: null, + bilder: [], lieferart: 'beides', widerruf: 'gesetzlich', +} +const produkt = (id, extra = {}) => ({ ...basis, id, name: `Artikel ${id}`, preisCent: 1000, ...extra }) +const karte = (...liste) => new Map(liste.map((p) => [p.id, p])) +const shirt = produkt('shirt', { + name: 'T-Shirt', + preisCent: null, + varianten: { + name: 'Größe', + werte: [ + { id: 'sizeaaaa', name: 'M', preisCent: 2000, bestand: 5 }, + { id: 'sizebbbb', name: 'L', preisCent: 2200, bestand: null }, + ], + }, +}) +const einstellungen = (extra = {}) => ({ + kleinunternehmer: false, + versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: null }, + abholung: { aktiv: true, adresse: 'Weg 1', hinweis: '' }, + mindestbestellCent: null, + ...extra, +}) +const rechne = (args) => berechne({ lieferart: 'versand', land: 'DE', einstellungen: einstellungen(), ...args }) +const eine = (p, menge = 1, varianteId) => [{ produktId: p.id, menge, ...(varianteId ? { varianteId } : {}) }] + +test('einfacher Warenkorb: Summen, Texte, Versand und Steuer', () => { + const a = produkt('a', { preisCent: 1190 }) + const r = rechne({ produkte: karte(a), positionen: eine(a, 2) }) + assert.equal(r.ok, true) + assert.equal(r.zwischensummeCent, 2380) + assert.equal(r.versandCent, 490) + assert.equal(r.gesamtCent, 2870) + assert.deepEqual(r.positionen[0], { + produktId: 'a', varianteId: null, name: 'Artikel a', varianteText: '', menge: 2, einzelCent: 1190, summeCent: 2380, + steuersatz: 19, widerruf: 'gesetzlich', einzelText: euro(1190), summeText: euro(2380), + }) + assert.equal(r.texte.zwischensumme, euro(2380)) + assert.equal(r.texte.versand, euro(490)) + assert.equal(r.texte.gesamt, euro(2870)) + // 2870 * 19 / 119 = 458,23 => 458 + assert.deepEqual(r.steuer, [{ satz: 19, betragCent: 458, text: `inkl. 19 % USt: ${euro(458)}` }]) + assert.equal(r.texte.steuerHinweis, `inkl. 19 % USt: ${euro(458)}`) + assert.equal(r.lieferart, 'versand') + assert.equal(r.land, 'DE') +}) + +test('gemischte Steuersaetze: Text mit absteigenden Saetzen, 0 % liefert keinen Eintrag', () => { + const a = produkt('a', { preisCent: 2000, steuersatz: 19 }) + const b = produkt('b', { preisCent: 700, steuersatz: 7 }) + const c = produkt('c', { preisCent: 500, steuersatz: 0 }) + const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: 1 } }) + const r = berechne({ produkte: karte(a, b, c), positionen: [...eine(b), ...eine(c), ...eine(a)], lieferart: 'versand', land: 'DE', einstellungen: e }) + assert.equal(r.versandCent, 0) + assert.equal(r.texte.versand, 'kostenlos') + assert.deepEqual(r.steuer.map((s) => [s.satz, s.betragCent]), [[19, 319], [7, 46]]) + assert.equal(r.texte.steuerHinweis, `inkl. 19 % USt: ${euro(319)}; inkl. 7 % USt: ${euro(46)}`) + assert.deepEqual(r.positionen.map((p) => p.produktId), ['b', 'c', 'a'], 'Reihenfolge der Eingabe bleibt') +}) + +test('Steuer wird je Satz ueber die Summe gerundet, nicht je Position', () => { + // Einzeln: 3 * round(0,119 * ... ) waere 3 * 0 ; ueber die Summe: 3 * 10 = 30 Cent brutto => 30*19/119 = 4,79 => 5 + const a = produkt('a', { preisCent: 10 }) + const e = einstellungen({ abholung: { aktiv: true, adresse: 'x', hinweis: '' } }) + const r = berechne({ produkte: karte(a), positionen: eine(a, 3), lieferart: 'abholung', einstellungen: e }) + assert.equal(r.steuer[0].betragCent, 5) +}) + +test('Rundung halb auf an der Grenze', () => { + // brutto 107 bei 7 % => 107*7/107 = 7 exakt; brutto 50 bei 7 % => 3,271 => 3; brutto 1 bei 19 % => 0,159 => 0 (kein Eintrag) + const abh = (p, n = 1) => berechne({ produkte: karte(p), positionen: eine(p, n), lieferart: 'abholung', einstellungen: einstellungen() }) + assert.equal(abh(produkt('a', { preisCent: 107, steuersatz: 7 })).steuer[0].betragCent, 7) + assert.equal(abh(produkt('a', { preisCent: 50, steuersatz: 7 })).steuer[0].betragCent, 3) + assert.deepEqual(abh(produkt('a', { preisCent: 1, steuersatz: 19 })).steuer, []) + assert.equal(abh(produkt('a', { preisCent: 1, steuersatz: 19 })).texte.steuerHinweis, '') + // brutto 5 bei 19 % => 0,798 => 1 + assert.equal(abh(produkt('a', { preisCent: 5, steuersatz: 19 })).steuer[0].betragCent, 1) + // brutto 30 bei 7 % => 1,962 => 2 + assert.equal(abh(produkt('a', { preisCent: 30, steuersatz: 7 })).steuer[0].betragCent, 2) +}) + +test('Versand wird anteilig nach Bruttowert auf die Saetze verteilt, Rest-Cent auf den hoechsten Satz', () => { + const a = produkt('a', { preisCent: 1000, steuersatz: 19 }) + const b = produkt('b', { preisCent: 1000, steuersatz: 7 }) + const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 501 }, freiAbCent: null } }) + const r = berechne({ produkte: karte(b, a), positionen: [...eine(b), ...eine(a)], lieferart: 'versand', land: 'DE', einstellungen: e }) + // Anteile: floor(501*1000/2000) = 250 je Satz, Rest 1 => 19 %: 1251, 7 %: 1250 + assert.equal(r.gesamtCent, 2501) + assert.deepEqual(r.steuer.map((s) => [s.satz, s.betragCent]), [ + [19, Math.floor((2 * 1251 * 19 + 119) / (2 * 119))], + [7, Math.floor((2 * 1250 * 7 + 107) / (2 * 107))], + ]) + assert.equal(r.steuer[0].betragCent, 200) + assert.equal(r.steuer[1].betragCent, 82) +}) + +test('Versand bei nur 0-%-Artikeln erzeugt keine Steuer', () => { + const a = produkt('a', { preisCent: 1000, steuersatz: 0 }) + const r = rechne({ produkte: karte(a), positionen: eine(a) }) + assert.deepEqual(r.steuer, []) + assert.equal(r.gesamtCent, 1490) +}) + +test('Kleinunternehmer: keine Steuer, Hinweis nach Paragraph 19', () => { + const a = produkt('a', { preisCent: 1000, steuersatz: 0 }) + const r = rechne({ produkte: karte(a), positionen: eine(a), einstellungen: einstellungen({ kleinunternehmer: true }) }) + assert.deepEqual(r.steuer, []) + assert.equal(r.texte.steuerHinweis, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + // auch wenn ein Artikel (fehlerhaft) einen Satz traegt + const b = produkt('b', { preisCent: 1000, steuersatz: 19 }) + const r2 = rechne({ produkte: karte(b), positionen: eine(b), einstellungen: einstellungen({ kleinunternehmer: true }) }) + assert.deepEqual(r2.steuer, []) +}) + +test('Versandkosten: Oesterreich, Schwelle gleich ist kostenlos, knapp darunter nicht', () => { + const e = einstellungen({ versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 } }) + const p = (cent) => produkt('a', { preisCent: cent }) + const lauf = (cent, land) => { + const a = p(cent) + return berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'versand', land, einstellungen: e }) + } + assert.equal(lauf(2000, 'AT').versandCent, 990) + assert.equal(lauf(2000, 'AT').texte.versand, euro(990)) + assert.equal(lauf(4999, 'DE').versandCent, 490) + assert.equal(lauf(5000, 'DE').versandCent, 0) + assert.equal(lauf(5000, 'AT').versandCent, 0) + assert.equal(lauf(5001, 'DE').texte.versand, 'kostenlos') + assert.equal(lauf(5000, 'DE').gesamtCent, 5000) +}) + +test('freiAbCent 0 heisst immer kostenlos', () => { + const a = produkt('a') + const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: 0 } }) + assert.equal(berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'versand', land: 'DE', einstellungen: e }).versandCent, 0) +}) + +test('Abholung: keine Versandkosten, eigener Text, Land ignoriert', () => { + const a = produkt('a', { preisCent: 1190 }) + const r = berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: einstellungen() }) + assert.equal(r.ok, true) + assert.equal(r.versandCent, 0) + assert.equal(r.gesamtCent, 1190) + assert.equal(r.texte.versand, 'Abholung – kostenlos') + assert.equal(r.lieferart, 'abholung') +}) + +test('Abholung nur-Abholung-Artikel sind bei Abholung erlaubt', () => { + const a = produkt('a', { lieferart: 'abholung' }) + const r = berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: einstellungen() }) + assert.equal(r.ok, true) +}) + +test('Varianten: Preis und Text der Variante', () => { + const r = rechne({ produkte: karte(shirt), positionen: eine(shirt, 2, 'sizebbbb') }) + assert.equal(r.ok, true) + assert.equal(r.positionen[0].varianteId, 'sizebbbb') + assert.equal(r.positionen[0].varianteText, 'Größe: L') + assert.equal(r.positionen[0].einzelCent, 2200) + assert.equal(r.positionen[0].summeCent, 4400) +}) + +test('Fehler leer: leere, fehlende oder keine Liste', () => { + const a = produkt('a') + for (const positionen of [[], undefined, null, 'x', {}]) { + const r = rechne({ produkte: karte(a), positionen }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'leer') + assert.match(r.fehler.text, /Warenkorb/) + } +}) + +test('Fehler zu_viele: 51 verschiedene Positionen, aber 50 sind erlaubt und Duplikate zaehlen einmal', () => { + const liste = Array.from({ length: 51 }, (_, i) => produkt(`p${i}`)) + const positionen = (n) => liste.slice(0, n).map((p) => ({ produktId: p.id, menge: 1 })) + const r51 = rechne({ produkte: karte(...liste), positionen: positionen(51) }) + assert.equal(r51.ok, false) + assert.equal(r51.fehler.code, 'zu_viele') + assert.equal(rechne({ produkte: karte(...liste), positionen: positionen(50) }).ok, true) + // 51 Eintraege, aber nur 50 verschiedene + const dup = [...positionen(50), { produktId: 'p0', menge: 1 }] + const r = rechne({ produkte: karte(...liste), positionen: dup }) + assert.equal(r.ok, true) + assert.equal(r.positionen.length, 50) + assert.equal(r.positionen[0].menge, 2) +}) + +test('Fehler menge: 0, 100, negativ, Dezimal, Text, NaN, fehlend', () => { + const a = produkt('a') + for (const menge of [0, 100, -1, 1.5, '2', NaN, Infinity, undefined, null]) { + const r = rechne({ produkte: karte(a), positionen: [{ produktId: 'a', menge }] }) + assert.equal(r.ok, false, String(menge)) + assert.equal(r.fehler.code, 'menge', String(menge)) + assert.equal(r.fehler.produktId, 'a') + } + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 99) }).ok, true) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 1) }).ok, true) +}) + +test('Zusammengefasste Menge ueber 99 ist ein Mengenfehler', () => { + const a = produkt('a') + const r = rechne({ produkte: karte(a), positionen: [{ produktId: 'a', menge: 60 }, { produktId: 'a', menge: 40 }] }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'menge') + assert.equal(r.fehler.produktId, 'a') +}) + +test('Duplikate gleicher Artikel werden zusammengefasst, Varianten bleiben getrennt', () => { + const a = produkt('a', { preisCent: 300 }) + const r = rechne({ + produkte: karte(a, shirt), + positionen: [ + { produktId: 'a', menge: 1 }, + { produktId: 'shirt', varianteId: 'sizeaaaa', menge: 1 }, + { produktId: 'a', menge: 2 }, + { produktId: 'shirt', varianteId: 'sizebbbb', menge: 1 }, + { produktId: 'shirt', varianteId: 'sizeaaaa', menge: 1 }, + ], + }) + assert.equal(r.ok, true) + assert.deepEqual(r.positionen.map((p) => [p.produktId, p.varianteId, p.menge]), [ + ['a', null, 3], ['shirt', 'sizeaaaa', 2], ['shirt', 'sizebbbb', 1], + ]) + assert.equal(r.zwischensummeCent, 900 + 4000 + 2200) +}) + +test('Fehler nicht_verfuegbar: unbekannt, inaktiv, ungueltige Position', () => { + const a = produkt('a') + const aus = produkt('aus', { aktiv: false }) + for (const pos of [{ produktId: 'x', menge: 1 }, { produktId: 'aus', menge: 1 }, { produktId: '__proto__', menge: 1 }, { produktId: 'constructor', menge: 1 }]) { + const r = rechne({ produkte: karte(a, aus), positionen: [pos] }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'nicht_verfuegbar') + } + assert.equal(rechne({ produkte: karte(a), positionen: [null] }).fehler.code, 'nicht_verfuegbar') + assert.equal(rechne({ produkte: karte(a), positionen: [{ menge: 1 }] }).fehler.code, 'nicht_verfuegbar') + assert.equal(rechne({ produkte: karte(a), positionen: [{ produktId: 5, menge: 1 }] }).fehler.code, 'nicht_verfuegbar') + assert.equal(rechne({ produkte: karte(aus), positionen: eine(aus) }).fehler.produktId, 'aus') +}) + +test('Fehler variante: fehlt, unbekannt, bei Artikel ohne Varianten', () => { + const a = produkt('a') + const ohne = rechne({ produkte: karte(shirt), positionen: eine(shirt) }) + assert.equal(ohne.fehler.code, 'variante') + assert.match(ohne.fehler.text, /Größe/) + assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 1, 'gibtsnich') }).fehler.code, 'variante') + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 1, 'sizeaaaa') }).fehler.code, 'variante') + assert.equal(ohne.fehler.produktId, 'shirt') + assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 1, 'sizeaaaa') }).ok, true) +}) + +test('Bestand: unbegrenzt, genau ausreichend, zu wenig mit Restmenge, ausverkauft', () => { + const frei = produkt('frei', { bestand: null }) + assert.equal(rechne({ produkte: karte(frei), positionen: eine(frei, 99) }).ok, true) + const a = produkt('a', { bestand: 3 }) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 3) }).ok, true) + const zuwenig = rechne({ produkte: karte(a), positionen: eine(a, 4) }) + assert.equal(zuwenig.ok, false) + assert.equal(zuwenig.fehler.code, 'ausverkauft') + assert.equal(zuwenig.fehler.produktId, 'a') + assert.match(zuwenig.fehler.text, /Artikel a/) + assert.match(zuwenig.fehler.text, /nur noch 3/) + const null0 = produkt('n', { bestand: 0 }) + const aus = rechne({ produkte: karte(null0), positionen: eine(null0) }) + assert.equal(aus.fehler.code, 'ausverkauft') + assert.match(aus.fehler.text, /Artikel n/) + assert.match(aus.fehler.text, /ausverkauft/) +}) + +test('Bestand: gilt fuer die zusammengefasste Menge', () => { + const a = produkt('a', { bestand: 3 }) + const r = rechne({ produkte: karte(a), positionen: [{ produktId: 'a', menge: 2 }, { produktId: 'a', menge: 2 }] }) + assert.equal(r.fehler.code, 'ausverkauft') +}) + +test('Reservierungen verringern den Bestand (Artikel und Variante)', () => { + const a = produkt('a', { bestand: 5 }) + const res = new Map([['a', 3]]) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 2), reserviert: res }).ok, true) + const r = rechne({ produkte: karte(a), positionen: eine(a, 3), reserviert: res }) + assert.equal(r.fehler.code, 'ausverkauft') + assert.match(r.fehler.text, /nur noch 2/) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 1), reserviert: new Map([['a', 7]]) }).fehler.code, 'ausverkauft') + + const vres = new Map([['shirt:sizeaaaa', 4]]) + assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 1, 'sizeaaaa'), reserviert: vres }).ok, true) + const rv = rechne({ produkte: karte(shirt), positionen: eine(shirt, 2, 'sizeaaaa'), reserviert: vres }) + assert.equal(rv.fehler.code, 'ausverkauft') + assert.match(rv.fehler.text, /T-Shirt/) + assert.match(rv.fehler.text, /Größe: M/) + // unbegrenzte Variante bleibt unberuehrt, andere Schluessel stoeren nicht + assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 50, 'sizebbbb'), reserviert: new Map([['shirt:sizebbbb', 99], ['shirt', 99]]) }).ok, true) +}) + +test('Variantenbestand wird je Variante geprueft', () => { + const r = rechne({ produkte: karte(shirt), positionen: eine(shirt, 6, 'sizeaaaa') }) + assert.equal(r.fehler.code, 'ausverkauft') + assert.match(r.fehler.text, /nur noch 5/) +}) + +test('Fehler lieferart: unbekannt, Versand aus, Abholung aus', () => { + const a = produkt('a') + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'post' }).fehler.code, 'lieferart') + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: undefined }).fehler.code, 'lieferart') + const keinVersand = einstellungen({ versand: { aktiv: false, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } }) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), einstellungen: keinVersand }).fehler.code, 'lieferart') + const keineAbholung = einstellungen({ abholung: { aktiv: false, adresse: '', hinweis: '' } }) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: keineAbholung }).fehler.code, 'lieferart') + // Abholung geht, obwohl Versand aus ist + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: einstellungen({ versand: { aktiv: false, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } }) }).ok, true) +}) + +test('Fehler land: nicht geliefert, fehlend, ungueltig', () => { + const a = produkt('a') + const nurDe = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } }) + for (const land of ['AT', 'CH', '', undefined, null, 'de']) { + const r = rechne({ produkte: karte(a), positionen: eine(a), land, einstellungen: nurDe }) + assert.equal(r.fehler.code, 'land', String(land)) + } + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), land: 'AT' }).ok, true) +}) + +test('Fehler nur_abholung: Versand mit Nur-Abholung-Artikel', () => { + const a = produkt('a') + const abholer = produkt('abholer', { lieferart: 'abholung' }) + const r = rechne({ produkte: karte(a, abholer), positionen: [...eine(a), ...eine(abholer)] }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'nur_abholung') + assert.equal(r.fehler.produktId, 'abholer') + assert.match(r.fehler.text, /Artikel abholer/) +}) + +test('Fehler mindestbestellwert: Grenze gleich ist erlaubt, Versandkosten zaehlen nicht mit', () => { + const a = produkt('a', { preisCent: 1000 }) + const e = (cent) => einstellungen({ mindestbestellCent: cent }) + const r = rechne({ produkte: karte(a), positionen: eine(a), einstellungen: e(1001) }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'mindestbestellwert') + assert.ok(r.fehler.text.includes(euro(1001))) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), einstellungen: e(1000) }).ok, true) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), einstellungen: e(null) }).ok, true) + // gilt auch bei Abholung + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: e(1001) }).fehler.code, 'mindestbestellwert') +}) + +test('Reihenfolge der Pruefungen: Artikelfehler vor Lieferart, Lieferart vor Mindestbestellwert', () => { + const a = produkt('a', { bestand: 0 }) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'post' }).fehler.code, 'ausverkauft') + const b = produkt('b') + assert.equal(rechne({ produkte: karte(b), positionen: eine(b), lieferart: 'post', einstellungen: einstellungen({ mindestbestellCent: 99999 }) }).fehler.code, 'lieferart') +}) + +test('Eingaben werden nicht veraendert und das Ergebnis teilt keine Referenzen', () => { + const a = produkt('a', { bestand: 10 }) + const positionen = [{ produktId: 'a', menge: 2 }, { produktId: 'a', menge: 1 }] + const e = einstellungen() + const vorher = JSON.stringify({ a, positionen, e }) + const r1 = rechne({ produkte: karte(a), positionen, einstellungen: e }) + assert.equal(JSON.stringify({ a, positionen, e }), vorher) + const r2 = rechne({ produkte: karte(a), positionen, einstellungen: e }) + assert.deepEqual(r1, r2) + assert.notEqual(r1.positionen, r2.positionen) +}) + +test('Gesamtsumme ist immer ganzzahlig und gleich Zwischensumme plus Versand', () => { + const a = produkt('a', { preisCent: 333, steuersatz: 7 }) + const b = produkt('b', { preisCent: 777, steuersatz: 19 }) + const r = rechne({ produkte: karte(a, b), positionen: [{ produktId: 'a', menge: 7 }, { produktId: 'b', menge: 3 }], land: 'AT' }) + assert.ok(Number.isInteger(r.gesamtCent)) + assert.equal(r.gesamtCent, r.zwischensummeCent + r.versandCent) + assert.equal(r.zwischensummeCent, 333 * 7 + 777 * 3) + const summe = r.steuer.reduce((s, x) => s + x.betragCent, 0) + assert.ok(summe < r.gesamtCent) +}) From 608ff61d3dea4564ccaee13a505255642e03b72d Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Mon, 5 Oct 2026 08:43:37 +0000 Subject: [PATCH 044/105] Shop C6: Kontext per Projekt und siteKey, Bereitschaft und Oeffnungs-Regel Laden von shopConfig (Einstellungen, Rechtstexte als Entwurf/bestaetigt, Stripe-Status) mit sicherem Parsen, 30-s-Speicher je siteKey mit generation-Zaehler (active und disabled), bereitschaft() mit den fuenf Punkten, istGeoeffnet() und oeffentlicheRechtstexte(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- server/services/shop/kontext.js | 257 +++++++++++++++++++++++ test/shopKontext.test.js | 358 ++++++++++++++++++++++++++++++++ 2 files changed, 615 insertions(+) create mode 100644 server/services/shop/kontext.js create mode 100644 test/shopKontext.test.js diff --git a/server/services/shop/kontext.js b/server/services/shop/kontext.js new file mode 100644 index 0000000..2e24f9e --- /dev/null +++ b/server/services/shop/kontext.js @@ -0,0 +1,257 @@ +import { config } from '../../config.js' +import { getDocument, listDocuments, Query } from '../appwriteAdmin.js' +import { getFeatureDoc } from '../siteFeatures.js' +import { istVollstaendig, ladeEinstellungen, nachSpeichern } from './einstellungen.js' +import { listeProdukte, produktOeffentlich } from './produkte.js' +import { rechtstexteHash, vollstaendig } from './rechtstexte.js' + +/** + * Kontext eines Shops (Plan 2026-10-05-webshop.md, Task C6; Spezifikation 4.6) und seine Bereitschaft zum Verkaufen. + * + * ctx = { doc, shop, project } + * doc portalFeatures-Dokument (featureKey 'shop') + * shop geparstes shopConfig-Dokument (ladeShop) oder null (noch keines angelegt) + * project websiteProjects-Dokument oder null + * Ein ctx aus shopPerSiteKey ist nur zum Lesen: innerhalb von 30 Sekunden kommt dasselbe Objekt zurueck. + * + * shopConfig.rechtstexte ist ein JSON-Text { entwurf: {agb, widerrufsbelehrung, widerrufsformular, impressumUrl, datenschutzUrl}, + * bestaetigt: null | { texte: {dieselben fuenf}, hash, at, von } }. Der Entwurf gehoert dem Haendler, die Kaeufer sehen + * immer die zuletzt bestaetigte Fassung (oeffentlicheRechtstexte). Kaputt oder alt => leerer Entwurf, nichts bestaetigt. + */ + +const FUNKTION = 'shop' +const SITE_KEY_RE = /^[a-z2-7]{16}$/ +const CACHE_MS = 30 * 1000 +const CACHE_MAX = 10000 +const TEXT_FELDER = ['agb', 'widerrufsbelehrung', 'widerrufsformular', 'impressumUrl', 'datenschutzUrl'] + +const cache = new Map() +/** Wird mit jedem Leeren erhoeht: eine Abfrage, die davor begann, darf ihr Ergebnis nicht mehr merken. */ +let generation = 0 + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */ +const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found' + +async function holeOderNull(collectionId, id) { + if (typeof id !== 'string' || id === '') return null + try { + return await getDocument(collectionId, id) + } catch (err) { + if (dokumentFehlt(err)) return null + throw err + } +} + +const leereTexte = () => Object.fromEntries(TEXT_FELDER.map((k) => [k, ''])) +const textFelder = (roh) => Object.fromEntries(TEXT_FELDER.map((k) => [k, istObjekt(roh) && Object.hasOwn(roh, k) && typeof roh[k] === 'string' ? roh[k] : ''])) + +/** + * Rechtstexte aus einem shopConfig-Dokument: { entwurf, bestaetigt }. Fehlt der JSON-Text, ist er kaputt oder hat er eine + * andere Form, ist das Ergebnis { entwurf: (leer), bestaetigt: null }. bestaetigt braucht texte (Objekt) und einen hash (Text). + */ +export function ladeRechtstexte(shopDoc) { + let roh = null + try { + roh = typeof shopDoc?.rechtstexte === 'string' && shopDoc.rechtstexte !== '' ? JSON.parse(shopDoc.rechtstexte) : null + } catch { + roh = null + } + if (!istObjekt(roh)) return { entwurf: leereTexte(), bestaetigt: null } + const b = Object.hasOwn(roh, 'bestaetigt') ? roh.bestaetigt : null + const bestaetigt = + istObjekt(b) && istObjekt(b.texte) && typeof b.hash === 'string' && b.hash !== '' + ? { texte: textFelder(b.texte), hash: b.hash, at: typeof b.at === 'string' ? b.at : '', von: typeof b.von === 'string' ? b.von : '' } + : null + return { entwurf: textFelder(Object.hasOwn(roh, 'entwurf') ? roh.entwurf : null), bestaetigt } +} + +function ladeStripeStatus(shopDoc) { + try { + const s = typeof shopDoc?.stripeStatus === 'string' && shopDoc.stripeStatus !== '' ? JSON.parse(shopDoc.stripeStatus) : null + return istObjekt(s) ? s : null + } catch { + return null + } +} + +/** Geparster Shop aus einem shopConfig-Dokument. */ +export function ladeShop(shopDoc) { + return { + id: shopDoc.$id, + customerId: shopDoc.customerId ?? '', + projectId: shopDoc.projectId ?? '', + siteKey: shopDoc.siteKey ?? '', + einstellungen: ladeEinstellungen(shopDoc), + rechtstexte: ladeRechtstexte(shopDoc), + stripeAccountId: typeof shopDoc.stripeAccountId === 'string' ? shopDoc.stripeAccountId : '', + stripeStatus: ladeStripeStatus(shopDoc), + geoeffnet: shopDoc.geoeffnet === true, + anfrageStatus: typeof shopDoc.anfrageStatus === 'string' ? shopDoc.anfrageStatus : '', + version: Number.isInteger(shopDoc.version) && shopDoc.version > 0 ? shopDoc.version : 0, + } +} + +/** + * Kontext fuer das Portal: portalFeatures-Dokument des Projekts (Zustand egal), shopConfig dazu und das Projekt. + * doc null: keine Funktion; shop null: noch kein shopConfig-Dokument; project null: das Projekt gibt es nicht oder es gehoert + * einem anderen Kunden. + * @returns {Promise<{ doc: object|null, shop: object|null, project: object|null }>} + */ +export async function ladeShopFuerProjekt(customerId, projectId) { + const projekt = await holeOderNull(config.collections.websiteProjects, projectId) + const project = projekt && customerId && projekt.customerId === customerId ? projekt : null + const doc = project ? await getFeatureDoc(customerId, projectId, FUNKTION) : null + const shopDoc = doc ? await holeOderNull(config.collections.shopConfig, doc.$id) : null + return { doc, shop: shopDoc ? ladeShop(shopDoc) : null, project } +} + +/** + * Kontext fuer die oeffentliche Schnittstelle: Funktion 'shop' mit diesem siteKey im Zustand active oder disabled (disabled, + * weil Kaeufer den Widerruf weiter erklaeren koennen muessen) samt shopConfig und Projekt. Treffer und Fehlschlaege werden 30 Sekunden + * je siteKey gemerkt; leereCache und ein Speichern der Einstellungen leeren den Speicher. + * @param {string} siteKey + * @param {{ now?: Date }} [opt] now: Zeitpunkt fuer den Speicher (Tests) + * @returns {Promise<{ doc: object, shop: object, project: object|null }|null>} null bei ungueltigem oder unbekanntem siteKey, + * anderem Zustand als active/disabled und fehlendem shopConfig + */ +export async function shopPerSiteKey(siteKey, { now = new Date() } = {}) { + if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return null + const nowMs = now instanceof Date ? now.getTime() : Number(now) + if (!Number.isFinite(nowMs)) throw new TypeError('shopPerSiteKey: now ist kein gueltiger Zeitpunkt') + const gemerkt = cache.get(siteKey) + if (gemerkt && gemerkt.bis > nowMs) return gemerkt.ctx + + const stand = generation + const [doc] = await listDocuments(config.collections.portalFeatures, [ + Query.equal('siteKey', siteKey), + Query.equal('featureKey', FUNKTION), + Query.equal('state', ['active', 'disabled']), + Query.limit(1), + ]) + let ctx = null + if (doc && ['active', 'disabled'].includes(doc.state)) { + const [shopDoc, project] = await Promise.all([ + holeOderNull(config.collections.shopConfig, doc.$id), + doc.projectId ? holeOderNull(config.collections.websiteProjects, doc.projectId) : null, + ]) + if (shopDoc) ctx = { doc, shop: ladeShop(shopDoc), project } + } + if (stand === generation) merke(siteKey, ctx, nowMs) + return ctx +} + +function merke(siteKey, ctx, nowMs) { + if (cache.size >= CACHE_MAX) { + for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key) + if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value) + } + cache.set(siteKey, { bis: nowMs + CACHE_MS, ctx }) +} + +/** Leert den Speicher von shopPerSiteKey: fuer einen siteKey oder (ohne Angabe) ganz. Laufende Abfragen merken ihr Ergebnis nicht mehr. */ +export function leereCache(siteKey) { + generation++ + if (siteKey === undefined) cache.clear() + else cache.delete(siteKey) +} + +// Nach dem Speichern der Einstellungen (Hook mit der Shop-ID) alle Eintraege dieses Shops verwerfen +nachSpeichern((shopId) => { + generation++ + for (const [key, eintrag] of cache) if (eintrag.ctx?.shop?.id === shopId) cache.delete(key) +}) + +// --------------------------------------------------------------------------------------------------------------------- +// Bereitschaft + +/** Mindestens ein aktiver, lesbarer und lieferbarer Artikel. */ +function hatArtikel(produkte, shop) { + return (Array.isArray(produkte) ? produkte : []).some((p) => { + if (p?.aktiv === false) return false + const o = produktOeffentlich(p, { einstellungen: shop.einstellungen, siteKey: shop.siteKey }) + return o !== null && o.verfuegbar === true + }) +} + +async function produkteFuer(ctx, produkte) { + return Array.isArray(produkte) ? produkte : listeProdukte(ctx.shop.id) +} + +/** Stand der Rechtstexte: 'bestaetigt' (Hash passt), 'geaendert' (frueher bestaetigt, Entwurf weicht ab) oder 'offen'. */ +function rechtstexteStand(shop) { + const { entwurf, bestaetigt } = shop.rechtstexte + if (!bestaetigt) return 'offen' + return bestaetigt.hash === rechtstexteHash(entwurf, shop.einstellungen) ? 'bestaetigt' : 'geaendert' +} + +/** + * Die fuenf Punkte, die vor dem Oeffnen stimmen muessen, in dieser Reihenfolge: einbau, stripe, einstellungen, rechtstexte, artikel. + * @param {{ doc: object, shop: object }} ctx + * @param {{ produkte?: object[] }} [opt] bereits geladene Artikel-Dokumente; ohne Angabe werden sie geladen + * @returns {Promise<{ bereit: boolean, punkte: Array<{ id: string, ok: boolean, text: string }> }>} + */ +export async function bereitschaft(ctx, { produkte } = {}) { + const { doc, shop } = ctx + const liste = await produkteFuer(ctx, produkte) + const stand = rechtstexteStand(shop) + const punkte = [ + { + id: 'einbau', + ok: doc?.state === 'active', + text: doc?.state === 'active' ? 'Der Shop ist eingebaut.' : 'Wir bauen den Shop gerade in Deine Website ein.', + }, + { + id: 'stripe', + ok: shop.stripeStatus?.charges_enabled === true, + text: + shop.stripeStatus?.charges_enabled === true + ? 'Dein Zahlungskonto bei Stripe ist bereit.' + : 'Bitte richte Dein Zahlungskonto bei Stripe ein, damit Du Zahlungen empfangen kannst.', + }, + { + id: 'einstellungen', + ok: istVollstaendig(shop.einstellungen), + text: istVollstaendig(shop.einstellungen) + ? 'Deine Shop-Angaben sind vollständig.' + : 'Bitte vervollständige Deine Shop-Angaben (Shopname, Anbieter mit Anschrift und E-Mail, Lieferzeit).', + }, + { + id: 'rechtstexte', + ok: stand === 'bestaetigt', + text: + stand === 'bestaetigt' + ? 'Deine Rechtstexte sind bestätigt.' + : stand === 'geaendert' + ? 'Bitte bestätige Deine geänderten Rechtstexte.' + : vollstaendig(shop.rechtstexte.entwurf) + ? 'Bitte bestätige Deine Rechtstexte.' + : 'Bitte ergänze Deine Rechtstexte (AGB, Widerrufsbelehrung, Widerrufsformular, Impressum und Datenschutz) und bestätige sie.', + }, + { + id: 'artikel', + ok: hatArtikel(liste, shop), + text: hatArtikel(liste, shop) ? 'Du hast mindestens einen lieferbaren Artikel.' : 'Bitte lege mindestens einen aktiven, lieferbaren Artikel an.', + }, + ] + return { bereit: punkte.every((p) => p.ok), punkte } +} + +/** + * Verkauft der Shop gerade? Geoeffnet vom Haendler, Funktion active, Stripe bereit, Angaben vollstaendig, mindestens ein lieferbarer + * Artikel und die Rechtstexte irgendwann bestaetigt (auch wenn der Entwurf danach geaendert wurde: der Shop bleibt mit der zuletzt + * bestaetigten Fassung offen, Spezifikation 4.6). + * @returns {Promise<boolean>} + */ +export async function istGeoeffnet(ctx, { produkte } = {}) { + const { doc, shop } = ctx + if (shop.geoeffnet !== true || doc?.state !== 'active' || shop.stripeStatus?.charges_enabled !== true) return false + if (!istVollstaendig(shop.einstellungen) || !shop.rechtstexte.bestaetigt) return false + return hatArtikel(await produkteFuer(ctx, produkte), shop) +} + +/** Die den Kaeufern gezeigten Rechtstexte (zuletzt bestaetigte Fassung) oder null, wenn noch nie bestaetigt. */ +export function oeffentlicheRechtstexte(ctx) { + return ctx?.shop?.rechtstexte?.bestaetigt?.texte ?? null +} diff --git a/test/shopKontext.test.js b/test/shopKontext.test.js new file mode 100644 index 0000000..97be98b --- /dev/null +++ b/test/shopKontext.test.js @@ -0,0 +1,358 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Shop-Kontext (C6): Laden per Projekt und siteKey, sicheres Parsen, 30-s-Speicher, Bereitschaft, Oeffnungs-Regel. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + }, + datetimes: SHOP_DATETIMES, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const K = await import('../server/services/shop/kontext.js') +const { normalisiereProdukt } = await import('../server/services/shop/produkte.js') +const { speichereEinstellungen, STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js') + +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const FEATURE = 'f_p_projekt01_shop' +const SITE_KEY = 'abcdefghijklmnop' +const T0 = new Date('2026-10-05T12:00:00.000Z') +const nach = (s) => new Date(T0.getTime() + s * 1000) + +const EINSTELLUNGEN = { + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Brot & Mehr', + anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' }, +} +const TEXTE = { + agb: 'AGB-Text', + widerrufsbelehrung: 'Widerruf-Text', + widerrufsformular: 'Formular-Text', + impressumUrl: 'https://brot.example/impressum', + datenschutzUrl: 'https://brot.example/datenschutz', +} +const hashFuer = (texte = TEXTE, einstellungen = EINSTELLUNGEN) => rechtstexteHash(texte, einstellungen) +const bestaetigt = (texte = TEXTE, hash = hashFuer(texte)) => ({ texte, hash, at: '2026-10-05T10:00:00.000Z', von: 'kunde@example.invalid' }) + +const featureDoc = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over }) +const shopDoc = (over = {}) => ({ + $id: FEATURE, + customerId: KUNDE, + projectId: PROJEKT, + siteKey: SITE_KEY, + anfrageStatus: 'angenommen', + stripeAccountId: 'acct_test', + stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }), + einstellungen: JSON.stringify(EINSTELLUNGEN), + rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: bestaetigt() }), + geoeffnet: true, + version: 3, + ...over, +}) +const artikel = (id, over = {}, produktOver = {}) => { + const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 450, ...produktOver }, { einstellungen: { kleinunternehmer: true } }) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt), ...over } +} + +async function leere() { + for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'websiteProjects']) { + for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id) + } + K.leereCache() +} +async function aufbau({ feature = {}, shop = {}, produkte = [artikel('a1')] } = {}) { + await leere() + aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen' }) + aw.seed('portalFeatures', featureDoc(feature)) + if (shop !== null) aw.seed('shopConfig', shopDoc(shop)) + for (const p of produkte) aw.seed('shopProducts', p) +} +beforeEach(() => K.leereCache()) + +test('ladeRechtstexte: Normalfall, leer, kaputt, alte Form', () => { + const roh = JSON.stringify({ entwurf: TEXTE, bestaetigt: bestaetigt() }) + const r = K.ladeRechtstexte({ rechtstexte: roh }) + assert.deepEqual(r.entwurf, TEXTE) + assert.deepEqual(r.bestaetigt.texte, TEXTE) + assert.equal(r.bestaetigt.hash, hashFuer()) + const leer = { entwurf: { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }, bestaetigt: null } + for (const rohText of [undefined, null, '', '{kaputt', '[]', '"x"', '42', JSON.stringify(TEXTE), JSON.stringify({ entwurf: 5, bestaetigt: 'ja' })]) { + assert.deepEqual(K.ladeRechtstexte({ rechtstexte: rohText }), leer, String(rohText)) + } + assert.deepEqual(K.ladeRechtstexte(null), leer) + // bestaetigt ohne Hash oder ohne Texte zaehlt nicht; Nicht-Text-Felder werden leer + assert.equal(K.ladeRechtstexte({ rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE } }) }).bestaetigt, null) + assert.equal(K.ladeRechtstexte({ rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { hash: 'x' } }) }).bestaetigt, null) + assert.equal(K.ladeRechtstexte({ rechtstexte: JSON.stringify({ entwurf: { ...TEXTE, agb: 5 } }) }).entwurf.agb, '') +}) + +test('ladeShop: geparste Felder, kaputtes JSON wird Standard', () => { + const s = K.ladeShop(shopDoc()) + assert.equal(s.id, FEATURE) + assert.equal(s.siteKey, SITE_KEY) + assert.equal(s.einstellungen.shopname, 'Brot & Mehr') + assert.equal(s.stripeStatus.charges_enabled, true) + assert.equal(s.stripeAccountId, 'acct_test') + assert.equal(s.geoeffnet, true) + assert.equal(s.anfrageStatus, 'angenommen') + assert.equal(s.version, 3) + const kaputt = K.ladeShop({ $id: 'x', einstellungen: '{kaputt', rechtstexte: '{{', stripeStatus: 'nope', geoeffnet: 'ja' }) + assert.equal(kaputt.einstellungen.shopname, '') + assert.equal(kaputt.einstellungen.lieferzeit, STANDARD_EINSTELLUNGEN.lieferzeit) + assert.equal(kaputt.rechtstexte.bestaetigt, null) + assert.equal(kaputt.stripeStatus, null) + assert.equal(kaputt.geoeffnet, false) + assert.equal(kaputt.version, 0) + assert.equal(K.ladeShop({ $id: 'y', stripeStatus: '[1]' }).stripeStatus, null) +}) + +test('ladeShopFuerProjekt: eigenes Projekt, fremdes Projekt, ohne Funktion, ohne shopConfig', async () => { + await aufbau() + const r = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + assert.equal(r.doc.$id, FEATURE) + assert.equal(r.shop.id, FEATURE) + assert.equal(r.project.$id, PROJEKT) + + const fremd = await K.ladeShopFuerProjekt('c_anderer', PROJEKT) + assert.deepEqual(fremd, { doc: null, shop: null, project: null }) + assert.deepEqual(await K.ladeShopFuerProjekt(KUNDE, 'p_gibtsnicht'), { doc: null, shop: null, project: null }) + + await aufbau({ shop: null }) + const ohneShop = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + assert.equal(ohneShop.doc.$id, FEATURE) + assert.equal(ohneShop.shop, null) + assert.equal(ohneShop.project.$id, PROJEKT) + + await leere() + aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen' }) + const ohneFunktion = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + assert.equal(ohneFunktion.doc, null) + assert.equal(ohneFunktion.shop, null) + assert.equal(ohneFunktion.project.$id, PROJEKT) +}) + +test('shopPerSiteKey: active und disabled, sonst null', async () => { + await aufbau() + const ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 }) + assert.equal(ctx.doc.$id, FEATURE) + assert.equal(ctx.shop.id, FEATURE) + assert.equal(ctx.project.$id, PROJEKT) + + await aufbau({ feature: { state: 'disabled', enabled: false } }) + const aus = await K.shopPerSiteKey(SITE_KEY, { now: T0 }) + assert.equal(aus.doc.state, 'disabled', 'disabled bleibt fuer den Widerruf erreichbar') + + for (const state of ['off', 'ordered', 'setup']) { + await aufbau({ feature: { state, enabled: false } }) + assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null, state) + } + await aufbau({ feature: { featureKey: 'news' } }) + assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null, 'andere Funktion') + await aufbau({ shop: null }) + assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null, 'ohne shopConfig') + await aufbau() + for (const schlecht of ['', 'ABCDEFGHIJKLMNOP', 'abcdefghijklmno', 'abcdefghijklmnop1', 'abcdefghijklmn01', null, undefined, 42, {}]) { + assert.equal(await K.shopPerSiteKey(schlecht, { now: T0 }), null, String(schlecht)) + } + assert.equal(await K.shopPerSiteKey('zzzzzzzzzzzzzzzz', { now: T0 }), null, 'unbekannter siteKey') +}) + +test('shopPerSiteKey: 30-Sekunden-Speicher mit fester Zeit, Fehlschlaege werden auch gemerkt', async () => { + await aufbau() + const a = await K.shopPerSiteKey(SITE_KEY, { now: T0 }) + await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: false }) + assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: nach(29) }), a, 'innerhalb von 30 s dasselbe Objekt') + assert.equal(a.shop.geoeffnet, true) + const b = await K.shopPerSiteKey(SITE_KEY, { now: nach(30) }) + assert.notEqual(b, a) + assert.equal(b.shop.geoeffnet, false) + + await aufbau({ feature: { state: 'setup', enabled: false } }) + assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null) + await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'active', enabled: true }) + assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: nach(10) }), null, 'null ist gemerkt') + assert.ok(await K.shopPerSiteKey(SITE_KEY, { now: nach(31) })) +}) + +test('leereCache(siteKey) und leereCache() verwerfen den Speicher', async () => { + await aufbau() + const a = await K.shopPerSiteKey(SITE_KEY, { now: T0 }) + await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: false }) + K.leereCache('aaaaaaaaaaaaaaaa') + assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: nach(1) }), a, 'anderer siteKey: nichts passiert') + K.leereCache(SITE_KEY) + assert.equal((await K.shopPerSiteKey(SITE_KEY, { now: nach(2) })).shop.geoeffnet, false) + await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: true }) + K.leereCache() + assert.equal((await K.shopPerSiteKey(SITE_KEY, { now: nach(3) })).shop.geoeffnet, true) +}) + +test('Speichern der Einstellungen leert den Speicher dieses Shops (Hook), andere bleiben', async () => { + await aufbau() + const a = await K.shopPerSiteKey(SITE_KEY, { now: T0 }) + assert.equal(a.shop.einstellungen.lieferzeit, '2–4 Werktage') + // zweiter Shop, bleibt im Speicher + const KEY2 = 'bcdefghijklmnopq' + aw.seed('portalFeatures', featureDoc({ $id: 'f_p2_shop', projectId: 'p2', siteKey: KEY2 })) + aw.seed('shopConfig', shopDoc({ $id: 'f_p2_shop', projectId: 'p2', siteKey: KEY2 })) + const b = await K.shopPerSiteKey(KEY2, { now: T0 }) + + await speichereEinstellungen(FEATURE, { ...EINSTELLUNGEN, lieferzeit: '1–2 Werktage' }) + const a2 = await K.shopPerSiteKey(SITE_KEY, { now: nach(1) }) + assert.notEqual(a2, a) + assert.equal(a2.shop.einstellungen.lieferzeit, '1–2 Werktage') + assert.equal(await K.shopPerSiteKey(KEY2, { now: nach(1) }), b) +}) + +test('Eine Abfrage, die waehrend des Leerens lief, landet nicht im Speicher', async () => { + await aufbau() + const lauf = K.shopPerSiteKey(SITE_KEY, { now: T0 }) + K.leereCache() // vor dem Ende der Abfrage + const erstes = await lauf + await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: false }) + const zweites = await K.shopPerSiteKey(SITE_KEY, { now: nach(1) }) + assert.notEqual(zweites, erstes) + assert.equal(zweites.shop.geoeffnet, false) +}) + +test('shopPerSiteKey: ungueltiger Zeitpunkt wirft', async () => { + await assert.rejects(() => K.shopPerSiteKey(SITE_KEY, { now: new Date('kaputt') }), TypeError) +}) + +const punkt = (r, id) => r.punkte.find((p) => p.id === id) + +test('bereitschaft: alles in Ordnung', async () => { + await aufbau() + const ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 }) + const r = await K.bereitschaft(ctx) // Artikel werden geladen + assert.deepEqual(r.punkte.map((p) => p.id), ['einbau', 'stripe', 'einstellungen', 'rechtstexte', 'artikel']) + assert.equal(r.bereit, true) + assert.ok(r.punkte.every((p) => p.ok && typeof p.text === 'string' && p.text !== '')) + assert.equal(punkt(r, 'einbau').text, 'Der Shop ist eingebaut.') + assert.equal(await K.istGeoeffnet(ctx), true) + assert.equal(await K.istGeoeffnet(ctx, { produkte: [artikel('a1')] }), true) +}) + +test('bereitschaft: einbau', async () => { + await aufbau({ feature: { state: 'setup', enabled: false } }) + const { doc, shop } = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + const r = await K.bereitschaft({ doc, shop }) + assert.equal(punkt(r, 'einbau').ok, false) + assert.equal(punkt(r, 'einbau').text, 'Wir bauen den Shop gerade in Deine Website ein.') + assert.equal(r.bereit, false) + assert.equal(await K.istGeoeffnet({ doc, shop }), false) +}) + +test('bereitschaft: stripe', async () => { + for (const stripeStatus of [null, '', '{kaputt', JSON.stringify({ charges_enabled: false }), JSON.stringify({ charges_enabled: 'true' })]) { + await aufbau({ shop: { stripeStatus } }) + const { doc, shop } = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + const r = await K.bereitschaft({ doc, shop }) + assert.equal(punkt(r, 'stripe').ok, false, String(stripeStatus)) + assert.match(punkt(r, 'stripe').text, /^Bitte /) + assert.equal(await K.istGeoeffnet({ doc, shop }), false) + } +}) + +test('bereitschaft: einstellungen', async () => { + await aufbau({ shop: { einstellungen: JSON.stringify({ ...EINSTELLUNGEN, anbieter: { ...EINSTELLUNGEN.anbieter, email: '' } }) } }) + const { doc, shop } = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + const r = await K.bereitschaft({ doc, shop }) + assert.equal(punkt(r, 'einstellungen').ok, false) + assert.equal(await K.istGeoeffnet({ doc, shop }), false) + await aufbau({ shop: { einstellungen: '{kaputt' } }) + const k = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + assert.equal(punkt(await K.bereitschaft(k), 'einstellungen').ok, false) +}) + +test('bereitschaft und Oeffnung: Rechtstexte offen, geaendert, bestaetigt', async () => { + // nie bestaetigt + await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: null }) } }) + let c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + let r = await K.bereitschaft(c) + assert.equal(punkt(r, 'rechtstexte').ok, false) + assert.equal(punkt(r, 'rechtstexte').text, 'Bitte bestätige Deine Rechtstexte.') + assert.equal(await K.istGeoeffnet(c), false, 'nie bestaetigt => zu') + assert.equal(K.oeffentlicheRechtstexte(c), null) + + // Entwurf unvollstaendig + await aufbau({ shop: { rechtstexte: '' } }) + c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + r = await K.bereitschaft(c) + assert.equal(punkt(r, 'rechtstexte').ok, false) + assert.match(punkt(r, 'rechtstexte').text, /^Bitte ergänze Deine Rechtstexte/) + + // bestaetigt, Entwurf danach geaendert + const neu = { ...TEXTE, agb: 'Neue AGB' } + await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: neu, bestaetigt: bestaetigt() }) } }) + c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + r = await K.bereitschaft(c) + assert.equal(punkt(r, 'rechtstexte').ok, false) + assert.equal(punkt(r, 'rechtstexte').text, 'Bitte bestätige Deine geänderten Rechtstexte.') + assert.equal(r.bereit, false) + assert.equal(await K.istGeoeffnet(c), true, 'frueher bestaetigt => bleibt offen') + assert.deepEqual(K.oeffentlicheRechtstexte(c), TEXTE, 'Kaeufer sehen die bestaetigte Fassung') + + // Hash passt zum Entwurf + await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: neu, bestaetigt: bestaetigt(neu) }) } }) + c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + assert.equal(punkt(await K.bereitschaft(c), 'rechtstexte').ok, true) + + // Hash passt nach geaenderten Einstellungen (Versand/Zahlung fliessen ein) nicht mehr + await aufbau({ shop: { einstellungen: JSON.stringify({ ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] }) } }) + c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + assert.equal(punkt(await K.bereitschaft(c), 'rechtstexte').ok, false) + assert.equal(await K.istGeoeffnet(c), true) +}) + +test('bereitschaft und Oeffnung: Artikel', async () => { + const lauf = async (produkte) => { + await aufbau({ produkte }) + const c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + return { r: await K.bereitschaft(c), offen: await K.istGeoeffnet(c) } + } + let e = await lauf([]) + assert.equal(punkt(e.r, 'artikel').ok, false) + assert.equal(punkt(e.r, 'artikel').text, 'Bitte lege mindestens einen aktiven, lieferbaren Artikel an.') + assert.equal(e.offen, false) + + e = await lauf([artikel('a1', { aktiv: false })]) + assert.equal(punkt(e.r, 'artikel').ok, false, 'nur inaktive') + e = await lauf([artikel('a1', {}, { bestand: 0 })]) + assert.equal(punkt(e.r, 'artikel').ok, false, 'ausverkauft') + assert.equal(e.offen, false) + e = await lauf([artikel('a1', { produkt: '{kaputt' })]) + assert.equal(punkt(e.r, 'artikel').ok, false, 'unlesbar') + e = await lauf([artikel('a1', { aktiv: false }), artikel('a2', {}, { bestand: 5 })]) + assert.equal(punkt(e.r, 'artikel').ok, true) + assert.equal(e.offen, true) +}) + +test('istGeoeffnet: Haendler hat geschlossen, Funktion disabled, uebergebene Artikel', async () => { + await aufbau({ shop: { geoeffnet: false } }) + let c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + assert.equal(await K.istGeoeffnet(c), false) + assert.equal((await K.bereitschaft(c)).bereit, true, 'bereit, aber nicht geoeffnet') + + await aufbau({ feature: { state: 'disabled', enabled: false } }) + c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + assert.equal(await K.istGeoeffnet(c), false) + + await aufbau() + c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) + assert.equal(await K.istGeoeffnet(c, { produkte: [] }), false, 'uebergebene (leere) Liste gilt, es wird nicht nachgeladen') + assert.equal((await K.bereitschaft(c, { produkte: [] })).punkte.find((p) => p.id === 'artikel').ok, false) +}) From 0fb84021f802455b1a2d71f23939a79aef4e8911 Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Mon, 5 Oct 2026 08:43:38 +0000 Subject: [PATCH 045/105] =?UTF-8?q?Webshop=20B2:=20Team-Entscheidung=20?= =?UTF-8?q?=C3=BCber=20die=20Anfrage,=20=C3=9Cbersicht=20/api/team/shop=20?= =?UTF-8?q?und=20Kundenmail=20K24?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../mail-templates/K24 Webshop-Anfrage.html | 52 ++++ server/routes/team/index.js | 3 + server/routes/team/shop.js | 92 +++++++ server/services/shop/anfrage.js | 79 +++++- test/mailTemplates.test.js | 36 ++- test/shopTeam.test.js | 254 ++++++++++++++++++ 6 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 server/mail-templates/K24 Webshop-Anfrage.html create mode 100644 server/routes/team/shop.js create mode 100644 test/shopTeam.test.js diff --git a/server/mail-templates/K24 Webshop-Anfrage.html b/server/mail-templates/K24 Webshop-Anfrage.html new file mode 100644 index 0000000..11a2dc6 --- /dev/null +++ b/server/mail-templates/K24 Webshop-Anfrage.html @@ -0,0 +1,52 @@ +<!DOCTYPE html> +<html lang="de" xmlns="http://www.w3.org/1999/xhtml"> +<head> +<meta charset="utf-8"> +<!-- + K24 · Webshop-Anfrage entschieden (Annahme oder Ablehnung) + Betreff: Deine Webshop-Anfrage für {{projekt}} + Absender: „WEBklar“ <Portal-Postfach> + Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern + Platzhalter: {{kunde_name}}, {{projekt}}, {{preis}}, {{ust_hinweis}}, {{begruendung}}, {{portal_url}} + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angenommen, abgelehnt + Genau einer der Blöcke ist an. angenommen = Anfrage angenommen, der Kunde kann den Webshop jetzt bestellen; abgelehnt = Anfrage abgelehnt, mit Begründung des Teams. + preis = Preis der Einrichtung, z. B. „299 €“; ust_hinweis = Umsatzsteuerhinweis zum Preis; portal_url = Webshop-Seite des Projekts im Kundenportal. + Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke. +--> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<meta name="color-scheme" content="light dark"> +<meta name="supported-color-schemes" content="light dark"> +<title>Deine Webshop-Anfrage für {{projekt}} + + + + +Deine Webshop-Anfrage wurde bearbeitet. ͏ ͏ ͏ + + + + + diff --git a/server/routes/team/index.js b/server/routes/team/index.js index 9c38be4..5c803fc 100644 --- a/server/routes/team/index.js +++ b/server/routes/team/index.js @@ -8,6 +8,7 @@ import { takeoverAdminRouter } from '../takeoverCredits.js' import customersRoutes from './customers.js' import filesRoutes from './files.js' import previewRoutes from './preview.js' +import shopTeamRoutes from './shop.js' import termineTeamRoutes from './termine.js' // Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem) @@ -25,5 +26,7 @@ router.use('/storage-tasks', storageTasksAdminRouter) router.use('/funktionen', funktionenAdminRouter) // Terminplaner-Betrieb (Status fuer Budget, Warteschlange, Anmeldepause, Tagesjob) router.use('/termine', termineTeamRoutes) +// Webshop: Stand je Projekt und Entscheidung ueber die Anfrage +router.use('/shop', shopTeamRoutes) export default router diff --git a/server/routes/team/shop.js b/server/routes/team/shop.js new file mode 100644 index 0000000..2480ea4 --- /dev/null +++ b/server/routes/team/shop.js @@ -0,0 +1,92 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { Query, countDocuments, listDocuments } from '../../services/appwriteAdmin.js' +import { anfrageAnsicht, entscheideAnfrage, leseShopConfig } from '../../services/shop/anfrage.js' +import { listCustomerFeatureDocs } from '../../services/siteFeatures.js' + +/** + * Webshop im Ticketsystem (Kunden-Reiter "Funktionen"): Stand je Projekt und Entscheidung ueber die Anfrage. + * Eingehaengt in routes/team/index.js unter /api/team/shop, dort hinter requireStaffJwt. + */ +const router = Router() + +const sendError = (res, err) => { + const status = err.status >= 400 && err.status < 600 ? err.status : 500 + if (status >= 500) console.error('[team-shop]', err.message) + return res.status(status).json({ error: err.message || 'Interner Serverfehler', ...(err.body?.code ? { code: err.body.code } : {}) }) +} +const route = (fn) => (req, res) => fn(req, res).catch((err) => sendError(res, err)) +const person = (req) => ({ name: req.session.adminName || req.session.name || '', email: req.session.email || '' }) + +/** Zaehler oder null, wenn die Abfrage scheitert (die Uebersicht bleibt lesbar). */ +async function zaehle(collection, queries, was) { + try { + return await countDocuments(config.collections[collection], queries) + } catch (err) { + console.warn(`[team-shop] ${was} nicht lesbar: ${err.message}`) + return null + } +} + +/** stripeStatus (JSON-Text im shopConfig) als Objekt oder null, wenn leer oder nicht lesbar. */ +function stripeStatus(text) { + try { + const roh = typeof text === 'string' && text ? JSON.parse(text) : null + return roh !== null && typeof roh === 'object' && !Array.isArray(roh) ? roh : null + } catch { + return null + } +} + +async function projektStand(project, feature) { + let shop = null + if (feature) { + try { + shop = await leseShopConfig(feature.$id) + } catch (err) { + console.warn(`[team-shop] shopConfig ${feature.$id} nicht lesbar: ${err.message}`) + } + } + const shopId = feature?.$id + const [artikel, offeneBestellungen] = shopId + ? await Promise.all([ + zaehle('shopProducts', [Query.equal('shopId', shopId)], 'Artikelzahl'), + zaehle('shopOrders', [Query.equal('shopId', shopId), Query.equal('status', 'bezahlt')], 'Bestellzahl'), + ]) + : [0, 0] + return { + projectId: project.$id, + projectName: project.projectName || '', + state: feature?.state || 'off', + anfrage: shop ? anfrageAnsicht(shop) : null, + stripe: { accountId: Boolean(shop?.stripeAccountId), status: stripeStatus(shop?.stripeStatus) }, + geoeffnet: shop?.geoeffnet === true, + zahlen: { artikel, offeneBestellungen }, + } +} + +// Uebersicht je Projekt des Kunden; archivierte Projekte nur, wenn dort ein Shop-Dokument existiert +router.get('/:customerId', route(async (req, res) => { + const customerId = req.params.customerId + const [docs, projects] = await Promise.all([ + listCustomerFeatureDocs(customerId), + listDocuments(config.collections.websiteProjects, [Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000)]), + ]) + const shopDocs = new Map(docs.filter((d) => d.featureKey === 'shop').map((d) => [d.projectId, d])) + const sichtbar = projects.filter((p) => !p.archived || shopDocs.has(p.$id)) + res.json({ projects: await Promise.all(sichtbar.map((p) => projektStand(p, shopDocs.get(p.$id)))) }) +})) + +router.post('/:customerId/anfrage', route(async (req, res) => { + const { projectId, entscheidung, begruendung } = req.body || {} + const ergebnis = await entscheideAnfrage({ + customerId: req.params.customerId, + projectId, + entscheidung, + begruendung, + person: person(req), + }) + res.json({ ok: true, anfrage: ergebnis.anfrage, ...(ergebnis.mailFehler ? { mailFehler: true } : {}) }) +})) + +export default router diff --git a/server/services/shop/anfrage.js b/server/services/shop/anfrage.js index 196835f..81412ac 100644 --- a/server/services/shop/anfrage.js +++ b/server/services/shop/anfrage.js @@ -1,7 +1,7 @@ import { config } from '../../config.js' import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' -import { sendAdminNotice } from '../mailer.js' +import { sendAdminNotice, sendCustomerMail } from '../mailer.js' import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js' import { shopFuerKunde } from './schalter.js' @@ -28,6 +28,9 @@ const MELDUNG = { vorhanden: 'Für dieses Projekt gibt es schon einen Webshop.', laeuft: 'Für dieses Projekt gibt es schon eine Webshop-Anfrage.', nichtOffen: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.', + nichtEntscheidbar: 'Diese Anfrage ist nicht (mehr) offen und kann nicht entschieden werden.', + entscheidung: 'Bitte wähle annehmen oder ablehnen.', + begruendung: 'Bitte nenne eine Begründung für die Ablehnung (höchstens 1000 Zeichen).', ungueltig: 'Ungültige Eingabe.', } @@ -47,7 +50,7 @@ export async function anfrageOffenePunkte({ customerId, projectId }) { } /** shopConfig-Dokument zur ID oder null, wenn es (noch) keines gibt. Fehlt die Collection selbst, wird der Fehler weitergereicht. */ -async function leseShopConfig(id) { +export async function leseShopConfig(id) { try { return await getDocument(config.collections.shopConfig, id) } catch (err) { @@ -230,3 +233,75 @@ export async function zieheZurueck({ customerId, projectId, person = {} }) { }) return anfrageAnsicht(gespeichert) } + +const BEGRUENDUNG_MAX = 1000 + +/** 'Name ' (wie createdBy der Angebote); ohne Name die Adresse, ohne beides 'Admin'. */ +const personLabel = (person) => (person?.name ? `${person.name}${person.email ? ` <${person.email}>` : ''}` : person?.email || 'Admin') + +/** + * Das Team entscheidet eine offene Anfrage (B2): annehmen oder ablehnen (Begruendung Pflicht, 1-1000 Zeichen). Unter der Sperre je + * Projekt; das Projekt muss dem Kunden gehoeren (403), der Status muss `offen` sein (409). Speichert Status, Zeit und Person + * (anfrageEntschiedenVon), bei annehmen eine leere Begruendung. Danach geht K24 an den Kunden (BCC Team); scheitert die Mail, bleibt + * die Entscheidung bestehen, wird geloggt und die Antwort traegt `mailFehler: true`. + * @returns {Promise<{ anfrage: object, mailFehler: boolean }>} anfrageAnsicht des neuen Standes + */ +export async function entscheideAnfrage({ customerId, projectId, entscheidung, begruendung, person = {}, now = new Date() }) { + if (entscheidung !== 'annehmen' && entscheidung !== 'ablehnen') throw stop(400, MELDUNG.entscheidung) + let grund = '' + if (entscheidung === 'ablehnen') { + grund = typeof begruendung === 'string' ? begruendung.toWellFormed().trim() : '' + if (!grund || grund.length > BEGRUENDUNG_MAX) throw stop(400, MELDUNG.begruendung) + } + if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt) + + const { gespeichert, project } = await withLocks([`shop-anfrage:${projectId}`], async () => { + let besitz + try { + besitz = await assertOwnProject(customerId, projectId) + } catch (err) { + throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err + } + const feature = await getFeatureDoc(customerId, projectId, 'shop') + const shop = feature ? await leseShopConfig(feature.$id) : null + if (!shop || shop.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtEntscheidbar, 'nicht_offen') + const neu = await updateDocument(config.collections.shopConfig, shop.$id, { + anfrageStatus: entscheidung === 'annehmen' ? 'angenommen' : 'abgelehnt', + anfrageEntschiedenAt: now.toISOString(), + anfrageEntschiedenVon: personLabel(person).slice(0, 255), + anfrageBegruendung: grund, + version: (Number.isInteger(shop.version) ? shop.version : 0) + 1, + }) + return { gespeichert: neu, project: besitz } + }) + + const mailFehler = !(await sendeEntscheidung({ customerId, projectId, project, angenommen: entscheidung === 'annehmen', begruendung: grund })) + return { anfrage: anfrageAnsicht(gespeichert), mailFehler } +} + +/** K24 an den Kunden; true = gesendet. Jeder Fehler wird nur geloggt (die Entscheidung ist gespeichert). */ +async function sendeEntscheidung({ customerId, projectId, project, angenommen, begruendung }) { + try { + const kunde = await customerOf(customerId) + if (!kunde?.email) { + console.warn(`[shop] K24 nicht gesendet: Kunde ${customerId} ohne E-Mail-Adresse`) + return false + } + await sendCustomerMail(kunde.email, { + template: 'K24', + values: { + kunde_name: String(kunde.name || '').trim() || 'Kunde', + projekt: project?.projectName || projectId, + preis: config.features.shop.priceLabel, + ust_hinweis: config.ustHinweis, + begruendung, + portal_url: `${config.publicBaseUrl}/dashboard.html#webshop?projekt=${encodeURIComponent(projectId)}`, + }, + blocks: { angenommen, abgelehnt: !angenommen }, + }) + return true + } catch (err) { + console.warn(`[shop] K24 nicht gesendet: ${err?.message}`) + return false + } +} diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index da9d5da..c909cbf 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. const DIR = new URL('../server/mail-templates/', import.meta.url) // G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb; -// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) +// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) -const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20', 'T21'] +const EXPECTED = [...ids('G', 8), ...ids('K', 24), ...ids('T', 19), 'T20', 'T21'] const GUEST = ids('G', 8) const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) @@ -1041,3 +1041,35 @@ test('K20 und K21: WEBklar-Layout in Du-Form, Gastdaten maskiert, Blöcke je Fal for (const [anderer, satz] of Object.entries(SATZ)) assert.equal(mail.text.includes(satz), anderer === block, `${block}: ${satz}`) } }) + +// Webshop B2: K24 meldet dem Kunden die Entscheidung des Teams über seine Webshop-Anfrage (Block angenommen oder abgelehnt) +const K24_WERTE = { kunde_name: 'Erika', projekt: 'Töpferei', preis: '299 €', ust_hinweis: 'ohne Umsatzsteuer gemäß § 19 UStG', begruendung: 'Wir verkaufen keine .\nBitte melde Dich.', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' } + +test('K24: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => { + const tpl = loadTemplate('K24') + assert.equal(tpl.file, 'K24 Webshop-Anfrage.html') + assert.equal(tpl.subject, 'Deine Webshop-Anfrage für {{projekt}}') + assert.deepEqual(tpl.placeholders, ['kunde_name', 'projekt', 'preis', 'ust_hinweis', 'begruendung', 'portal_url']) + assert.deepEqual(tpl.blocks, ['angenommen', 'abgelehnt']) +}) + +test('K24 angenommen: Preis mit Umsatzsteuerhinweis, Knopf ins Portal, keine Begründung', () => { + const mail = renderMail('K24', K24_WERTE, { angenommen: true, abgelehnt: false }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Deine Webshop-Anfrage für Töpferei') + assert.match(mail.text, /Hallo Erika/) + assert.match(mail.text, /angenommen/) + assert.match(mail.text, /299 €/) + assert.match(mail.text, /§ 19 UStG/) + assert.doesNotMatch(mail.text, /Waffen|Begründung|leider/) + assert.match(mail.html, /href="https:\/\/portal\.example\/dashboard\.html#webshop\?projekt=p1"/) +}) + +test('K24 abgelehnt: nennt die Begründung (maskiert, Zeilenumbruch), keinen Preis', () => { + const mail = renderMail('K24', K24_WERTE, { angenommen: false, abgelehnt: true }) + assert.deepEqual(mail.problems, []) + assert.match(mail.text, /leider/) + assert.match(mail.text, /Wir verkaufen keine \./) + assert.match(mail.html, /Wir verkaufen keine <Waffen>\.
Bitte melde Dich\./) + assert.doesNotMatch(mail.text, /299 €|UStG|angenommen/) +}) diff --git a/test/shopTeam.test.js b/test/shopTeam.test.js new file mode 100644 index 0000000..d36a76e --- /dev/null +++ b/test/shopTeam.test.js @@ -0,0 +1,254 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Webshop B2: Team-Uebersicht und Entscheidung ueber die Anfrage (routes/team/shop.js, services/shop/anfrage.js entscheideAnfrage), K24 +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + shopOrders: schemaKeys('shopOrders'), + featureConsents: schemaKeys('featureConsents'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, rawGet, post } = await import('./support/testApp.js') +const { seedFixtures, customers } = await import('./support/fixtures.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') +const anfrage = await import('../server/services/shop/anfrage.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { featureHaken } = await import('../server/services/featureRegistry.js') +const { ensureFeatureDoc } = await import('../server/services/siteFeatures.js') +const { resetShopRoutesForTests } = await import('../server/routes/shop.js') + +const app = await startTestApp({ '/api/team': teamRoutes }) +const mails = mailSink() +mails.versandFehler = false +const sinkMail = mails.sendMail.bind(mails) +mails.sendMail = async (mail) => { + if (mails.versandFehler && /Webshop-Anfrage/.test(mail.subject)) throw new Error('SMTP nicht erreichbar') + return sinkMail(mail) +} +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) + +seedFixtures(aw) +aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) +aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Töpferei' }) +aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' }) +aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' }) +aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Altprojekt', archived: true }) +aw.seed('websiteProjects', { $id: 'pt5', customerId: 'c1', projectName: 'Altshop', archived: true }) + +const staff = { headers: { authorization: 'Bearer jwt-staff' } } +const GUELTIG = { + warenart: 'kunst', + beschreibung: 'Handgemachte Keramik und Tassen aus eigener Werkstatt.', + artikelzahl: 'bis20', + versand: true, + abholung: false, + unternehmer: true, + keineAusgeschlossenen: true, + nachricht: '', +} +const person = { name: 'Kunde Eins', email: customers.c1.email } +const stelle = (projectId = 'pt1') => anfrage.stelleAnfrage({ customerId: 'c1', projectId, eingabe: GUELTIG, person }) +const entscheide = (body, opts = staff, customerId = 'c1') => post(`/api/team/shop/${customerId}/anfrage`, { projectId: 'pt1', ...body }, opts) +const k24 = () => mails.sent.filter((m) => /Webshop-Anfrage/.test(m.subject)) +const shopDoc = (projectId = 'pt1') => [...aw.docs('shopConfig').values()].find((d) => d.projectId === projectId) ?? null +const uebersicht = async (customerId = 'c1') => (await rawGet(`/api/team/shop/${customerId}`, staff)).body.projects + +let origin +beforeEach(() => { + origin ??= { mode: config.shop.mode, pilotEmails: config.shop.pilotEmails } + config.shop.mode = 'pilot' + config.shop.pilotEmails = [customers.c1.email] + mails.versandFehler = false + resetShopRoutesForTests() +}) + +async function leereAlles() { + for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders']) { + for (const id of aw.docs(col).keys()) await deleteDocument(config.collections[col], id) + } + mails.sent.length = 0 +} + +test('ohne Anmeldung 401, mit Mitarbeiter-JWT erreichbar', async () => { + assert.equal((await rawGet('/api/team/shop/c1')).status, 401) + assert.equal((await post('/api/team/shop/c1/anfrage', { projectId: 'pt1', entscheidung: 'annehmen' })).status, 401) + assert.equal((await rawGet('/api/team/shop/c1', staff)).status, 200) +}) + +test('GET: Projekte des Kunden ohne Shop, archivierte nur mit Shop-Dokument', async () => { + await leereAlles() + const ohne = await uebersicht() + assert.deepEqual(ohne.map((p) => p.projectId).sort(), ['pt1', 'pt2']) + assert.deepEqual(ohne[0], { + projectId: ohne[0].projectId, + projectName: ohne[0].projectName, + state: 'off', + anfrage: null, + stripe: { accountId: false, status: null }, + geoeffnet: false, + zahlen: { artikel: 0, offeneBestellungen: 0 }, + }) + assert.deepEqual(await uebersicht('c3'), []) + + await ensureFeatureDoc('c1', 'pt5', 'shop') + assert.deepEqual((await uebersicht()).map((p) => p.projectId).sort(), ['pt1', 'pt2', 'pt5']) +}) + +test('GET: Anfrage, Stripe-Stand, geoeffnet und Zahlen je Projekt', async () => { + await leereAlles() + await stelle('pt1') + const doc = shopDoc('pt1') + await updateDocument(config.collections.shopConfig, doc.$id, { + stripeAccountId: 'acct_1', + stripeStatus: JSON.stringify({ chargesEnabled: true, detailsSubmitted: true }), + geoeffnet: true, + }) + for (const [id, shopId, status] of [['o1', doc.$id, 'bezahlt'], ['o2', doc.$id, 'bezahlt'], ['o3', doc.$id, 'offen'], ['o4', doc.$id, 'versendet'], ['o5', 'anderer', 'bezahlt']]) { + aw.seed('shopOrders', { $id: id, shopId, customerId: 'c1', status }) + } + aw.seed('shopProducts', { $id: 'a1', shopId: doc.$id, customerId: 'c1', name: 'Tasse' }) + aw.seed('shopProducts', { $id: 'a2', shopId: doc.$id, customerId: 'c1', name: 'Teller' }) + + const p1 = (await uebersicht()).find((p) => p.projectId === 'pt1') + assert.equal(p1.anfrage.status, 'offen') + assert.equal(p1.anfrage.anfrage.warenart, 'kunst') + assert.deepEqual(p1.stripe, { accountId: true, status: { chargesEnabled: true, detailsSubmitted: true } }) + assert.equal(p1.geoeffnet, true) + assert.deepEqual(p1.zahlen, { artikel: 2, offeneBestellungen: 2 }) + const p2 = (await uebersicht()).find((p) => p.projectId === 'pt2') + assert.equal(p2.anfrage, null) + + await updateDocument(config.collections.shopConfig, doc.$id, { stripeStatus: '{kaputt' }) + assert.equal((await uebersicht()).find((p) => p.projectId === 'pt1').stripe.status, null, 'unlesbares JSON => null') +}) + +test('annehmen: speichert Entscheidung und Person, K24 an den Kunden, Bestell-Haken liefert []', async () => { + await leereAlles() + await stelle() + assert.deepEqual(await featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }), [anfrage.ANFRAGE_OFFEN_TEXT]) + mails.sent.length = 0 + + const res = await entscheide({ entscheidung: 'annehmen', begruendung: 'wird ignoriert' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.mailFehler, undefined) + assert.equal(res.body.anfrage.status, 'angenommen') + assert.ok(res.body.anfrage.entschiedenAt) + assert.equal(res.body.anfrage.begruendung, '') + const doc = shopDoc() + assert.equal(doc.anfrageStatus, 'angenommen') + assert.equal(doc.anfrageEntschiedenVon, 'Andrej ') + assert.equal(doc.anfrageBegruendung, '') + assert.equal(doc.version, 2) + + assert.deepEqual(await featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }), []) + + const mail = k24() + assert.equal(mail.length, 1) + assert.equal(mail[0].to[0].address, customers.c1.email) + assert.equal(mail[0].subject, 'Deine Webshop-Anfrage für Töpferei') + assert.match(mail[0].text, /Hallo Kunde Eins/) + assert.match(mail[0].text, /angenommen/) + assert.match(mail[0].text, /299 €/) + assert.match(mail[0].text, /§ 19 UStG/) + assert.doesNotMatch(mail[0].text, /leider/) + assert.match(mail[0].html, /dashboard\.html#webshop\?projekt=pt1"/) + assert.ok(mail[0].html.includes(config.publicBaseUrl)) +}) + +test('ablehnen: Begründung Pflicht (1-1000), gespeichert und in K24', async () => { + await leereAlles() + await stelle() + mails.sent.length = 0 + for (const begruendung of [undefined, '', ' ', 5, 'x'.repeat(1001)]) { + const res = await entscheide({ entscheidung: 'ablehnen', begruendung }) + assert.equal(res.status, 400, String(begruendung).slice(0, 10)) + } + assert.equal(shopDoc().anfrageStatus, 'offen') + assert.equal(k24().length, 0) + + const res = await entscheide({ entscheidung: 'ablehnen', begruendung: ` ${'y'.repeat(1000)} ` }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.anfrage.status, 'abgelehnt') + assert.equal(res.body.anfrage.begruendung, 'y'.repeat(1000)) + assert.equal(shopDoc().anfrageStatus, 'abgelehnt') + assert.deepEqual(await featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }), [anfrage.ANFRAGE_OFFEN_TEXT]) + assert.match(k24()[0].text, /leider/) + assert.match(k24()[0].text, /yyyy/) + assert.doesNotMatch(k24()[0].text, /299 €/) +}) + +test('falscher Zustand 409, fremdes Projekt 403, ungültige Eingabe 400', async () => { + await leereAlles() + // kein Dokument + let res = await entscheide({ entscheidung: 'annehmen' }) + assert.equal(res.status, 409) + assert.equal(res.body.code, 'nicht_offen') + await stelle() + assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200) + mails.sent.length = 0 + // schon entschieden: weder annehmen noch ablehnen + assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 409) + assert.equal((await entscheide({ entscheidung: 'ablehnen', begruendung: 'Grund' })).status, 409) + assert.equal(shopDoc().anfrageStatus, 'angenommen') + // zurückgezogen + await stelle('pt2') + await anfrage.zieheZurueck({ customerId: 'c1', projectId: 'pt2', person }) + assert.equal((await entscheide({ projectId: 'pt2', entscheidung: 'annehmen' })).status, 409) + assert.equal(k24().length, 0, 'bei 409 keine Mail') + + assert.equal((await entscheide({ projectId: 'pt3', entscheidung: 'annehmen' })).status, 403, 'Projekt eines anderen Kunden') + assert.equal((await entscheide({ projectId: 'gibtsnicht', entscheidung: 'annehmen' })).status, 403) + assert.equal((await entscheide({ projectId: undefined, entscheidung: 'annehmen' })).status, 400) + assert.equal((await entscheide({ entscheidung: 'vielleicht' })).status, 400) + assert.equal((await entscheide({ entscheidung: undefined })).status, 400) + assert.equal((await entscheide({ entscheidung: 'annehmen' }, staff, 'c2')).status, 403, 'Projekt gehört nicht zum Kunden der URL') +}) + +test('Mailfehler: Entscheidung bleibt, Antwort trägt mailFehler', async () => { + await leereAlles() + await stelle() + mails.versandFehler = true + const res = await entscheide({ entscheidung: 'annehmen' }) + assert.equal(res.status, 200) + assert.equal(res.body.mailFehler, true) + assert.equal(res.body.anfrage.status, 'angenommen') + assert.equal(shopDoc().anfrageStatus, 'angenommen') + assert.equal(k24().length, 0) +}) + +test('zwei gleichzeitige Entscheidungen: genau eine gewinnt, genau eine Mail', async () => { + await leereAlles() + await stelle() + mails.sent.length = 0 + const [a, b] = await Promise.all([entscheide({ entscheidung: 'annehmen' }), entscheide({ entscheidung: 'ablehnen', begruendung: 'Nein' })]) + assert.deepEqual([a.status, b.status].sort(), [200, 409]) + assert.equal(k24().length, 1) + assert.equal(shopDoc().anfrageStatus, a.status === 200 ? 'angenommen' : 'abgelehnt') +}) + +test('nach Ablehnung kann der Kunde neu anfragen, die alte Entscheidung ist gelöscht', async () => { + await leereAlles() + await stelle() + await entscheide({ entscheidung: 'ablehnen', begruendung: 'Nein' }) + await stelle() + const doc = shopDoc() + assert.equal(doc.anfrageStatus, 'offen') + assert.equal(doc.anfrageBegruendung, '') + assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200) +}) From 03a7f1d877be704e35cac7b9d2a27bcb81c23a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:43:43 +0000 Subject: [PATCH 046/105] =?UTF-8?q?Webshop:=20amtliche=20Gew=C3=A4hrleistu?= =?UTF-8?q?ngsmitteilung=20(VO=20(EU)=202025/1960=20Anhang=20I)=20als=20Gr?= =?UTF-8?q?afik=20und=20w=C3=B6rtlicher=20Text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grafik aus dem Amtsblatt (ABl. L vom 2.10.2025, S. 5) ausgeschnitten; Text der Grafik wörtlich für Alternativtext und Bestellbestätigung. Co-Authored-By: Claude Opus 5.5 --- docs/funktionen/shop-muster-rechtstexte.md | 20 ++++++++++++++++---- public/img/gewaehrleistung-eu.webp | Bin 0 -> 341752 bytes server/services/shop/rechtstexte.js | 20 ++++++++++++++------ test/shopRechtstexte.test.js | 5 +++-- 4 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 public/img/gewaehrleistung-eu.webp diff --git a/docs/funktionen/shop-muster-rechtstexte.md b/docs/funktionen/shop-muster-rechtstexte.md index 01d53d3..495e893 100644 --- a/docs/funktionen/shop-muster-rechtstexte.md +++ b/docs/funktionen/shop-muster-rechtstexte.md @@ -20,7 +20,7 @@ Stand: 2026-10-05. - Rücksendekosten: Standard ist „Käufer trägt“. Variante „Händler trägt“ steht unten. - Abholung: Bietet der Shop Abholung an, kommt der Satz „Haben Sie die Waren bei uns abgeholt …“ hinzu. - Ausschlussgründe: Der letzte Absatz der Belehrung nennt § 312g Abs. 2 Nr. 1, 2 und 3 BGB in allgemeiner Form. Ob und für welche Waren er zutrifft, entscheidet der Händler; Einordnung von Lieferung und Abholung von Speisen (§ 312g Abs. 2 Nr. 9, § 312 Abs. 2 Nr. 8 BGB) ist offen (Recherche, Offene Punkte 6). -- Wortlaut des Gewährleistungshinweises: im Code als „Wortlaut prüfen (Kanzlei)“ markiert; der amtliche deutsche Wortlaut von Anhang I ist einzusetzen. +- Gewährleistungshinweis: amtliche Grafik und wörtlicher Text aus Anhang I eingesetzt; offen bleibt nur der Abgleich der Grafikdatei mit der Originaldatei der Kommission. - Widerrufsfunktion: Erreichbarkeit ohne Login bei Gastbestellungen, Identifikation der Bestellung und Zeitstempel mit der Kanzlei abstimmen (Recherche, Offene Punkte 7). ## Widerrufsbelehrung @@ -143,8 +143,20 @@ Bei Kleinunternehmern steht in § 3 statt „Alle Preise enthalten die gesetzlic ## Hinweis auf das gesetzliche Gewährleistungsrecht (Kasse und Bestellbestätigung) -Wortlaut prüfen (Kanzlei): sinngemäße Fassung, bis der amtliche Wortlaut von Anhang I eingesetzt ist. +Amtliche Grafik nach Durchführungsverordnung (EU) 2025/1960 Anhang I (ABl. L vom 2.10.2025), nicht editierbar, online farbig: `public/img/gewaehrleistung-eu.webp` (aus dem Amtsblatt ausgeschnitten; vor Livegang mit der Originaldatei der Kommission abgleichen, Kanzlei-Frage). Wörtlicher Text der Grafik (Alternativtext, Bestellbestätigung): -Gesetzliches Gewährleistungsrecht +GESETZLICHE GEWÄHRLEISTUNG -Für Waren besteht ein gesetzliches Gewährleistungsrecht von mindestens zwei Jahren ab Lieferung der Ware. Ist die Ware nicht vertragsgemäß, haben Sie zunächst Anspruch auf kostenlose Nachbesserung (Reparatur) oder Ersatzlieferung; unter den gesetzlichen Voraussetzungen können Sie stattdessen den Preis mindern oder vom Vertrag zurücktreten. Diese Rechte bestehen unabhängig von einer Garantie. +Mindestens zwei Jahre gesetzliche Gewährleistung der Vertragsmäßigkeit für Waren, die in der Europäischen Union verkauft werden. + +Verbraucherinnen und Verbraucher können ihre Rechte im Rahmen des gesetzlichen Gewährleistungsrechts geltend machen, z. B. wenn die Waren nicht der Beschreibung entsprechen, nicht bestimmungsgemäß funktionieren. + +Verkäufer haften für jede Vertragswidrigkeit, die zum Zeitpunkt der Lieferung der Waren bestand und innerhalb des Zeitraums der gesetzlichen Gewährleistung erkennbar wird. Verkäufer müssen in solchen Fällen Folgendes anbieten: kostenlose Nachbesserung oder kostenlose Ersatzlieferung, in bestimmten Fällen eine Preisminderung oder eine vollständige Erstattung des Kaufpreises. + +In einigen Ländern gilt ein längerer Zeitraum für die gesetzliche Gewährleistung. Für gebrauchte Waren kann ein kürzerer Zeitraum gelten, jedoch nicht weniger als ein Jahr. + +Für weitere Informationen zu Ihren Rechten in einem bestimmten Land scannen Sie den nachstehenden QR-Code oder fragen Sie den Verkäufer. europa.eu/youreurope/garantien + +Was ist zu tun, wenn Sie vertragswidrige Waren erhalten? 1. Melden Sie dem Verkäufer das Problem so bald wie möglich. 2. Legen Sie einen Kaufnachweis vor, z. B. die Quittung, Rechnung oder einen Kontoauszug. + +Verkäufer und Hersteller können auch gewerbliche Garantien gewähren, die unabhängig von der gesetzlichen Gewährleistung gelten. Diese GARAN-Kennzeichnung zeigt beispielsweise, dass der Hersteller eine gewerbliche Haltbarkeitsgarantie ohne zusätzliche Kosten gewährt, die die gesamte Ware abdeckt. diff --git a/public/img/gewaehrleistung-eu.webp b/public/img/gewaehrleistung-eu.webp new file mode 100644 index 0000000000000000000000000000000000000000..ded55550a5ba364e4bfee0c216de074d793f84ea GIT binary patch literal 341752 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%0A~BiG;+* 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

6tNZ?g-9I?p#uHoIR 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 literal 0 HcmV?d00001 diff --git a/server/services/shop/rechtstexte.js b/server/services/shop/rechtstexte.js index 54c276e..3b40ea0 100644 --- a/server/services/shop/rechtstexte.js +++ b/server/services/shop/rechtstexte.js @@ -195,15 +195,23 @@ export function versandUndZahlung(e) { } /** - * Harmonisierter Hinweis auf das gesetzliche Gewährleistungsrecht (Art. 246a § 1 Abs. 1 S. 1 Nr. 11 EGBGB, VO (EU) 2025/1960 Anhang I, - * seit 27.09.2026), in Kasse hervorgehoben. - * Wortlaut prüfen (Kanzlei): Der amtliche deutsche Wortlaut von Anhang I stand in der Recherche nicht vor (EUR-Lex nicht geladen); - * dies ist eine sinngemaesse Fassung (Mindestdauer zwei Jahre, wichtigste Elemente). Grafik/Symbol und Farbe kommen spaeter. + * Harmonisierte Mitteilung ueber das gesetzliche Gewaehrleistungsrecht (Art. 246a § 1 Abs. 1 S. 1 Nr. 11 EGBGB, + * Durchfuehrungsverordnung (EU) 2025/1960 Anhang I, ABl. L vom 2.10.2025, seit 27.09.2026). Die Mitteilung ist eine feste Grafik + * (keines der Elemente editierbar, online farbig RGB): public/img/gewaehrleistung-eu.webp, aus dem Amtsblatt ausgeschnitten. + * Dieser Text ist der woertliche Text der Grafik (Reihenfolge der Grafik), fuer Alternativtext und Bestellbestaetigung. */ +export const GEWAEHRLEISTUNG_BILD = '/img/gewaehrleistung-eu.webp' + export function gewaehrleistungsHinweis() { return [ - 'Gesetzliches Gewährleistungsrecht', - 'Für Waren besteht ein gesetzliches Gewährleistungsrecht von mindestens zwei Jahren ab Lieferung der Ware. Ist die Ware nicht vertragsgemäß, haben Sie zunächst Anspruch auf kostenlose Nachbesserung (Reparatur) oder Ersatzlieferung; unter den gesetzlichen Voraussetzungen können Sie stattdessen den Preis mindern oder vom Vertrag zurücktreten. Diese Rechte bestehen unabhängig von einer Garantie.', + 'GESETZLICHE GEWÄHRLEISTUNG', + 'Mindestens zwei Jahre gesetzliche Gewährleistung der Vertragsmäßigkeit für Waren, die in der Europäischen Union verkauft werden.', + 'Verbraucherinnen und Verbraucher können ihre Rechte im Rahmen des gesetzlichen Gewährleistungsrechts geltend machen, z. B. wenn die Waren nicht der Beschreibung entsprechen, nicht bestimmungsgemäß funktionieren.', + 'Verkäufer haften für jede Vertragswidrigkeit, die zum Zeitpunkt der Lieferung der Waren bestand und innerhalb des Zeitraums der gesetzlichen Gewährleistung erkennbar wird. Verkäufer müssen in solchen Fällen Folgendes anbieten: kostenlose Nachbesserung oder kostenlose Ersatzlieferung, in bestimmten Fällen eine Preisminderung oder eine vollständige Erstattung des Kaufpreises.', + 'In einigen Ländern gilt ein längerer Zeitraum für die gesetzliche Gewährleistung. Für gebrauchte Waren kann ein kürzerer Zeitraum gelten, jedoch nicht weniger als ein Jahr.', + 'Für weitere Informationen zu Ihren Rechten in einem bestimmten Land scannen Sie den nachstehenden QR-Code oder fragen Sie den Verkäufer. europa.eu/youreurope/garantien', + 'Was ist zu tun, wenn Sie vertragswidrige Waren erhalten? 1. Melden Sie dem Verkäufer das Problem so bald wie möglich. 2. Legen Sie einen Kaufnachweis vor, z. B. die Quittung, Rechnung oder einen Kontoauszug.', + 'Verkäufer und Hersteller können auch gewerbliche Garantien gewähren, die unabhängig von der gesetzlichen Gewährleistung gelten. Diese GARAN-Kennzeichnung zeigt beispielsweise, dass der Hersteller eine gewerbliche Haltbarkeitsgarantie ohne zusätzliche Kosten gewährt, die die gesamte Ware abdeckt.', ].join('\n\n') } diff --git a/test/shopRechtstexte.test.js b/test/shopRechtstexte.test.js index cc82444..87e955f 100644 --- a/test/shopRechtstexte.test.js +++ b/test/shopRechtstexte.test.js @@ -180,8 +180,9 @@ test('zahlungsartenText: feste Reihenfolge, nur aktive Arten', () => { test('Gewaehrleistungshinweis: zwei Jahre, stabil, ohne Platzhalter', () => { const t = gewaehrleistungsHinweis() - assert.ok(t.startsWith('Gesetzliches Gewährleistungsrecht')) - assert.ok(t.includes('mindestens zwei Jahren')) + assert.ok(t.startsWith('GESETZLICHE GEWÄHRLEISTUNG')) + assert.ok(t.includes('Mindestens zwei Jahre gesetzliche Gewährleistung der Vertragsmäßigkeit')) + assert.ok(t.includes('europa.eu/youreurope/garantien')) assert.equal(gewaehrleistungsHinweis(), t) }) From da43ee2b413681dc00efd31cb40462200e3412c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:44:10 +0000 Subject: [PATCH 047/105] =?UTF-8?q?Shop=20D1:=20Connect-Dienst=20(Konto,?= =?UTF-8?q?=20Onboarding,=20Checkout,=20Erstattung,=20Webhook-Pr=C3=BCfung?= =?UTF-8?q?)=20und=20Fake-Stripe=20f=C3=BCr=20Connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/shop/connect.js | 159 +++++++++++++++++++++ test/shopConnect.test.js | 242 ++++++++++++++++++++++++++++++++ test/support/fakeStripe.js | 158 ++++++++++++++++++++- 3 files changed, 556 insertions(+), 3 deletions(-) create mode 100644 server/services/shop/connect.js create mode 100644 test/shopConnect.test.js diff --git a/server/services/shop/connect.js b/server/services/shop/connect.js new file mode 100644 index 0000000..a84401a --- /dev/null +++ b/server/services/shop/connect.js @@ -0,0 +1,159 @@ +import { config } from '../../config.js' +import { getStripe } from '../stripeBilling.js' + +/** + * Stripe Connect fuer den Webshop (Plan 2026-10-05-webshop.md, Task D1). Der Haendler verkauft ueber ein eigenes + * Stripe-Konto (Accounts v1 mit controller-Eigenschaften = Standard-aehnlich: eigenes Voll-Dashboard, Stripe-Gebuehren + * beim Haendler, Stripe haftet fuer Negativsalden). Zahlungen sind Direct Charges: alle Aufrufe auf Ressourcen des Kontos + * (Checkout, Erstattung) tragen { stripeAccount: accountId }. Konto selbst und Kontolinks sind Plattform-Aufrufe. + * Fehler: Error mit status (400 = ungueltige Eingabe/Signatur, 503 = nicht eingerichtet), Texte deutsch und ohne Kundendaten. + */ + +const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/ +const SESSION_RE = /^cs_[A-Za-z0-9_]+$/ +const PAYMENT_INTENT_RE = /^pi_[A-Za-z0-9_]+$/ +const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/ + +function fehler(status, text) { + const err = new Error(text) + err.status = status + return err +} + +function pruefeKonto(accountId) { + if (typeof accountId !== 'string' || !ACCOUNT_RE.test(accountId)) throw fehler(400, 'Ungültige Stripe-Konto-ID.') + return accountId +} + +function pruefeSession(id) { + if (typeof id !== 'string' || !SESSION_RE.test(id)) throw fehler(400, 'Ungültige Checkout-Sitzung.') + return id +} + +/** https-Adresse der Live-Domain des Projekts (ohne Pfad), sonst undefined. */ +function liveUrl(project) { + const host = String(project?.liveDomain || '') + .trim() + .toLowerCase() + .replace(/^[a-z]+:\/\//, '') + .replace(/[/?#].*$/, '') + return HOST_RE.test(host) ? `https://${host}` : undefined +} + +/** Legt das Haendlerkonto an und liefert die Konto-ID. Idempotent je Projekt (Stripe liefert bei gleichem Schluessel dasselbe Konto). */ +export async function kontoAnlegen({ customer, project }) { + const customerId = String(customer?.$id || customer?.id || '') + const projectId = String(project?.$id || project?.id || '') + if (!projectId) throw fehler(400, 'Projekt fehlt.') + const url = liveUrl(project) + const name = String(project?.projectName || project?.subdomain || '').trim().slice(0, 100) + const businessProfile = { ...(name ? { name } : {}), ...(url ? { url } : {}) } + const account = await getStripe().accounts.create( + { + country: 'DE', + ...(customer?.email ? { email: String(customer.email) } : {}), + ...(Object.keys(businessProfile).length ? { business_profile: businessProfile } : {}), + controller: { + fees: { payer: 'account' }, + losses: { payments: 'stripe' }, + stripe_dashboard: { type: 'full' }, + requirement_collection: 'stripe', + }, + capabilities: { card_payments: { requested: true }, transfers: { requested: true } }, + metadata: { webklar: 'shop', customerId, projectId }, + }, + { idempotencyKey: `shop-konto-${projectId}` } + ) + return account.id +} + +/** Einmaliger Onboarding-Link (laeuft nach wenigen Minuten ab, nie per Mail verschicken). */ +export async function onboardingLink(accountId, { returnUrl, refreshUrl }) { + pruefeKonto(accountId) + const link = await getStripe().accountLinks.create({ + account: accountId, + type: 'account_onboarding', + refresh_url: refreshUrl, + return_url: returnUrl, + collection_options: { fields: 'eventually_due' }, + }) + return link.url +} + +/** Bereitschaftsstatus aus einem Konto-Objekt (auch fuer den Webhook account.updated). Rein bis auf die Zeit. */ +export function statusAusKonto(account, now = new Date()) { + const req = account?.requirements || {} + return { + charges_enabled: account?.charges_enabled === true, + payouts_enabled: account?.payouts_enabled === true, + details_submitted: account?.details_submitted === true, + currently_due: Array.isArray(req.currently_due) ? [...req.currently_due] : [], + past_due: Array.isArray(req.past_due) ? [...req.past_due] : [], + disabled_reason: req.disabled_reason ?? null, + geprueftAt: new Date(now).toISOString(), + } +} + +export async function kontoStatus(accountId) { + pruefeKonto(accountId) + return statusAusKonto(await getStripe().accounts.retrieve(accountId)) +} + +/** Checkout-Sitzung auf dem Haendlerkonto (Direct Charge). */ +export async function checkoutAnlegen(accountId, params, { idempotencyKey } = {}) { + pruefeKonto(accountId) + const session = await getStripe().checkout.sessions.create(params, { + stripeAccount: accountId, + ...(idempotencyKey ? { idempotencyKey } : {}), + }) + return { id: session.id, url: session.url } +} + +export async function sessionHolen(accountId, id, { expand } = {}) { + pruefeKonto(accountId) + pruefeSession(id) + return getStripe().checkout.sessions.retrieve(id, expand?.length ? { expand } : {}, { stripeAccount: accountId }) +} + +/** Offene Sitzung beenden (Stripe lehnt ab, wenn sie nicht mehr offen ist). */ +export async function sessionAblaufen(accountId, id) { + pruefeKonto(accountId) + pruefeSession(id) + return getStripe().checkout.sessions.expire(id, {}, { stripeAccount: accountId }) +} + +/** Erstattung auf dem Haendlerkonto; ohne betragCent wird voll erstattet. */ +export async function erstatten(accountId, { paymentIntent, betragCent, grund, idempotencyKey } = {}) { + pruefeKonto(accountId) + if (typeof paymentIntent !== 'string' || !PAYMENT_INTENT_RE.test(paymentIntent)) throw fehler(400, 'Ungültige Zahlung.') + if (betragCent !== undefined && (!Number.isInteger(betragCent) || betragCent <= 0)) throw fehler(400, 'Ungültiger Erstattungsbetrag.') + const text = typeof grund === 'string' ? grund.trim().slice(0, 500) : '' + return getStripe().refunds.create( + { + payment_intent: paymentIntent, + ...(betragCent !== undefined ? { amount: betragCent } : {}), + reason: 'requested_by_customer', + metadata: { webklar: 'shop', ...(text ? { grund: text } : {}) }, + }, + { stripeAccount: accountId, ...(idempotencyKey ? { idempotencyKey } : {}) } + ) +} + +/** + * Prueft Signatur des Connect-Endpoints und liefert das Ereignis. 503 ohne Secret, 400 bei ungueltiger Signatur. + * Mit Live-Schluessel zaehlen nur Live-Ereignisse (Connect-Endpoints bekommen auch Test-Ereignisse): sonst null, der + * Aufrufer ignoriert sie (200 ohne Wirkung). + */ +export function connectEreignis(rawBody, signatur) { + const secret = config.shop.connectWebhookSecret + if (!secret) throw fehler(503, 'Der Connect-Webhook ist nicht eingerichtet.') + const stripe = getStripe() + let event + try { + event = stripe.webhooks.constructEvent(rawBody, signatur, secret) + } catch { + throw fehler(400, 'Ungültige Webhook-Signatur.') + } + if (String(config.stripe.secretKey).startsWith('sk_live') && event.livemode !== true) return null + return event +} diff --git a/test/shopConnect.test.js b/test/shopConnect.test.js new file mode 100644 index 0000000..4b42447 --- /dev/null +++ b/test/shopConnect.test.js @@ -0,0 +1,242 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import Stripe from 'stripe' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +// Connect-Dienst des Webshops (Task D1) gegen den Stripe-Nachbau: gesendete Parameter, Kopfzeile Stripe-Account, +// Statusabbildung, Webhook-Signatur und Livemode-Regel. +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const connect = await import('../server/services/shop/connect.js') + +const signer = new Stripe('sk_test_fake') +const SECRET = 'whsec_connect_test' +const KEY = config.stripe.secretKey +const customer = { $id: 'cust1', email: 'inhaber@beispiel.de' } +const project = { $id: 'proj1', projectName: 'Bäckerei Muster', liveDomain: 'https://www.baeckerei-muster.de/shop?x=1' } + +after(() => { + config.stripe.secretKey = KEY + config.shop.connectWebhookSecret = '' + return st.close() +}) +beforeEach(() => { + st.calls.length = 0 + config.stripe.secretKey = KEY + config.shop.connectWebhookSecret = SECRET +}) + +const signiere = (event, secret = SECRET) => { + const payload = JSON.stringify(event) + return { payload, signatur: signer.webhooks.generateTestHeaderString({ payload, secret }) } +} +const rejects = (fn, status) => assert.rejects(Promise.resolve().then(fn), (err) => err.status === status) + +test('kontoAnlegen sendet controller, Capabilities, Profil, Metadaten und Idempotenz-Schluessel', async () => { + const id = await connect.kontoAnlegen({ customer, project }) + assert.match(id, /^acct_/) + const [call] = st.calls + assert.equal(call.method, 'POST') + assert.equal(call.path, '/v1/accounts') + assert.equal(call.account, undefined) + assert.equal(call.idempotencyKey, 'shop-konto-proj1') + assert.deepEqual(call.body, { + country: 'DE', + email: 'inhaber@beispiel.de', + 'business_profile[name]': 'Bäckerei Muster', + 'business_profile[url]': 'https://www.baeckerei-muster.de', + 'controller[fees][payer]': 'account', + 'controller[losses][payments]': 'stripe', + 'controller[stripe_dashboard][type]': 'full', + 'controller[requirement_collection]': 'stripe', + 'capabilities[card_payments][requested]': 'true', + 'capabilities[transfers][requested]': 'true', + 'metadata[webklar]': 'shop', + 'metadata[customerId]': 'cust1', + 'metadata[projectId]': 'proj1', + }) + const konto = st.accounts.get(id) + assert.equal(konto.controller.stripe_dashboard.type, 'full') + assert.equal(konto.charges_enabled, false) +}) + +test('kontoAnlegen: gleicher Projekt-Schluessel liefert dasselbe Konto, ohne Live-Domain keine business_profile.url', async () => { + const a = await connect.kontoAnlegen({ customer, project }) + const b = await connect.kontoAnlegen({ customer, project }) + assert.equal(a, b) + st.calls.length = 0 + await connect.kontoAnlegen({ customer, project: { $id: 'proj2', projectName: 'Ohne Domain' } }) + assert.equal(st.calls[0].body['business_profile[url]'], undefined) + assert.equal(st.calls[0].body['business_profile[name]'], 'Ohne Domain') + st.calls.length = 0 + await connect.kontoAnlegen({ customer, project: { $id: 'proj3', projectName: 'Kaputt', liveDomain: 'nicht gültig' } }) + assert.equal(st.calls[0].body['business_profile[url]'], undefined) +}) + +test('onboardingLink sendet Konto, Typ, Rueck-URLs und eventually_due', async () => { + const id = await connect.kontoAnlegen({ customer, project }) + st.calls.length = 0 + const url = await connect.onboardingLink(id, { returnUrl: 'https://p.test/zurueck', refreshUrl: 'https://p.test/neu' }) + assert.match(url, /^https:\/\/connect\.stripe\.test\/setup\//) + assert.deepEqual(st.calls[0].body, { + account: id, + type: 'account_onboarding', + refresh_url: 'https://p.test/neu', + return_url: 'https://p.test/zurueck', + 'collection_options[fields]': 'eventually_due', + }) +}) + +test('kontoStatus bildet unfertige und fertige Konten ab', async () => { + const id = await connect.kontoAnlegen({ customer, project }) + const neu = await connect.kontoStatus(id) + assert.deepEqual({ ...neu, geprueftAt: undefined }, { + charges_enabled: false, + payouts_enabled: false, + details_submitted: false, + currently_due: ['external_account'], + past_due: [], + disabled_reason: null, + geprueftAt: undefined, + }) + assert.ok(!Number.isNaN(Date.parse(neu.geprueftAt))) + st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [] } }) + const fertig = await connect.kontoStatus(id) + assert.equal(fertig.charges_enabled, true) + assert.equal(fertig.payouts_enabled, true) + assert.equal(fertig.details_submitted, true) + assert.deepEqual(fertig.currently_due, []) + st.setAccount(id, { charges_enabled: false, requirements: { past_due: ['individual.id_number'], disabled_reason: 'requirements.past_due' } }) + const gesperrt = await connect.kontoStatus(id) + assert.deepEqual(gesperrt.past_due, ['individual.id_number']) + assert.equal(gesperrt.disabled_reason, 'requirements.past_due') +}) + +test('statusAusKonto ist rein und robust gegen fehlende Felder', () => { + const now = new Date('2026-10-05T10:00:00Z') + assert.deepEqual(connect.statusAusKonto({}, now), { + charges_enabled: false, payouts_enabled: false, details_submitted: false, + currently_due: [], past_due: [], disabled_reason: null, geprueftAt: '2026-10-05T10:00:00.000Z', + }) + const eingabe = { charges_enabled: true, requirements: { currently_due: ['a'] } } + const aus = connect.statusAusKonto(eingabe, now) + aus.currently_due.push('b') + assert.deepEqual(eingabe.requirements.currently_due, ['a']) +}) + +test('Checkout, Abruf, Ablauf: Stripe-Account-Kopfzeile, Idempotenz, Ablage je Konto', async () => { + const id = await connect.kontoAnlegen({ customer, project }) + const other = 'acct_andere1' + st.calls.length = 0 + const params = { mode: 'payment', success_url: 'https://x.test/ok', cancel_url: 'https://x.test/nein', metadata: { wkShop: '1', orderId: 'o1' }, expires_at: 1900000000 } + const sess = await connect.checkoutAnlegen(id, params, { idempotencyKey: 'shop-checkout-o1' }) + assert.match(sess.id, /^cs_/) + assert.match(sess.url, /^https:\/\/checkout\.stripe\.test\//) + assert.equal(st.calls[0].account, id) + assert.equal(st.calls[0].idempotencyKey, 'shop-checkout-o1') + assert.equal(st.calls[0].body['metadata[wkShop]'], '1') + const again = await connect.checkoutAnlegen(id, params, { idempotencyKey: 'shop-checkout-o1' }) + assert.equal(again.id, sess.id) + assert.equal(st.accountSessions(id).size, 1) + assert.equal(st.checkoutSessions.size, 0, 'Plattform-Ablage bleibt unberuehrt') + + const geholt = await connect.sessionHolen(id, sess.id, { expand: ['payment_intent'] }) + assert.equal(geholt.status, 'open') + const get = st.calls.at(-1) + assert.equal(get.account, id) + assert.equal(get.query['expand[0]'], 'payment_intent') + await assert.rejects(connect.sessionHolen(other, sess.id), (err) => err.statusCode === 404) + + const expired = await connect.sessionAblaufen(id, sess.id) + assert.equal(expired.status, 'expired') + assert.equal(st.calls.at(-1).account, id) + assert.equal(st.calls.at(-1).path, `/v1/checkout/sessions/${sess.id}/expire`) + await assert.rejects(connect.sessionAblaufen(id, sess.id), (err) => err.statusCode === 400) +}) + +test('erstatten: Teil- und Restbetrag, Konto-Kopfzeile, Idempotenz, Obergrenze', async () => { + const id = await connect.kontoAnlegen({ customer, project }) + st.setPaymentIntent(id, 'pi_abc', { amount: 2500 }) + st.calls.length = 0 + const teil = await connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1000, grund: 'Artikel beschädigt', idempotencyKey: 'shop-erstattung-o1-1' }) + assert.equal(teil.amount, 1000) + assert.deepEqual(st.calls[0].body, { + payment_intent: 'pi_abc', + amount: '1000', + reason: 'requested_by_customer', + 'metadata[webklar]': 'shop', + 'metadata[grund]': 'Artikel beschädigt', + }) + assert.equal(st.calls[0].account, id) + assert.equal(st.calls[0].idempotencyKey, 'shop-erstattung-o1-1') + const wieder = await connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1000, idempotencyKey: 'shop-erstattung-o1-1' }) + assert.equal(wieder.id, teil.id) + assert.equal(st.refunds(id).size, 1) + const rest = await connect.erstatten(id, { paymentIntent: 'pi_abc' }) + assert.equal(rest.amount, 1500, 'ohne Betrag der Rest') + await assert.rejects(connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1 }), (err) => err.statusCode === 400) + await rejects(() => connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 10.5 }), 400) + await rejects(() => connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 0 }), 400) + await rejects(() => connect.erstatten(id, { paymentIntent: '../x', betragCent: 100 }), 400) +}) + +test('Konto-ID wird geprueft, bevor etwas gesendet wird', async () => { + st.calls.length = 0 + for (const bad of ['', 'acct_', 'acct_a/b', 'cus_123', 'acct_ab cd', null, undefined, 'acct_x?y=1']) { + await rejects(() => connect.kontoStatus(bad), 400) + await rejects(() => connect.onboardingLink(bad, { returnUrl: 'https://a.test', refreshUrl: 'https://a.test' }), 400) + await rejects(() => connect.checkoutAnlegen(bad, {}), 400) + await rejects(() => connect.sessionHolen(bad, 'cs_test_1'), 400) + await rejects(() => connect.sessionAblaufen(bad, 'cs_test_1'), 400) + await rejects(() => connect.erstatten(bad, { paymentIntent: 'pi_1' }), 400) + } + await rejects(() => connect.sessionHolen('acct_ok1', '../x'), 400) + assert.equal(st.calls.length, 0) +}) + +test('Stripe-Fehler (Konto unbekannt) kommen als Fehler an', async () => { + await assert.rejects(connect.kontoStatus('acct_gibtesnicht'), (err) => err.statusCode === 404) +}) + +test('connectEreignis: gueltige Signatur liefert das Ereignis', () => { + const event = { id: 'evt_1', object: 'event', type: 'account.updated', account: 'acct_x1', livemode: false, data: { object: { id: 'acct_x1' } } } + const { payload, signatur } = signiere(event) + const aus = connect.connectEreignis(Buffer.from(payload), signatur) + assert.equal(aus.id, 'evt_1') + assert.equal(aus.account, 'acct_x1') + assert.equal(connect.connectEreignis(payload, signatur).id, 'evt_1', 'auch als Text') +}) + +test('connectEreignis: ungueltige oder fehlende Signatur ist 400', () => { + const event = { id: 'evt_1', object: 'event', type: 'account.updated', livemode: false, data: { object: {} } } + const { payload } = signiere(event) + assert.throws(() => connect.connectEreignis(payload, signiere(event, 'whsec_falsch').signatur), (err) => err.status === 400) + assert.throws(() => connect.connectEreignis(payload, undefined), (err) => err.status === 400) + assert.throws(() => connect.connectEreignis(payload, 'quatsch'), (err) => err.status === 400) + const { signatur } = signiere(event) + assert.throws(() => connect.connectEreignis(payload.replace('evt_1', 'evt_2'), signatur), (err) => err.status === 400) +}) + +test('connectEreignis: ohne Secret 503', () => { + config.shop.connectWebhookSecret = '' + const event = { id: 'evt_1', object: 'event', type: 'x', livemode: false, data: { object: {} } } + const { payload, signatur } = signiere(event) + assert.throws(() => connect.connectEreignis(payload, signatur), (err) => err.status === 503) +}) + +test('connectEreignis: mit Live-Schluessel zaehlen nur Live-Ereignisse', () => { + config.stripe.secretKey = 'sk_live_fake' + const test = { id: 'evt_t', object: 'event', type: 'account.updated', livemode: false, data: { object: {} } } + const live = { id: 'evt_l', object: 'event', type: 'account.updated', livemode: true, data: { object: {} } } + const a = signiere(test) + assert.equal(connect.connectEreignis(a.payload, a.signatur), null) + const b = signiere(live) + assert.equal(connect.connectEreignis(b.payload, b.signatur).id, 'evt_l') + // Signatur wird trotzdem zuerst geprueft + assert.throws(() => connect.connectEreignis(a.payload, b.signatur), (err) => err.status === 400) + // Testschluessel: beide Arten gehen durch + config.stripe.secretKey = KEY + assert.equal(connect.connectEreignis(a.payload, a.signatur).id, 'evt_t') +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index b25df45..8ea8bc3 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -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,112 @@ 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' && (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 +750,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 +766,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 +788,29 @@ 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 + }, + /** 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 From 9d19a2cbadecf6f1a5f986da849c0b3da2c148a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:48:56 +0000 Subject: [PATCH 048/105] =?UTF-8?q?Shop=20E1:=20Bestellung=20anlegen=20(Re?= =?UTF-8?q?servierung=20unter=20Sperre,=20Stripe-Kasse=20auf=20dem=20H?= =?UTF-8?q?=C3=A4ndlerkonto,=20R=C3=BCck-URL-Pr=C3=BCfung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/shop/bestellungen.js | 359 +++++++++++++++++++++++ test/shopBestellungen.test.js | 416 +++++++++++++++++++++++++++ 2 files changed, 775 insertions(+) create mode 100644 server/services/shop/bestellungen.js create mode 100644 test/shopBestellungen.test.js diff --git a/server/services/shop/bestellungen.js b/server/services/shop/bestellungen.js new file mode 100644 index 0000000..ec17f98 --- /dev/null +++ b/server/services/shop/bestellungen.js @@ -0,0 +1,359 @@ +import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto' +import { config } from '../../config.js' +import { createDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { analyseDomains } from '../analyse.js' +import { withLocks } from '../keyedLock.js' +import { checkoutAnlegen } from './connect.js' +import { istGeoeffnet, leereCache } from './kontext.js' +import { berechne } from './preise.js' +import { listeProdukte } from './produkte.js' + +/** + * Bestellungen des Webshops (Plan 2026-10-05-webshop.md, Task E1: Anlegen; E2/E3 ergaenzen Abschluss, Aktionen, Erstattung). + * + * Datenlage einer Bestellung (shopOrders): Spalten shopId, customerId, bestellnummer, status, tokenHash, stripeSessionId, + * paymentIntentId, summeCent, erstelltAt, ablaufAt, bezahltAt, kaeuferEmail, bestellung (JSON-Text, <= 60000), anonymisiertAt. + * bestellung (geparst: bestellDaten(doc), auch als doc.daten aus bestellungLesen): + * { positionen, summen: { zwischensummeCent, versandCent, gesamtCent, steuer, texte }, lieferart, land, anbieter, shopname, + * lieferzeit, zahlungsarten, abholung?, rechtstexteHash, verlauf: [{ at, was, ... }] } + * E2 ergaenzt kaeufer / lieferadresse / erstattungen[] / hinweise im selben JSON. Status offen reserviert Bestand bis ablaufAt. + * Fehler: Error mit status, code und Kaeufertext (Sie-Form) in message. + */ + +const NUMMER_ZEICHEN = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' +const NUMMER_VERSUCHE = 5 +/** Stripe verlangt mindestens 30 Minuten; die Reserve deckt Zeit zwischen Berechnung und Aufruf ab. */ +const SESSION_RESERVE_S = 30 +const MAX_JSON = 60000 + +const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.' +const ZAHLUNG_TEXT = 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.' +const KLARNA_HINWEIS = + 'Klarna/SEPA ist in Deinem Stripe-Konto nicht aktiviert. Bestellungen wurden deshalb nur mit Karte angeboten. ' + + 'Bitte aktiviere die Zahlungsarten in Deinem Stripe-Dashboard unter Einstellungen > Zahlungsmethoden.' + +const col = () => config.collections.shopOrders +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +function fehler(status, code, text, extra = {}) { + return Object.assign(new Error(text), { status, code, ...extra }) +} + +// --------------------------------------------------------------------------------------------------------------------- +// Hilfen + +/** Gespeicherten JSON-Text einer Bestellung lesen; bei kaputten Daten ein leeres Objekt. */ +export function bestellDaten(doc) { + try { + const d = JSON.parse(doc?.bestellung) + return istObjekt(d) ? d : {} + } catch { + return {} + } +} + +/** Bestellung per ID: Dokument mit zusaetzlichem Feld daten (geparstes bestellung) oder null, wenn es sie nicht gibt. */ +export async function bestellungLesen(orderId) { + if (typeof orderId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(orderId)) return null + let doc + try { + doc = await getDocument(col(), orderId) + } catch (err) { + if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return null + throw err + } + return { ...doc, daten: bestellDaten(doc) } +} + +export const tokenHash = (token) => createHash('sha256').update(String(token ?? '')).digest('hex') + +/** Passt der Token aus der Rueckkehr-Adresse zur Bestellung? Vergleich in konstanter Zeit ueber den SHA-256-Hash. */ +export function tokenPasst(order, token) { + if (typeof token !== 'string' || token === '' || typeof order?.tokenHash !== 'string' || order.tokenHash === '') return false + const a = Buffer.from(tokenHash(token), 'hex') + const b = Buffer.from(order.tokenHash, 'hex') + return a.length === b.length && timingSafeEqual(a, b) +} + +const schluesselFuer = (pos) => (pos.varianteId ? `${pos.produktId}:${pos.varianteId}` : pos.produktId) + +/** + * Menge je Artikel, die in noch laufenden offenen Bestellungen steckt (Status offen, ablaufAt in der Zukunft). + * @returns {Promise>} Schluessel produktId bzw. `${produktId}:${varianteId}` + */ +export async function reserviert(shopId, now = new Date()) { + const nowMs = new Date(now).getTime() + const docs = await listAllDocuments(col(), [Query.equal('shopId', shopId), Query.equal('status', 'offen')]) + const karte = new Map() + for (const doc of docs) { + const ablauf = new Date(doc.ablaufAt).getTime() + if (!(ablauf > nowMs)) continue + const positionen = bestellDaten(doc).positionen + if (!Array.isArray(positionen)) continue + for (const pos of positionen) { + if (typeof pos?.produktId !== 'string' || !Number.isInteger(pos.menge) || pos.menge < 1) continue + const key = schluesselFuer(pos) + karte.set(key, (karte.get(key) ?? 0) + pos.menge) + } + } + return karte +} + +const berlinJahr = (now) => + new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', year: 'numeric' }).format(now).slice(-2) + +/** Bestellnummer `-<6 Zeichen>` (JJ = Jahr in Berlin; Zeichen ohne 0, 1, O, I). Eindeutigkeit prueft der Aufrufer. */ +export function neueBestellnummer(now = new Date()) { + let zeichen = '' + for (let i = 0; i < 6; i += 1) zeichen += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)] + return `${berlinJahr(now)}-${zeichen}` +} + +async function freieBestellnummer(shopId, now) { + for (let i = 0; i < NUMMER_VERSUCHE; i += 1) { + const nummer = neueBestellnummer(now) + const treffer = await listDocuments(col(), [Query.equal('shopId', shopId), Query.equal('bestellnummer', nummer), Query.limit(1)]) + if (treffer.length === 0) return nummer + } + throw fehler(503, 'zahlung_nicht_moeglich', ZAHLUNG_TEXT) +} + +/** + * Rueck-URL fuer success_url/cancel_url: https (http nur, wenn die Portal-Adresse selbst http ist, also in Tests), Host aus + * analyseDomains(project) oder Host der Portal-Adresse, ohne Zugangsdaten. Entfernt wk_*-Parameter und Fragment. Sonst + * `/shop/`. + */ +export function pruefeRueckUrl(rueckUrl, ctx) { + const basis = String(config.publicBaseUrl).replace(/\/$/, '') + const ausweich = `${basis}/shop/${ctx?.shop?.siteKey ?? ''}` + if (typeof rueckUrl !== 'string' || rueckUrl === '' || rueckUrl.length > 2000) return ausweich + let url + let portal = null + try { + url = new URL(rueckUrl) + } catch { + return ausweich + } + try { + portal = new URL(basis) + } catch { + portal = null + } + if (url.username || url.password) return ausweich + const portalHost = portal ? portal.host.toLowerCase() : '' + const istPortal = portal !== null && url.host.toLowerCase() === portalHost && url.protocol === portal.protocol + const betrieb = ctx?.project ? analyseDomains(ctx.project) : [] + const istBetrieb = url.protocol === 'https:' && url.port === '' && betrieb.includes(url.hostname.toLowerCase()) + if (!istPortal && !istBetrieb) return ausweich + if (url.protocol !== 'https:' && !(portal?.protocol === 'http:' && istPortal)) return ausweich + for (const key of [...url.searchParams.keys()]) if (key.startsWith('wk_')) url.searchParams.delete(key) + url.hash = '' + return url.toString() +} + +const mitParametern = (basis, params) => { + const url = new URL(basis) + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) + return url.toString() +} + +/** Artikel-Map fuer berechne: id => normalisiertes Produkt (aus dem JSON-Text), aktiv aus Spalte und JSON. */ +function produktKarte(docs) { + const karte = new Map() + for (const doc of docs) { + let p + try { + p = JSON.parse(doc.produkt) + } catch { + continue + } + if (!istObjekt(p)) continue + karte.set(doc.$id, { ...p, id: doc.$id, aktiv: doc.aktiv !== false && p.aktiv !== false }) + } + return karte +} + +function stripeProduktName(pos, produkte) { + if (!pos.varianteId) return pos.name + const wert = produkte.get(pos.produktId)?.varianten?.werte?.find((w) => w.id === pos.varianteId) + return `${pos.name} – ${wert?.name ?? pos.varianteText}` +} + +function versandName(lieferart, land) { + if (lieferart === 'abholung') return 'Abholung' + return land === 'AT' ? 'Versand nach Österreich' : 'Versand nach Deutschland' +} + +/** Absolute https-Bild-URL des ersten Bildes, sonst undefined (Stripe holt Bilder selbst; http oder lokal geht nicht). */ +function bildUrl(produkt, siteKey) { + const id = Array.isArray(produkt?.bilder) ? produkt.bilder[0] : undefined + if (typeof id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(id)) return undefined + const basis = String(config.publicBaseUrl).replace(/\/$/, '') + if (!basis.startsWith('https://')) return undefined + return `${basis}/api/public/shop/v1/${encodeURIComponent(siteKey)}/bild/${id}-l.webp` +} + +// --------------------------------------------------------------------------------------------------------------------- +// Bestellung anlegen + +/** Stripe lehnt Klarna/SEPA ab, weil das Konto sie nicht aktiviert hat. */ +function zahlartAbgelehnt(err) { + if (err?.type !== 'StripeInvalidRequestError') return false + return /payment_method_types|klarna|sepa/i.test(`${err.param ?? ''} ${err.message ?? ''}`) +} + +async function merkeHinweis(ctx) { + const shopId = ctx.shop.id + await withLocks([`shop-cfg:${shopId}`], async () => { + const doc = await getDocument(config.collections.shopConfig, shopId) + let status = {} + try { + const s = JSON.parse(doc.stripeStatus) + if (istObjekt(s)) status = s + } catch { + status = {} + } + await updateDocument(config.collections.shopConfig, shopId, { stripeStatus: JSON.stringify({ ...status, hinweis: KLARNA_HINWEIS }) }) + }) + leereCache(ctx.shop.siteKey) +} + +async function storniere(orderId, daten, was, now) { + const verlauf = [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), { at: new Date(now).toISOString(), was }] + await updateDocument(col(), orderId, { status: 'storniert', bestellung: JSON.stringify({ ...daten, verlauf }) }) +} + +/** + * Legt eine offene Bestellung an und startet die Stripe-Kasse auf dem Konto des Haendlers. + * Preise, Bestand und Versand kommen immer aus der Datenbank; der Browser liefert nur Artikel, Mengen, Lieferart und Land. + * @param {object} ctx Kontext aus shopPerSiteKey + * @param {{ positionen: Array, lieferart: string, land?: string, rueckUrl?: string }} eingabe + * @param {{ now?: Date }} [opt] + * @returns {Promise<{ url: string, orderId: string, bestellnummer: string }>} + */ +export async function legeBestellungAn(ctx, eingabe, { now = new Date() } = {}) { + const { shop } = ctx + const e = shop.einstellungen + const geschlossen = () => fehler(409, 'geschlossen', GESCHLOSSEN_TEXT) + if (!shop.stripeAccountId) throw geschlossen() + const jetzt = new Date(now) + const { positionen, lieferart, land } = istObjekt(eingabe) ? eingabe : {} + + // 1. Pruefen, rechnen und reservieren: alles unter einer Sperre je Shop, damit der letzte Artikel nur einmal weggeht + const angelegt = await withLocks([`shop-bestand:${shop.id}`], async () => { + const docs = await listeProdukte(shop.id) + if (!(await istGeoeffnet(ctx, { produkte: docs }))) throw geschlossen() + const produkte = produktKarte(docs) + const ergebnis = berechne({ + produkte, + positionen, + lieferart, + land, + einstellungen: e, + reserviert: await reserviert(shop.id, jetzt), + }) + if (!ergebnis.ok) { + const { code, text, produktId } = ergebnis.fehler + const konflikt = code === 'ausverkauft' || code === 'nicht_verfuegbar' || code === 'nur_abholung' + throw fehler(konflikt ? 409 : 400, code, text, produktId ? { produktId } : {}) + } + + const orderId = randomUUID() + const token = randomBytes(32).toString('base64url') + const bestellnummer = await freieBestellnummer(shop.id, jetzt) + const sessionEnde = Math.ceil(jetzt.getTime() / 1000) + config.shop.bestellAblaufMinuten * 60 + SESSION_RESERVE_S + const daten = { + positionen: ergebnis.positionen, + summen: { + zwischensummeCent: ergebnis.zwischensummeCent, + versandCent: ergebnis.versandCent, + gesamtCent: ergebnis.gesamtCent, + steuer: ergebnis.steuer, + texte: ergebnis.texte, + }, + lieferart: ergebnis.lieferart, + land: ergebnis.land, + anbieter: e.anbieter, + shopname: e.shopname, + lieferzeit: e.lieferzeit, + zahlungsarten: e.zahlungsarten, + ...(ergebnis.lieferart === 'abholung' ? { abholung: { adresse: e.abholung.adresse, hinweis: e.abholung.hinweis } } : {}), + rechtstexteHash: shop.rechtstexte.bestaetigt.hash, + verlauf: [{ at: jetzt.toISOString(), was: 'angelegt' }], + } + const json = JSON.stringify(daten) + if (json.length > MAX_JSON) throw fehler(400, 'zu_viele', 'Ihr Warenkorb enthält zu viele verschiedene Artikel.') + await createDocument( + col(), + { + shopId: shop.id, + customerId: shop.customerId, + bestellnummer, + status: 'offen', + tokenHash: tokenHash(token), + summeCent: ergebnis.gesamtCent, + erstelltAt: jetzt.toISOString(), + ablaufAt: new Date(sessionEnde * 1000).toISOString(), + bestellung: json, + }, + orderId + ) + return { orderId, token, bestellnummer, daten, ergebnis, produkte, sessionEnde } + }) + + // 2. Kasse bei Stripe starten (ausserhalb der Sperre: die offene Bestellung haelt den Bestand schon) + const { orderId, token, bestellnummer, daten, ergebnis, produkte, sessionEnde } = angelegt + const rueck = pruefeRueckUrl(eingabe?.rueckUrl, ctx) + const metadata = { wkShop: '1', shopId: shop.id, orderId, bestellnummer } + const parameter = (zahlungsarten) => ({ + mode: 'payment', + line_items: ergebnis.positionen.map((pos) => { + const bild = bildUrl(produkte.get(pos.produktId), shop.siteKey) + return { + quantity: pos.menge, + price_data: { + currency: 'eur', + unit_amount: pos.einzelCent, + product_data: { name: stripeProduktName(pos, produkte), ...(bild ? { images: [bild] } : {}) }, + }, + } + }), + shipping_options: [ + { + shipping_rate_data: { + type: 'fixed_amount', + fixed_amount: { amount: ergebnis.versandCent, currency: 'eur' }, + display_name: versandName(ergebnis.lieferart, ergebnis.land), + }, + }, + ], + ...(ergebnis.lieferart === 'versand' ? { shipping_address_collection: { allowed_countries: [ergebnis.land] } } : {}), + billing_address_collection: 'required', + phone_number_collection: { enabled: true }, + payment_method_types: zahlungsarten, + locale: 'de', + expires_at: sessionEnde, + client_reference_id: orderId, + metadata, + payment_intent_data: { metadata, description: `Bestellung ${bestellnummer} – ${e.shopname}` }, + success_url: mitParametern(rueck, { wk_bestellung: orderId, wk_t: token }), + cancel_url: mitParametern(rueck, { wk_abbruch: '1' }), + }) + + let session + try { + try { + session = await checkoutAnlegen(shop.stripeAccountId, parameter(e.zahlungsarten), { idempotencyKey: `shop-order-${orderId}` }) + } catch (err) { + if (!zahlartAbgelehnt(err) || e.zahlungsarten.every((z) => z === 'card')) throw err + session = await checkoutAnlegen(shop.stripeAccountId, parameter(['card']), { idempotencyKey: `shop-order-${orderId}-card` }) + await merkeHinweis(ctx).catch(() => {}) + } + if (typeof session?.id !== 'string' || typeof session?.url !== 'string' || session.url === '') throw new Error('Stripe lieferte keine Kasse') + } catch { + await storniere(orderId, daten, 'stripe_fehler', jetzt).catch(() => {}) + throw fehler(503, 'zahlung_nicht_moeglich', ZAHLUNG_TEXT) + } + await updateDocument(col(), orderId, { stripeSessionId: session.id }) + return { url: session.url, orderId, bestellnummer } +} diff --git a/test/shopBestellungen.test.js b/test/shopBestellungen.test.js new file mode 100644 index 0000000..d987d31 --- /dev/null +++ b/test/shopBestellungen.test.js @@ -0,0 +1,416 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Bestellung anlegen (E1): Berechnung nur aus der Datenbank, Reservierung unter Sperre, Stripe-Parameter auf dem Haendlerkonto, +// Rueck-URL-Pruefung, Fehlerwege (geschlossen, ausverkauft, Stripe-Fehler, Klarna nicht freigeschaltet). +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + shopOrders: schemaKeys('shopOrders'), + }, + datetimes: SHOP_DATETIMES, +}) +const st = await startFakeStripe() +after(async () => { + await aw.close() + await st.close() +}) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const K = await import('../server/services/shop/kontext.js') +const B = await import('../server/services/shop/bestellungen.js') +const { normalisiereProdukt } = await import('../server/services/shop/produkte.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js') + +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const FEATURE = 'f_p_projekt01_shop' +const SITE_KEY = 'abcdefghijklmnop' +const ACCOUNT = 'acct_haendler1' +const T0 = new Date('2026-10-05T12:00:00.000Z') +const nach = (s) => new Date(T0.getTime() + s * 1000) +const PORTAL = 'https://project.webklar.com' + +const EINSTELLUNGEN = { + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Brot & Mehr', + anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' }, + abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }, + versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null }, + zahlungsarten: ['card'], +} +const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' } +const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN) + +const featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true } +const shopDoc = (over = {}, einstellungen = EINSTELLUNGEN) => ({ + $id: FEATURE, + customerId: KUNDE, + projectId: PROJEKT, + siteKey: SITE_KEY, + anfrageStatus: 'angenommen', + stripeAccountId: ACCOUNT, + stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }), + einstellungen: JSON.stringify(einstellungen), + rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }), + geoeffnet: true, + version: 1, + ...over, +}) +const artikel = (id, produktEingabe = {}) => { + const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...produktEingabe }, { einstellungen: { kleinunternehmer: true } }) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) } +} + +async function aufbau({ shop = {}, einstellungen = EINSTELLUNGEN, produkte = [artikel('a1')], projekt = {} } = {}) { + for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects']) { + for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + } + aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example', ...projekt }) + aw.seed('portalFeatures', featureDoc) + aw.seed('shopConfig', shopDoc(shop, einstellungen)) + for (const p of produkte) aw.seed('shopProducts', p) + st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) + K.leereCache() + return K.shopPerSiteKey(SITE_KEY, { now: T0 }) +} +beforeEach(() => { + st.calls.length = 0 + config.publicBaseUrl = PORTAL + K.leereCache() +}) + +const sessionCalls = () => st.calls.filter((c) => c.path === '/v1/checkout/sessions' && c.method === 'POST') +const orders = () => [...aw.docs('shopOrders').values()] +const eingabe = (over = {}) => ({ positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE', rueckUrl: 'https://www.brot.example/laden?x=1#warenkorb', ...over }) +const rejectsMit = (promise, status, code) => + assert.rejects(promise, (err) => { + assert.equal(err.status, status, err.message) + assert.equal(err.code, code) + return true + }) + +test('neueBestellnummer: JJ-XXXXXX ohne 0, 1, O, I; Jahr nach Berliner Zeit', () => { + for (let i = 0; i < 200; i += 1) assert.match(B.neueBestellnummer(T0), /^26-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/) + // Silvester 23:30 UTC ist in Berlin schon Neujahr + assert.match(B.neueBestellnummer(new Date('2026-12-31T23:30:00.000Z')), /^27-/) + assert.match(B.neueBestellnummer(new Date('2026-12-31T22:30:00.000Z')), /^26-/) +}) + +test('tokenPasst: nur der passende Token, kein Hash als Token', () => { + const token = 'abc123' + const order = { tokenHash: B.tokenHash(token) } + assert.equal(B.tokenPasst(order, token), true) + assert.equal(B.tokenPasst(order, 'abc124'), false) + assert.equal(B.tokenPasst(order, order.tokenHash), false) + assert.equal(B.tokenPasst(order, ''), false) + assert.equal(B.tokenPasst(order, undefined), false) + assert.equal(B.tokenPasst({}, token), false) + assert.equal(B.tokenPasst({ tokenHash: '' }, ''), false) +}) + +test('pruefeRueckUrl: eigene Hosts und Portal ja, fremde, http, Zugangsdaten nein; wk_* und Fragment fallen weg', async () => { + const ctx = await aufbau() + const fallback = `${PORTAL}/shop/${SITE_KEY}` + const p = (u) => B.pruefeRueckUrl(u, ctx) + assert.equal(p('https://www.brot.example/laden?x=1&wk_t=alt&wk_abbruch=1#top'), 'https://www.brot.example/laden?x=1') + assert.equal(p('https://brot.example/'), fallback) // Apex nur, wenn das Projekt weitere Hosts hat (analyseDomains) + assert.equal(p(`${PORTAL}/shop/${SITE_KEY}/seite`), `${PORTAL}/shop/${SITE_KEY}/seite`) + for (const schlecht of ['https://boese.example/laden', 'http://www.brot.example/laden', 'https://www.brot.example.boese.example/', 'https://user:pw@www.brot.example/', 'https://www.brot.example:8443/', 'javascript:alert(1)', '//boese.example', '/relativ', '', undefined, null, 42, 'x'.repeat(3000)]) { + assert.equal(p(schlecht), fallback, String(schlecht).slice(0, 40)) + } + // Ohne Projekt gilt nur das Portal + assert.equal(B.pruefeRueckUrl('https://www.brot.example/', { ...ctx, project: null }), fallback) + // Portal-Adresse http (Tests): http auf dem Portal-Host erlaubt, auf fremden Hosts nicht + config.publicBaseUrl = 'http://127.0.0.1:4000' + assert.equal(B.pruefeRueckUrl('http://127.0.0.1:4000/shop/x?wk_t=1', ctx), 'http://127.0.0.1:4000/shop/x') + assert.equal(B.pruefeRueckUrl('http://www.brot.example/', ctx), 'http://127.0.0.1:4000/shop/' + SITE_KEY) +}) + +test('Normalfall: Bestellung offen, Stripe-Kasse auf dem Haendlerkonto mit allen Parametern', async () => { + const ctx = await aufbau() + const r = await B.legeBestellungAn(ctx, eingabe(), { now: T0 }) + assert.match(r.bestellnummer, /^26-[A-Z2-9]{6}$/) + assert.match(r.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/) + + const [order] = orders() + assert.equal(order.$id, r.orderId) + assert.equal(order.status, 'offen') + assert.equal(order.bestellnummer, r.bestellnummer) + assert.equal(order.shopId, FEATURE) + assert.equal(order.customerId, KUNDE) + assert.equal(order.summeCent, 2 * 1250 + 490) + assert.equal(new Date(order.erstelltAt).toISOString(), T0.toISOString()) + assert.equal(new Date(order.ablaufAt).getTime(), T0.getTime() + (30 * 60 + 30) * 1000) + assert.match(order.stripeSessionId, /^cs_/) + assert.match(order.tokenHash, /^[0-9a-f]{64}$/) + const daten = JSON.parse(order.bestellung) + assert.equal(daten.lieferart, 'versand') + assert.equal(daten.land, 'DE') + assert.equal(daten.positionen[0].menge, 2) + assert.deepEqual({ z: daten.summen.zwischensummeCent, v: daten.summen.versandCent, g: daten.summen.gesamtCent }, { z: 2500, v: 490, g: 2990 }) + assert.equal(daten.anbieter.name, 'Brot & Mehr GmbH') + assert.equal(daten.shopname, 'Brot & Mehr') + assert.equal(daten.lieferzeit, EINSTELLUNGEN.lieferzeit) + assert.deepEqual(daten.zahlungsarten, ['card']) + assert.equal(daten.rechtstexteHash, HASH) + assert.equal(daten.abholung, undefined) + assert.equal(daten.verlauf.length, 1) + assert.equal(daten.verlauf[0].was, 'angelegt') + + const [call] = sessionCalls() + assert.equal(sessionCalls().length, 1) + assert.equal(call.account, ACCOUNT) + assert.equal(call.idempotencyKey, `shop-order-${r.orderId}`) + const b = call.body + assert.equal(b.mode, 'payment') + assert.equal(b['line_items[0][quantity]'], '2') + assert.equal(b['line_items[0][price_data][currency]'], 'eur') + assert.equal(b['line_items[0][price_data][unit_amount]'], '1250') + assert.equal(b['line_items[0][price_data][product_data][name]'], 'Artikel a1') + assert.equal(b['line_items[0][price_data][product_data][images][0]'], undefined) + assert.equal(b['shipping_options[0][shipping_rate_data][type]'], 'fixed_amount') + assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490') + assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][currency]'], 'eur') + assert.equal(b['shipping_options[0][shipping_rate_data][display_name]'], 'Versand nach Deutschland') + assert.equal(b['shipping_address_collection[allowed_countries][0]'], 'DE') + assert.equal(b.billing_address_collection, 'required') + assert.equal(b['phone_number_collection[enabled]'], 'true') + assert.equal(b['payment_method_types[0]'], 'card') + assert.equal(b['payment_method_types[1]'], undefined) + assert.equal(b.locale, 'de') + assert.equal(Number(b.expires_at), Math.ceil(T0.getTime() / 1000) + 30 * 60 + 30) + assert.equal(b.client_reference_id, r.orderId) + for (const prefix of ['metadata', 'payment_intent_data[metadata]']) { + assert.equal(b[`${prefix}[wkShop]`], '1') + assert.equal(b[`${prefix}[shopId]`], FEATURE) + assert.equal(b[`${prefix}[orderId]`], r.orderId) + assert.equal(b[`${prefix}[bestellnummer]`], r.bestellnummer) + } + assert.equal(b['payment_intent_data[description]'], `Bestellung ${r.bestellnummer} – Brot & Mehr`) + const success = new URL(b.success_url) + assert.equal(success.origin + success.pathname, 'https://www.brot.example/laden') + assert.equal(success.searchParams.get('x'), '1') + assert.equal(success.searchParams.get('wk_bestellung'), r.orderId) + assert.equal(success.hash, '') + const token = success.searchParams.get('wk_t') + assert.match(token, /^[A-Za-z0-9_-]{43}$/) + assert.equal(B.tokenPasst(order, token), true) + assert.equal(order.tokenHash.includes(token), false) + const cancel = new URL(b.cancel_url) + assert.equal(cancel.searchParams.get('wk_abbruch'), '1') + assert.equal(cancel.searchParams.get('x'), '1') + assert.equal(cancel.searchParams.get('wk_t'), null) + + // bestellungLesen + const gelesen = await B.bestellungLesen(r.orderId) + assert.equal(gelesen.daten.shopname, 'Brot & Mehr') + assert.equal(gelesen.status, 'offen') + assert.equal(await B.bestellungLesen('gibt-es-nicht'), null) + assert.equal(await B.bestellungLesen('../x'), null) + assert.deepEqual(B.bestellDaten({ bestellung: '{kaputt' }), {}) +}) + +test('Abholung, Variante, Bild und Landeswahl: Namen, Versandzeile und Bild-URL', async () => { + const ctx = await aufbau({ + produkte: [ + artikel('a1', { bilder: ['abc-123'], varianten: { name: 'Größe', werte: [{ name: 'M', preisCent: 2000 }, { name: 'L', preisCent: 2200 }] } }), + artikel('a2'), + ], + }) + const m = ctx && JSON.parse(aw.docs('shopProducts').get('a1').produkt).varianten.werte.find((w) => w.name === 'L') + const abholung = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', varianteId: m.id, menge: 1 }], lieferart: 'abholung', land: undefined }), { now: T0 }) + const a = sessionCalls()[0].body + assert.equal(a['line_items[0][price_data][product_data][name]'], 'Artikel a1 – L') + assert.equal(a['line_items[0][price_data][unit_amount]'], '2200') + assert.equal(a['line_items[0][price_data][product_data][images][0]'], `${PORTAL}/api/public/shop/v1/${SITE_KEY}/bild/abc-123-l.webp`) + assert.equal(a['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '0') + assert.equal(a['shipping_options[0][shipping_rate_data][display_name]'], 'Abholung') + assert.equal(a['shipping_address_collection[allowed_countries][0]'], undefined) + const daten = JSON.parse(aw.docs('shopOrders').get(abholung.orderId).bestellung) + assert.deepEqual(daten.abholung, { adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }) + assert.equal(daten.land, null) + + st.calls.length = 0 + await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a2', menge: 1 }], land: 'AT' }), { now: T0 }) + const v = sessionCalls()[0].body + assert.equal(v['shipping_options[0][shipping_rate_data][display_name]'], 'Versand nach Österreich') + assert.equal(v['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '790') + assert.equal(v['shipping_address_collection[allowed_countries][0]'], 'AT') + + // Bild-URL nur mit https-Portal + st.calls.length = 0 + config.publicBaseUrl = 'http://127.0.0.1:4000' + await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', varianteId: m.id, menge: 1 }], rueckUrl: undefined }), { now: T0 }) + assert.equal(sessionCalls()[0].body['line_items[0][price_data][product_data][images][0]'], undefined) + assert.ok(sessionCalls()[0].body.success_url.startsWith(`http://127.0.0.1:4000/shop/${SITE_KEY}?wk_bestellung=`)) +}) + +test('Preise nur aus der Datenbank: Preis, Summe und Versand vom Browser zaehlen nicht', async () => { + const ctx = await aufbau() + await B.legeBestellungAn( + ctx, + eingabe({ positionen: [{ produktId: 'a1', menge: 1, preisCent: 1, einzelCent: 1, summeCent: 1 }], versandCent: 0, gesamtCent: 1, summeCent: 1 }), + { now: T0 } + ) + const b = sessionCalls()[0].body + assert.equal(b['line_items[0][price_data][unit_amount]'], '1250') + assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490') + assert.equal(orders()[0].summeCent, 1740) +}) + +test('Berechnungsfehler: unbekannter Artikel 409, falsche Menge 400, Lieferart 400; nichts angelegt, kein Stripe-Aufruf', async () => { + const ctx = await aufbau() + await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }), { now: T0 }), 409, 'nicht_verfuegbar') + await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 0 }] }), { now: T0 }), 400, 'menge') + await rejectsMit(B.legeBestellungAn(ctx, eingabe({ lieferart: 'post' }), { now: T0 }), 400, 'lieferart') + await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [] }), { now: T0 }), 400, 'leer') + await rejectsMit(B.legeBestellungAn(ctx, eingabe({ land: 'FR' }), { now: T0 }), 400, 'land') + await rejectsMit(B.legeBestellungAn(ctx, undefined, { now: T0 }), 400, 'leer') + const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }), { now: T0 }).catch((e) => e) + assert.match(err.message, /nicht mehr verfügbar/) + assert.equal(orders().length, 0) + assert.equal(st.calls.length, 0) +}) + +test('Reservierung: der letzte Artikel geht nur einmal weg, abgelaufene Reservierungen geben ihn wieder frei', async () => { + const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] }) + const erste = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }) + assert.deepEqual([...(await B.reserviert(FEATURE, nach(10))).entries()], [['a1', 1]]) + const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: nach(60) }).catch((e) => e) + assert.equal(err.status, 409) + assert.equal(err.code, 'ausverkauft') + assert.match(err.message, /ausverkauft/) + assert.equal(orders().length, 1) + assert.equal(sessionCalls().length, 1) + + // Nach Ablauf (30 min + 30 s) zaehlt die Reservierung nicht mehr + assert.equal((await B.reserviert(FEATURE, nach(31 * 60))).size, 0) + const zweite = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: nach(31 * 60) }) + assert.notEqual(zweite.orderId, erste.orderId) +}) + +test('reserviert: nur offene Bestellungen dieses Shops, Varianten mit Doppelpunkt-Schluessel, Mengen summiert', async () => { + await aufbau() + const ablauf = nach(1800).toISOString() + const pos = (produktId, menge, varianteId = null) => ({ produktId, varianteId, menge }) + const bestellung = (...positionen) => JSON.stringify({ positionen }) + aw.seed('shopOrders', { $id: 'o1', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 2), pos('p2', 1, 'v1')) }) + aw.seed('shopOrders', { $id: 'o2', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 3), pos('p2', 4, 'v1'), pos('p2', 1, 'v2')) }) + aw.seed('shopOrders', { $id: 'o3', shopId: FEATURE, status: 'bezahlt', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 9)) }) + aw.seed('shopOrders', { $id: 'o4', shopId: 'anderer', status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 9)) }) + aw.seed('shopOrders', { $id: 'o5', shopId: FEATURE, status: 'offen', ablaufAt: nach(-5).toISOString(), bestellung: bestellung(pos('p1', 9)) }) + aw.seed('shopOrders', { $id: 'o6', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: '{kaputt' }) + const karte = await B.reserviert(FEATURE, T0) + assert.deepEqual(Object.fromEntries(karte), { p1: 5, 'p2:v1': 5, 'p2:v2': 1 }) +}) + +test('Gleichzeitig: zwei parallele Bestellungen fuer den letzten Artikel, genau eine gewinnt', async () => { + const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] }) + const lauf = () => B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }) + const ergebnisse = await Promise.allSettled([lauf(), lauf(), lauf()]) + assert.equal(ergebnisse.filter((x) => x.status === 'fulfilled').length, 1) + const abgelehnt = ergebnisse.filter((x) => x.status === 'rejected') + assert.equal(abgelehnt.length, 2) + for (const x of abgelehnt) assert.equal(x.reason.code, 'ausverkauft') + assert.equal(orders().length, 1) + assert.equal(sessionCalls().length, 1) +}) + +test('Geschlossener Shop: 409 geschlossen, nichts angelegt, kein Stripe-Aufruf', async () => { + for (const shop of [{ geoeffnet: false }, { stripeStatus: JSON.stringify({ charges_enabled: false }) }, { stripeAccountId: '' }]) { + const ctx = await aufbau({ shop }) + const err = await B.legeBestellungAn(ctx, eingabe(), { now: T0 }).catch((e) => e) + assert.equal(err.status, 409, JSON.stringify(shop)) + assert.equal(err.code, 'geschlossen') + assert.equal(err.message, 'Der Shop ist gerade geschlossen.') + assert.equal(orders().length, 0) + assert.equal(st.calls.length, 0) + } + // Funktion nicht aktiv + const ctx = await aufbau() + await rejectsMit(B.legeBestellungAn({ ...ctx, doc: { ...ctx.doc, state: 'disabled' } }, eingabe(), { now: T0 }), 409, 'geschlossen') + // Kein lieferbarer Artikel mehr + const leer = await aufbau({ produkte: [artikel('a1', { aktiv: false })] }) + await rejectsMit(B.legeBestellungAn(leer, eingabe(), { now: T0 }), 409, 'geschlossen') +}) + +test('Fremde Rueck-URL: Fallback auf die Shop-Seite im Portal', async () => { + const ctx = await aufbau() + await B.legeBestellungAn(ctx, eingabe({ rueckUrl: 'https://boese.example/phishing?x=1' }), { now: T0 }) + const b = sessionCalls()[0].body + const s = new URL(b.success_url) + assert.equal(s.origin + s.pathname, `${PORTAL}/shop/${SITE_KEY}`) + assert.equal(new URL(b.cancel_url).origin + new URL(b.cancel_url).pathname, `${PORTAL}/shop/${SITE_KEY}`) + assert.ok(!b.success_url.includes('boese')) +}) + +test('Stripe lehnt ab: Bestellung storniert (Reservierung frei), 503 mit Kaufertext', async () => { + const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] }) + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'Invalid something' }) + const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }).catch((e) => e) + assert.equal(err.status, 503) + assert.equal(err.code, 'zahlung_nicht_moeglich') + assert.equal(err.message, 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.') + assert.equal(sessionCalls().length, 1) + const [order] = orders() + assert.equal(order.status, 'storniert') + const daten = JSON.parse(order.bestellung) + assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'stripe_fehler']) + assert.equal(order.stripeSessionId ?? '', '') + assert.equal((await B.reserviert(FEATURE, T0)).size, 0) + // Der Artikel ist wieder bestellbar + await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }) +}) + +test('Klarna nicht freigeschaltet: einmal mit Karte wiederholen, Hinweis fuer den Haendler speichern', async () => { + const ctx = await aufbau({ einstellungen: { ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] } }) + assert.deepEqual(ctx.shop.einstellungen.zahlungsarten, ['card', 'klarna']) + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'The payment method type provided: klarna is invalid. Please ensure the provided type is activated in your dashboard (payment_method_types).' }) + const r = await B.legeBestellungAn(ctx, eingabe(), { now: T0 }) + const calls = sessionCalls() + assert.equal(calls.length, 2) + assert.equal(calls[0].body['payment_method_types[1]'], 'klarna') + assert.equal(calls[1].body['payment_method_types[0]'], 'card') + assert.equal(calls[1].body['payment_method_types[1]'], undefined) + assert.equal(calls[1].account, ACCOUNT) + assert.notEqual(calls[1].idempotencyKey, calls[0].idempotencyKey) + assert.equal(calls[1].body.client_reference_id, r.orderId) + assert.equal(calls[1].body.success_url, calls[0].body.success_url) + const [order] = orders() + assert.equal(order.status, 'offen') + assert.match(order.stripeSessionId, /^cs_/) + const status = JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus) + assert.equal(status.charges_enabled, true) + assert.match(status.hinweis, /^Klarna\/SEPA ist in Deinem Stripe-Konto nicht aktiviert/) + // Zwischenspeicher geleert: der naechste Kontext enthaelt den Hinweis + assert.match((await K.shopPerSiteKey(SITE_KEY, { now: T0 })).shop.stripeStatus.hinweis, /Klarna/) +}) + +test('Fehler bei reiner Kartenzahlung wird nicht wiederholt', async () => { + const ctx = await aufbau() + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'bad payment_method_types' }) + await rejectsMit(B.legeBestellungAn(ctx, eingabe(), { now: T0 }), 503, 'zahlung_nicht_moeglich') + assert.equal(sessionCalls().length, 1) + assert.equal(orders()[0].status, 'storniert') +}) + +test('Anderer Stripe-Fehler trotz Klarna-Auswahl: keine Wiederholung mit Karte', async () => { + const ctx = await aufbau({ einstellungen: { ...EINSTELLUNGEN, zahlungsarten: ['card', 'sepa_debit'] } }) + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'No such account: acct_x' }) + await rejectsMit(B.legeBestellungAn(ctx, eingabe(), { now: T0 }), 503, 'zahlung_nicht_moeglich') + assert.equal(sessionCalls().length, 1) + assert.equal(orders()[0].status, 'storniert') + assert.equal(JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus).hinweis, undefined) +}) From 9722f0a9386f28f789bd32ab3d464ef41f4cbf58 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:52:53 +0000 Subject: [PATCH 049/105] Shop C7: Kundenrouten der Verwaltung (Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder) - routes/shop.js: gemeinsame Pruefung mitShop (Schalter, eigenes Projekt, Funktion setup|active, lesend auch disabled, shopConfig bei Bedarf anlegen); GET /status erweitert (Bereitschaft, geoeffnet, Snippet, Vorschau, Stripe ohne Konto-ID, Zahlen); Einstellungen, Artikel (mit Bild-Pruefung, Beanspruchen und Aufraeumen), Sortierung, Rechtstexte (Entwurf, Bestaetigen mit Hash), Oeffnen/Schliessen, Bild-Upload (octet-stream, 8 MB) und Vorschau; Schreib-Limit 300 je Stunde und Kunde - services/shop/bilder.js: Artikelbilder nach dem Muster von newsImages.js (1600/480 px WebP, Sidecar, 24-h-Aufraeumen, 40 Uploads/h) - Kundenansicht: sieben Verwaltungs-Pfade erlaubt und als shop_aenderung protokolliert; Anfrage, Oeffnen, Bestaetigen, Stripe bleiben gesperrt - Tests: shopRoutes, shopBilder; Erwartungen von kundenansichtGuard, customerViewLog und shopAnfrage angepasst Co-Authored-By: Claude Opus 5.5 --- server/middleware/kundenansichtGuard.js | 10 + server/routes/shop.js | 431 +++++++++++++++- server/services/customerViewLog.js | 1 + server/services/shop/bilder.js | 166 +++++++ server/services/shop/produkte.js | 2 +- test/customerViewLog.test.js | 4 +- test/kundenansichtGuard.test.js | 31 +- test/shopAnfrage.test.js | 5 +- test/shopBilder.test.js | 140 ++++++ test/shopRoutes.test.js | 631 ++++++++++++++++++++++++ 10 files changed, 1389 insertions(+), 32 deletions(-) create mode 100644 server/services/shop/bilder.js create mode 100644 test/shopBilder.test.js create mode 100644 test/shopRoutes.test.js diff --git a/server/middleware/kundenansichtGuard.js b/server/middleware/kundenansichtGuard.js index 297d7a7..126ce0b 100644 --- a/server/middleware/kundenansichtGuard.js +++ b/server/middleware/kundenansichtGuard.js @@ -24,6 +24,16 @@ export const KUNDENANSICHT_ALLOWED = [ ['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/], ['PATCH', /^\/api\/termine\/einstellungen\/?$/], ['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/], + // Webshop: Artikel, Einstellungen, Rechtstexte-Entwurf und Bilder darf das Team mit dem Kunden ausfuellen (protokolliert). Genau diese + // sieben Pfade, jeder einzeln: Anfrage, Oeffnen, Bestaetigen der Rechtstexte (der Haendler bestaetigt persoenlich), Stripe und + // Erstattungen bleiben gesperrt, ohne dass es einen Eintrag braucht. + ['POST', /^\/api\/shop\/produkte\/?$/], + ['POST', /^\/api\/shop\/produkte\/sortierung\/?$/], + ['PUT', /^\/api\/shop\/produkte\/[^/]+\/?$/], + ['DELETE', /^\/api\/shop\/produkte\/[^/]+\/?$/], + ['PUT', /^\/api\/shop\/einstellungen\/?$/], + ['PUT', /^\/api\/shop\/rechtstexte\/?$/], + ['POST', /^\/api\/shop\/bild\/?$/], ['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/], ['POST', /^\/api\/auth\/logout\/?$/], ['POST', /^\/api\/auth\/login\/?$/], diff --git a/server/routes/shop.js b/server/routes/shop.js index 80a6629..99fb22e 100644 --- a/server/routes/shop.js +++ b/server/routes/shop.js @@ -1,30 +1,55 @@ -import { Router } from 'express' +import express, { Router } from 'express' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' -import { getDocument } from '../services/appwriteAdmin.js' +import { countDocuments, getDocument, listAllDocuments, Query, updateDocument } from '../services/appwriteAdmin.js' import { config } from '../config.js' -import { isKundenansicht } from '../services/kundenansicht.js' +import { withLocks } from '../services/keyedLock.js' +import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js' import { createWriteLimiter } from '../services/writeLimiter.js' -import { anfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js' +import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js' import { shopFuerKunde } from '../services/shop/schalter.js' +import { uploadSlot } from './chat.js' +import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js' +import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js' +import { + bildIdsInGebrauch, holeProdukt, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt, +} from '../services/shop/produkte.js' +import { + BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular, + normalisiereRechtstexte, rechtstexteHash, versandUndZahlung, vollstaendig, +} from '../services/shop/rechtstexte.js' +import { + BILD_MAX_BYTES, beanspruche, bildLimitErreicht, bildMeta, bildPfad, loescheBild, setzeBildLimitZurueck, speichereBild, +} from '../services/shop/bilder.js' /** - * Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Dieser Stand (Task B1) enthaelt - * die Anfrage: GET /status, POST /anfrage, POST /anfrage/zurueckziehen. Weitere Routen kommen mit C7, D2 und E5. + * Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Stand: Anfrage (B1) und + * Verwaltung (C7): Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder. Stripe (D2) und Bestellungen (E) kommen dazu. * - * Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Schreib-Routen sind in der Kundenansicht gesperrt - * (kundenansichtGuard: nicht auf der Erlaubnisliste; hier zusaetzlich 403) und zaehlen gegen 20 Aufrufe je Kunde und Stunde. + * Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Die Verwaltungs-Routen laufen ueber mitShop: Schalter + * (409 nicht verfuegbar), Funktion im Zustand setup|active (lesend auch disabled), shopConfig vorhanden (setup|active legt es an). + * Kundenansicht: Artikel, Einstellungen, Rechtstexte (nur Entwurf speichern) und Bilder darf das Team mit ausfuellen und werden + * protokolliert (kundenansichtGuard); Anfrage, Oeffnen und Bestaetigen der Rechtstexte bleiben dem Kunden vorbehalten (hier 403). + * Schreib-Routen zaehlen gegen 300 Aufrufe je Kunde und Stunde (Anfrage-Routen gegen 20), Bilder zusaetzlich gegen 40 je Stunde. * Fehler: { error: , fehler?: { : Text }, code? }; Texte von Appwrite oder unbekannten Fehlern verlassen den Server nie. */ const router = Router() -const MAX_WRITES_PER_HOUR = 20 +const MAX_ANFRAGE_WRITES_PER_HOUR = 20 +const MAX_WRITES_PER_HOUR = 300 +const PRODUKT_DOC_MAX_JSON = 100000 +const BILD_DATEI_RE = /^([a-z0-9-]{1,64})-(l|s)\.webp$/ const MELDUNG = { projekt: 'Bitte wähle ein Projekt.', fremd: 'Kein Zugriff auf dieses Projekt.', kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.', zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.', + nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.', + nichtEingerichtet: 'Der Webshop ist für dieses Projekt noch nicht eingerichtet.', + zuVieleBilder: 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.', + bildFehlt: 'Bild nicht gefunden.', + eingabe: 'Die Eingabe fehlt oder ist unlesbar.', } /** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */ @@ -35,7 +60,12 @@ function fail(res, err) { return res.status(500).json({ error: 'Interner Serverfehler' }) } const body = { ...err.body, error: err.message } - if (err.fehler && typeof err.fehler === 'object') body.fehler = err.fehler + if (err.fehler && typeof err.fehler === 'object') { + body.fehler = err.fehler + const erster = Object.values(err.fehler).find((t) => typeof t === 'string') + if (erster) body.error = erster + } + if (typeof err.code === 'string' && /^[A-Za-z0-9_]{1,40}$/.test(err.code) && body.code === undefined) body.code = err.code return res.status(err.status).json(body) } @@ -47,11 +77,15 @@ const route = (fn) => async (req, res, next) => { } } -const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR }) -const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) +const anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR }) +const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) +const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR }) +const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) export function resetShopRoutesForTests() { - limiter.reset() + anfrageLimiter.reset() + schreibLimiter.reset() + setzeBildLimitZurueck() } const nurKunde = (req, res, next) => { @@ -61,7 +95,7 @@ const nurKunde = (req, res, next) => { const person = (req) => ({ name: req.session?.name || '', email: req.session?.email || '' }) const projectIdOf = (req) => { - const raw = req.query.projectId ?? req.body?.projectId + const raw = req.params?.projectId ?? req.query.projectId ?? req.body?.projectId return typeof raw === 'string' ? raw : '' } @@ -70,9 +104,57 @@ router.use((_req, res, next) => { next() }) +const STRIPE_FELDER = ['charges_enabled', 'payouts_enabled', 'details_submitted', 'currently_due', 'past_due', 'disabled_reason', 'geprueftAt'] +const BEZAHLT = ['bezahlt', 'versendet', 'abholbereit', 'abgeschlossen'] + +/** Stripe-Status fuer den Browser: nur bekannte Felder, nie Konto-ID oder Schluessel. */ +function stripeAnsicht(shop) { + const roh = shop?.stripeStatus + const status = roh ? Object.fromEntries(STRIPE_FELDER.filter((k) => Object.hasOwn(roh, k)).map((k) => [k, roh[k]])) : null + return { verbunden: Boolean(shop?.stripeAccountId), status } +} + +/** Kennzahlen der Uebersicht (nur Anzeige): Artikel und, best effort, Bestellungen. Ein Lesefehler bei den Bestellungen zaehlt als 0. */ +async function kennzahlen(shopId, produkte, now = new Date()) { + const zahlen = { + artikel: produkte.length, + aktiveArtikel: produkte.filter((d) => d.aktiv !== false).length, + offeneBestellungen: 0, + bestellungen30: 0, + umsatz30Cent: 0, + } + try { + zahlen.offeneBestellungen = await countDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.equal('status', 'bezahlt')]) + const seit = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + const neu = await listAllDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.greaterThanEqual('erstelltAt', seit.toISOString())]) + for (const o of neu) { + if (!BEZAHLT.includes(o.status)) continue + zahlen.bestellungen30 += 1 + if (Number.isInteger(o.summeCent)) zahlen.umsatz30Cent += o.summeCent + } + } catch (err) { + console.warn(`[shop] Kennzahlen der Bestellungen nicht lesbar: ${String(err?.message ?? '').slice(0, 120)}`) + } + return zahlen +} + +const einbauSnippet = (siteKey) => `

\n` +const vorschauUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}` +const widerrufUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}/widerruf` + +async function ticketWoid(ticketId) { + if (!ticketId) return null + try { + return (await getDocument(config.collections.workorders, ticketId))?.woid || null + } catch { + return null + } +} + /** - * Stand des Webshops fuer ein Projekt: { verfuegbar, state, anfrage }. verfuegbar = Schalter fuer diesen Kunden; - * state = Zustand der Funktion (off, wenn es noch kein Dokument gibt); anfrage = anfrageAnsicht oder null. Legt nichts an. + * Stand des Webshops fuer ein Projekt. verfuegbar = Schalter fuer diesen Kunden; state = Zustand der Funktion (off ohne Dokument); + * anfrage = anfrageAnsicht oder null; mit shopConfig dazu: ticketWoid, bereitschaft ({ bereit, punkte }), geoeffnet (verkauft der Shop + * gerade), schalter (Schalter des Kunden), siteKey, snippet, vorschauUrl, stripe ({ verbunden, status }), zahlen. Legt nichts an. */ router.get('/status', requireSession, route(async (req, res) => { const projectId = projectIdOf(req) @@ -85,18 +167,38 @@ router.get('/status', requireSession, route(async (req, res) => { } const verfuegbar = shopFuerKunde(req.session?.email) const feature = await getFeatureDoc(customerId, projectId, 'shop') - let shop = null - if (feature) { - try { - shop = await getDocument(config.collections.shopConfig, feature.$id) - } catch (err) { - if (!(err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found')) throw err - } + const shopDoc = feature ? await leseShopConfig(feature.$id) : null + const antwort = { + verfuegbar, + state: feature?.state || 'off', + ticketWoid: null, + anfrage: shopDoc ? anfrageAnsicht(shopDoc) : null, + bereitschaft: null, + geoeffnet: false, + schalter: false, + siteKey: feature?.siteKey || null, + snippet: null, + vorschauUrl: null, + stripe: { verbunden: false, status: null }, + zahlen: null, } - res.json({ verfuegbar, state: feature?.state || 'off', anfrage: shop ? anfrageAnsicht(shop) : null }) + if (feature && shopDoc) { + const shop = ladeShop(shopDoc) + const produkte = await listeProdukte(shop.id) + const ctx = { doc: feature, shop, project: null } + antwort.ticketWoid = await ticketWoid(feature.ticketId) + antwort.bereitschaft = await bereitschaft(ctx, { produkte }) + antwort.geoeffnet = await istGeoeffnet(ctx, { produkte }) + antwort.schalter = shop.geoeffnet + antwort.snippet = einbauSnippet(shop.siteKey || feature.siteKey) + antwort.vorschauUrl = vorschauUrl(shop.siteKey || feature.siteKey) + antwort.stripe = stripeAnsicht(shop) + antwort.zahlen = await kennzahlen(shop.id, produkte) + } + res.json(antwort) })) -router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req, res) => { +router.post('/anfrage', requireSession, nurKunde, limitAnfrage, route(async (req, res) => { const anfrage = await stelleAnfrage({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), @@ -106,9 +208,286 @@ router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req, res.json({ anfrage }) })) -router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitWrites, route(async (req, res) => { +router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitAnfrage, route(async (req, res) => { const anfrage = await zieheZurueck({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), person: person(req) }) res.json({ anfrage }) })) +// ---------------------------------------------------------------- Verwaltung (C7) + +const stop = (status, text, code, fehlerFelder) => + Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}), ...(fehlerFelder ? { fehler: fehlerFelder } : {}) }) +const eingabeFehler = (fehlerFelder) => stop(400, Object.values(fehlerFelder)[0], undefined, fehlerFelder) +const personLabel = (p) => (p.name ? `${p.name}${p.email ? ` <${p.email}>` : ''}` : p.email || 'Kunde') + +/** + * Gemeinsame Pruefung der Verwaltungs-Routen: projectId, eigenes Projekt (403; archiviert nur lesend), Schalter (409 + * nicht_verfuegbar), Funktion im Zustand zustaende (Vorgabe: schreibend setup|active, lesend setup|active|disabled; sonst 409 + * nicht_eingerichtet) und shopConfig (fehlt es und der Zustand ist setup|active, wird es angelegt). + * @returns {Promise<{ customerId: string, projectId: string, project: object, doc: object, shopDoc: object, shop: object }>} + */ +async function mitShop(req, { schreiben = false, zustaende } = {}) { + const projectId = projectIdOf(req) + if (!projectId) throw stop(400, MELDUNG.projekt) + const customerId = getSessionCustomerId(req) + let project + try { + project = await assertOwnProject(customerId, projectId, { forNewOrder: schreiben }) + } catch (err) { + throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err + } + if (!shopFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') + const erlaubt = zustaende || (schreiben ? ['setup', 'active'] : ['setup', 'active', 'disabled']) + const doc = await getFeatureDoc(customerId, projectId, 'shop') + if (!doc || !erlaubt.includes(doc.state)) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet') + let shopDoc = await leseShopConfig(doc.$id) + if (!shopDoc && ['setup', 'active'].includes(doc.state)) shopDoc = await ensureShopConfig(doc) + if (!shopDoc) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet') + return { customerId, projectId, project, doc, shopDoc, shop: ladeShop(shopDoc) } +} + +/** Middleware: mitShop vor dem Einlesen eines Uploads; Ergebnis in req.shopCtx. */ +const mitShopSchreibend = route(async (req, _res, next) => { + req.shopCtx = await mitShop(req, { schreiben: true }) + next() +}) + +const protokoll = (req, text) => logIfKundenansicht(req, 'shop_aenderung', text) + +// ---- Einstellungen + +const einstellungenAntwort = (shop) => ({ einstellungen: shop.einstellungen, version: shop.version, vollstaendig: istVollstaendig(shop.einstellungen) }) + +router.get('/einstellungen', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + res.json(einstellungenAntwort(shop)) +})) + +router.put('/einstellungen', requireSession, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true }) + const gespeichert = await speichereEinstellungen(shop.id, req.body?.einstellungen, { erwarteteVersion: Number.isInteger(req.body?.version) ? req.body.version : undefined }) + await protokoll(req, 'Einstellungen gespeichert') + res.json({ einstellungen: gespeichert.einstellungen, version: gespeichert.version, vollstaendig: istVollstaendig(gespeichert.einstellungen) }) +})) + +// ---- Artikel + +/** Artikel fuer den Haendler: alle gespeicherten Felder, dazu id, aktiv, sort und Vorschau-Adressen der Bilder. */ +function produktAnsicht(doc, projectId) { + const p = parseProdukt(doc) || {} + const bilder = Array.isArray(p.bilder) ? p.bilder.filter((b) => typeof b === 'string') : [] + return { + ...p, + id: doc.$id, + name: typeof p.name === 'string' ? p.name : doc.name || '', + aktiv: doc.aktiv !== false, + sort: Number.isInteger(doc.sort) ? doc.sort : 0, + bilder, + bildUrls: bilder.map((id) => ({ + id, + klein: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-s.webp`, + gross: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-l.webp`, + })), + } +} + +const bildIdsVon = (produkt) => (Array.isArray(produkt?.bilder) ? produkt.bilder.filter((b) => typeof b === 'string') : []) + +/** Bilder, die nach einer Aenderung kein Artikel des Shops mehr verwendet, loeschen. */ +async function raeumeBilderAuf(shop, kandidaten) { + if (kandidaten.length === 0) return + const inGebrauch = await bildIdsInGebrauch(shop.id) + for (const id of kandidaten) if (!inGebrauch.has(id)) await loescheBild(shop.siteKey, id) +} + +router.get('/produkte', requireSession, route(async (req, res) => { + const { shop, projectId } = await mitShop(req) + const docs = await listeProdukte(shop.id) + res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)), maxArtikel: config.shop.maxArtikel, maxBilder: config.shop.maxBilder }) +})) + +/** Artikel anlegen (id null) oder aendern: normalisieren, Bilder pruefen und beanspruchen, entfernte Bilder aufraeumen. */ +async function speichereArtikel(req, res, id) { + const { shop, customerId, projectId } = await mitShop(req, { schreiben: true }) + const { produkt, fehler } = normalisiereProdukt(req.body?.produkt, { einstellungen: shop.einstellungen }) + if (fehler) throw eingabeFehler(fehler) + const neueBilder = bildIdsVon(produkt) + const doc = await withLocks([`shop-bilder:${shop.id}`], async () => { + let vorher = [] + if (id) { + const alt = await holeProdukt(shop.id, id) + if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).') + vorher = bildIdsVon(parseProdukt(alt)) + } + for (const bildId of neueBilder) { + const meta = await bildMeta(shop.siteKey, bildId) + if (!meta || meta.shopId !== shop.id) throw eingabeFehler({ bilder: 'Eines der Bilder gibt es nicht (mehr). Bitte lade es neu hoch.' }) + } + const gespeichert = await speichereProdukt({ shopId: shop.id, customerId }, id, produkt) + await beanspruche(shop.siteKey, neueBilder) + await raeumeBilderAuf(shop, vorher.filter((b) => !neueBilder.includes(b))) + return gespeichert + }) + await protokoll(req, `Artikel ${id ? 'geändert' : 'angelegt'}: ${produkt.name}`) + res.status(id ? 200 : 201).json({ produkt: produktAnsicht(doc, projectId) }) +} + +router.post('/produkte', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, null))) + +router.post('/produkte/sortierung', requireSession, limitWrites, route(async (req, res) => { + const { shop, projectId } = await mitShop(req, { schreiben: true }) + const docs = await sortiere(shop.id, req.body?.ids) + await protokoll(req, 'Artikel sortiert') + res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)) }) +})) + +router.put('/produkte/:id', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, req.params.id))) + +router.delete('/produkte/:id', requireSession, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true }) + const name = await withLocks([`shop-bilder:${shop.id}`], async () => { + const alt = await holeProdukt(shop.id, req.params.id) + if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).') + await loescheProdukt(shop.id, req.params.id) + await raeumeBilderAuf(shop, bildIdsVon(parseProdukt(alt))) + return alt.name || '' + }) + await protokoll(req, `Artikel gelöscht: ${name}`) + res.json({ ok: true }) +})) + +// ---- Rechtstexte + +/** Antwort der Rechtstexte: Entwurf, zuletzt bestaetigte Fassung, Muster zum Zuruecksetzen, erzeugte Texte und Hash des Entwurfs. */ +function rechtstexteAntwort(shop) { + const e = shop.einstellungen + const { entwurf, bestaetigt } = shop.rechtstexte + const hash = rechtstexteHash(entwurf, e) + return { + entwurf, + bestaetigt: bestaetigt ? { texte: bestaetigt.texte, at: bestaetigt.at, von: bestaetigt.von, aktuell: bestaetigt.hash === hash } : null, + muster: { + agb: musterAgb(e), + widerrufsbelehrung: musterWiderrufsbelehrung(e, { widerrufUrl: widerrufUrl(shop.siteKey) }), + widerrufsformular: musterWiderrufsformular(e), + }, + versandUndZahlung: versandUndZahlung(e), + gewaehrleistung: { bild: GEWAEHRLEISTUNG_BILD, text: gewaehrleistungsHinweis() }, + hash, + bestaetigungText: BESTAETIGUNG_TEXT, + vollstaendig: vollstaendig(entwurf), + } +} + +/** shopConfig frisch lesen (unter der Sperre) und als geparsten Shop liefern. */ +async function frischerShop(shopId) { + return ladeShop(await getDocument(config.collections.shopConfig, shopId)) +} + +router.get('/rechtstexte', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + res.json(rechtstexteAntwort(shop)) +})) + +router.put('/rechtstexte', requireSession, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true }) + const { rechtstexte, fehler } = normalisiereRechtstexte(req.body?.rechtstexte) + if (fehler) throw eingabeFehler(fehler) + const neu = await withLocks([`shop-rt:${shop.id}`], async () => { + const aktuell = await frischerShop(shop.id) + const json = JSON.stringify({ entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt }) + if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.') + await updateDocument(config.collections.shopConfig, shop.id, { rechtstexte: json }) + return { ...aktuell, rechtstexte: { entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt } } + }) + leereCache(shop.siteKey) + await protokoll(req, 'Rechtstexte-Entwurf gespeichert') + res.json(rechtstexteAntwort(neu)) +})) + +router.post('/rechtstexte/bestaetigen', requireSession, nurKunde, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true }) + const hash = typeof req.body?.hash === 'string' ? req.body.hash : '' + const von = personLabel(person(req)) + const neu = await withLocks([`shop-rt:${shop.id}`], async () => { + const aktuell = await frischerShop(shop.id) + const { entwurf } = aktuell.rechtstexte + if (hash === '' || hash !== rechtstexteHash(entwurf, aktuell.einstellungen)) { + throw stop(409, 'Die Texte wurden inzwischen geändert. Bitte lies sie noch einmal durch und bestätige dann erneut.', 'texte_geaendert') + } + if (!vollstaendig(entwurf)) throw stop(400, 'Bitte fülle AGB, Widerrufsbelehrung, Widerrufsformular, Impressum und Datenschutz aus, bevor Du bestätigst.') + const at = new Date().toISOString() + const bestaetigt = { texte: { ...entwurf }, hash, at, von } + const json = JSON.stringify({ entwurf, bestaetigt }) + if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.') + await updateDocument(config.collections.shopConfig, shop.id, { + rechtstexte: json, + rechtstexteBestaetigtAt: at, + rechtstexteBestaetigtVon: von, + rechtstexteHash: hash, + }) + return { ...aktuell, rechtstexte: { entwurf, bestaetigt } } + }) + leereCache(shop.siteKey) + res.json(rechtstexteAntwort(neu)) +})) + +// ---- Shop oeffnen und schliessen + +router.post('/oeffnen', requireSession, nurKunde, limitWrites, route(async (req, res) => { + if (typeof req.body?.offen !== 'boolean') throw stop(400, 'Bitte wähle, ob der Shop geöffnet oder geschlossen sein soll.') + const { shop, doc } = await mitShop(req, { schreiben: true }) + const produkte = await listeProdukte(shop.id) + const ctx = { doc, shop, project: null } + if (req.body.offen) { + const stand = await bereitschaft(ctx, { produkte }) + if (!stand.bereit) throw Object.assign(stop(409, 'Der Shop kann noch nicht geöffnet werden. Bitte erledige zuerst die offenen Punkte.', 'nicht_bereit'), { body: { code: 'nicht_bereit', punkte: stand.punkte } }) + } + await updateDocument(config.collections.shopConfig, shop.id, { geoeffnet: req.body.offen, geoeffnetAt: new Date().toISOString() }) + leereCache(shop.siteKey) + const neu = { ...shop, geoeffnet: req.body.offen } + res.json({ schalter: req.body.offen, geoeffnet: await istGeoeffnet({ doc, shop: neu, project: null }, { produkte }), bereitschaft: await bereitschaft({ doc, shop: neu, project: null }, { produkte }) }) +})) + +// ---- Bilder + +// Bild als rohe Bytes (application/octet-stream), hoechstens 8 MB; die Pruefung des Shops laeuft vor dem Einlesen +router.post( + '/bild', + requireSession, + limitWrites, + mitShopSchreibend, + (req, res, next) => { + if (bildLimitErreicht(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuVieleBilder }) + return next() + }, + uploadSlot, + express.raw({ type: 'application/octet-stream', limit: BILD_MAX_BYTES }), + route(async (req, res) => { + const { shop, customerId } = req.shopCtx + const bild = await speichereBild({ customerId, shopId: shop.id, siteKey: shop.siteKey, buffer: req.body }) + await protokoll(req, 'Bild hochgeladen') + res.status(201).json({ imageId: bild.imageId, w: bild.w, h: bild.h }) + }) +) + +// Vorschau im Portal: nur fuer den Kunden, dem das Projekt gehoert +router.get('/bild/:projectId/:datei', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + const treffer = BILD_DATEI_RE.exec(req.params.datei) + const datei = treffer && (await bildMeta(shop.siteKey, treffer[1])) && bildPfad(shop.siteKey, treffer[1], treffer[2]) + if (!datei) return res.status(404).json({ error: MELDUNG.bildFehlt }) + res.set({ 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' }) + return res.sendFile(datei, (err) => { + if (err && !res.headersSent) res.status(404).json({ error: MELDUNG.bildFehlt }) + }) +})) + +// Fehler beim Einlesen eines Uploads (zu gross, abgebrochen): verstaendlich beantworten, nie Rohtext +router.use((err, _req, res, next) => { + if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Das Bild ist größer als 8 MB.' }) + if (err?.status >= 400 && err.status < 500 && res.headersSent === false) return res.status(err.status).json({ error: 'Die Anfrage ist ungültig.' }) + return next(err) +}) + export default router diff --git a/server/services/customerViewLog.js b/server/services/customerViewLog.js index 9a8b2fe..128dc1e 100644 --- a/server/services/customerViewLog.js +++ b/server/services/customerViewLog.js @@ -16,6 +16,7 @@ export const VIEW_ACTIONS = [ 'termin_einstellungen', 'termin_aktion', 'termin_manuell', + 'shop_aenderung', ] const DETAIL_MAX = 300 diff --git a/server/services/shop/bilder.js b/server/services/shop/bilder.js new file mode 100644 index 0000000..db8a11c --- /dev/null +++ b/server/services/shop/bilder.js @@ -0,0 +1,166 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import sharp from 'sharp' +import { config } from '../../config.js' + +/** + * Artikelbilder des Webshops (Plan 2026-10-05-webshop.md, Task C7; verallgemeinert aus services/newsImages.js, das unveraendert + * bleibt). Jedes Bild wird neu kodiert (WebP, laengste Kante 1600 px, dazu Vorschau 480 px); dabei fallen alle Metadaten (GPS, + * Kamera, Drehung) weg. Nur JPEG, PNG und WebP, hoechstens 8 MB. + * + * Ablage: //-l.webp, -s.webp und Sidecar .json + * { customerId, shopId, createdAt, claimed, w, h }. Der Sidecar wird zuletzt geschrieben: ohne ihn gilt ein Bild als nicht + * vorhanden. Hochgeladene, nie an einen Artikel gebundene Bilder loescht raeumeUnbeanspruchteAuf() nach 24 h. + * Fehler tragen status (400/413/415) und einen deutschen Text. + */ + +export const BILD_MAX_BYTES = 8 * 1024 * 1024 +const UNBEANSPRUCHT_MS = 24 * 60 * 60 * 1000 +const MAX_UPLOADS_PRO_STUNDE = 40 +const ID_RE = /^[a-z0-9-]{1,64}$/ +const SITE_KEY_RE = /^[a-z2-7]{16}$/ +// Hoechstens 40 Megapixel je Bild: ein winziges PNG/WebP kann sonst Gigabytes beim Dekodieren belegen +const MAX_INPUT_PIXELS = 40_000_000 +// Fehlertexte von sharp/libvips bei kaputten, unbekannten oder zu grossen Bildern +const DECODE_ERROR_RE = /unsupported image format|pixel limit|corrupt|truncated|invalid|bad seek|bad (?:header|format)|not a|Input (?:buffer|file|Buffer)|Vips|heif|jpeg|png|webp|premature end|decode/i +const FORMATE = new Set(['jpeg', 'png', 'webp']) + +const fehler = (status, text) => Object.assign(new Error(text), { status }) +const idOk = (id) => typeof id === 'string' && ID_RE.test(id) +const keyOk = (siteKey) => typeof siteKey === 'string' && SITE_KEY_RE.test(siteKey) +const wurzel = () => path.resolve(config.shop.uploadDir) +const ordner = (siteKey) => path.join(wurzel(), siteKey) +const sidecar = (siteKey, id) => path.join(ordner(siteKey), `${id}.json`) + +/** Pfad zu einer Bilddatei ('l' gross, 's' Vorschau) oder null bei unsauberem siteKey, unsauberer ID oder Groesse. */ +export function bildPfad(siteKey, imageId, groesse) { + if (!keyOk(siteKey) || !idOk(imageId)) return null + if (groesse !== 'l' && groesse !== 's') return null + return path.join(ordner(siteKey), `${imageId}-${groesse}.webp`) +} + +/** + * Bild kodieren und ablegen. + * @returns {Promise<{ imageId: string, w: number, h: number }>} + */ +export async function speichereBild({ customerId, shopId, siteKey, buffer }) { + if (!keyOk(siteKey)) throw fehler(400, 'Ungültiger Shop.') + if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw fehler(415, 'Bitte lade ein Foto hoch (JPEG, PNG oder WebP).') + if (buffer.length > BILD_MAX_BYTES) throw fehler(413, 'Das Bild ist größer als 8 MB.') + + const nichtUnterstuetzt = () => fehler(415, 'Dieses Dateiformat können wir nicht verwenden. Bitte lade ein JPEG, PNG oder WebP hoch.') + let gross, klein + try { + const opts = { failOn: 'error', limitInputPixels: MAX_INPUT_PIXELS } + const meta = await sharp(buffer, opts).metadata() + if (!FORMATE.has(meta.format)) throw nichtUnterstuetzt() + // rotate() wendet die EXIF-Drehung an; ohne withMetadata() schreibt sharp keine Metadaten + gross = await sharp(buffer, opts).rotate() + .resize({ width: 1600, height: 1600, fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 80 }).toBuffer({ resolveWithObject: true }) + // Vorschau aus dem schon verkleinerten Ergebnis (spart eine Dekodierung des Originals) + klein = await sharp(gross.data) + .resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 75 }).toBuffer() + } catch (err) { + if (err.status) throw err + if (DECODE_ERROR_RE.test(String(err && err.message))) throw nichtUnterstuetzt() + console.error(`[shop] Bild-Verarbeitung fehlgeschlagen: ${String(err?.message ?? '').slice(0, 200)}`) + throw err + } + + const imageId = randomUUID() + const { width: w, height: h } = gross.info + await mkdir(ordner(siteKey), { recursive: true }) + await writeFile(bildPfad(siteKey, imageId, 'l'), gross.data) + await writeFile(bildPfad(siteKey, imageId, 's'), klein) + await schreibeSidecar(siteKey, imageId, { customerId, shopId, createdAt: new Date().toISOString(), claimed: false, w, h }) + return { imageId, w, h } +} + +async function schreibeSidecar(siteKey, imageId, meta) { + const datei = sidecar(siteKey, imageId) + await writeFile(`${datei}.tmp`, JSON.stringify(meta)) + await rename(`${datei}.tmp`, datei) +} + +/** Sidecar eines Bildes { customerId, shopId, createdAt, claimed, w, h } oder null, wenn es das Bild nicht gibt. */ +export async function bildMeta(siteKey, imageId) { + if (!keyOk(siteKey) || !idOk(imageId)) return null + try { + const meta = JSON.parse(await readFile(sidecar(siteKey, imageId), 'utf8')) + return meta && typeof meta === 'object' && !Array.isArray(meta) ? meta : null + } catch { + return null + } +} + +/** Markiert Bilder als an einen Artikel gebunden (werden nicht mehr aufgeraeumt). Unbekannte IDs werden uebergangen. */ +export async function beanspruche(siteKey, ids) { + for (const id of Array.isArray(ids) ? ids : []) { + const meta = await bildMeta(siteKey, id) + if (!meta || meta.claimed === true) continue + await schreibeSidecar(siteKey, id, { ...meta, claimed: true }) + } +} + +/** Loescht ein Bild (beide Groessen und Sidecar); fehlt es, passiert nichts. */ +export async function loescheBild(siteKey, imageId) { + if (!keyOk(siteKey) || !idOk(imageId)) return + await Promise.all([ + rm(bildPfad(siteKey, imageId, 'l'), { force: true }), + rm(bildPfad(siteKey, imageId, 's'), { force: true }), + rm(sidecar(siteKey, imageId), { force: true }), + rm(`${sidecar(siteKey, imageId)}.tmp`, { force: true }), + ]) +} + +/** Loescht alle Bilder eines Shops (Ordner samt Inhalt), z. B. beim Entfernen der Shop-Daten. */ +export async function loescheAlleBilder(siteKey) { + if (!keyOk(siteKey)) return + await rm(ordner(siteKey), { recursive: true, force: true }) +} + +/** Loescht nicht beanspruchte Bilder, die aelter als 24 h sind (Sidecar ohne Datum gilt als alt). Gibt die Anzahl zurueck. */ +export async function raeumeUnbeanspruchteAuf(now = new Date()) { + const jetzt = now instanceof Date ? now.getTime() : Number(now) + const shops = await readdir(wurzel()).catch(() => []) + let entfernt = 0 + for (const siteKey of shops) { + if (!keyOk(siteKey)) continue + const dateien = await readdir(ordner(siteKey)).catch(() => []) + for (const datei of dateien) { + if (!datei.endsWith('.json')) continue + const id = datei.slice(0, -5) + if (!idOk(id)) continue + const meta = await bildMeta(siteKey, id) + if (!meta || meta.claimed === true) continue + const erstellt = Date.parse(meta.createdAt) + if (Number.isFinite(erstellt) && jetzt - erstellt <= UNBEANSPRUCHT_MS) continue + await loescheBild(siteKey, id) + entfernt++ + } + } + return entfernt +} + +// Missbrauchsschutz: Uploads je Kunde und Stunde (im Speicher, reicht fuer einen Prozess). Jeder Aufruf zaehlt. +const uploads = new Map() +export function bildLimitErreicht(customerId) { + const jetzt = Date.now() + for (const [key, zeiten] of uploads) { + const frisch = zeiten.filter((t) => jetzt - t < 60 * 60 * 1000) + if (frisch.length === 0) uploads.delete(key) + else if (frisch.length !== zeiten.length) uploads.set(key, frisch) + } + const liste = uploads.get(customerId) || [] + liste.push(jetzt) + uploads.set(customerId, liste) + return liste.length > MAX_UPLOADS_PRO_STUNDE +} + +/** Setzt den Upload-Zaehler zurueck (Tests). */ +export function setzeBildLimitZurueck() { + uploads.clear() +} diff --git a/server/services/shop/produkte.js b/server/services/shop/produkte.js index 524d30e..9ab166e 100644 --- a/server/services/shop/produkte.js +++ b/server/services/shop/produkte.js @@ -295,7 +295,7 @@ export function normalisiereProdukt(eingabe, { einstellungen } = {}) { } /** Gespeicherten JSON-Text sicher lesen; null bei kaputten Daten. */ -function parseProdukt(doc) { +export function parseProdukt(doc) { try { const p = JSON.parse(doc?.produkt) return istObjekt(p) ? p : null diff --git a/test/customerViewLog.test.js b/test/customerViewLog.test.js index 008fa44..5ebd404 100644 --- a/test/customerViewLog.test.js +++ b/test/customerViewLog.test.js @@ -17,10 +17,10 @@ const entries = () => [...aw.docs('customerViewLog').values()] const seedLog = (id, customerId, createdAt, action = 'geoeffnet') => aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt }) -test('VIEW_ACTIONS: genau die zehn Aktionen', () => { +test('VIEW_ACTIONS: genau die elf Aktionen', () => { assert.deepEqual(VIEW_ACTIONS, [ 'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht', - 'termin_einstellungen', 'termin_aktion', 'termin_manuell', + 'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'shop_aenderung', ]) }) diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 218ab52..41fd5cd 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -119,7 +119,7 @@ test('Checkout je Funktion ist in der Kundenansicht gesperrt (Schleife trifft /a } }) -test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und Login', () => { +test('Erlaubnisliste: genau News, Terminplaner, Webshop-Verwaltung, Analyse-Link, Ende, Logout und Login', () => { const ok = [ ['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'], ['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'], @@ -127,9 +127,11 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L ['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'], ['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'], ['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'], + ['POST', '/api/shop/produkte'], ['POST', '/api/shop/produkte/'], ['POST', '/api/shop/produkte/sortierung'], ['PUT', '/api/shop/produkte/abc'], + ['DELETE', '/api/shop/produkte/abc'], ['PUT', '/api/shop/einstellungen'], ['PUT', '/api/shop/rechtstexte'], ['POST', '/api/shop/bild'], ] for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`) - assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6, 'genau sechs Termine-Eintraege dazu, keine Wildcard') + assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 7, 'genau sechs Termine- und sieben Webshop-Eintraege dazu, keine Wildcard') for (const [m, p] of [ ['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'], // Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen @@ -143,6 +145,12 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L ['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'], // Webshop: Anfrage stellen und zurueckziehen sind in der Kundenansicht gesperrt (nicht auf der Liste) ['POST', '/api/shop/anfrage'], ['POST', '/api/shop/anfrage/zurueckziehen'], + // Webshop: Oeffnen und Bestaetigen der Rechtstexte macht der Haendler selbst; Stripe und Erstattungen sind nie dabei + ['POST', '/api/shop/oeffnen'], ['POST', '/api/shop/rechtstexte/bestaetigen'], ['POST', '/api/shop/stripe/verbinden'], ['POST', '/api/shop/bestellungen/abc/erstatten'], + // und nur die ausgeschriebenen Pfade und Methoden der Verwaltung + ['POST', '/api/shop/rechtstexte'], ['POST', '/api/shop/einstellungen'], ['PATCH', '/api/shop/produkte/abc'], ['POST', '/api/shop/produkte/abc'], + ['PUT', '/api/shop/produkte'], ['DELETE', '/api/shop/produkte'], ['PUT', '/api/shop/bild'], ['POST', '/api/shop/bild/x'], + ['POST', '/api/shopX/produkte'], ['PUT', '/api/shop/produkte/abc/def'], ['DELETE', '/api/shop/einstellungen'], ]) { assert.ok(!allowed(m, p), `${m} ${p}`) } @@ -175,6 +183,25 @@ test('unbekanntes POST/PUT unter /api/termine in der Kundenansicht -> 403 view_b } }) +// Router-Abgleich Webshop: genau die sieben Verwaltungs-Schreib-Routen stehen auf der Liste, jeder Eintrag trifft genau eine echte Route +test('Router-Abgleich: genau sieben Schreib-Routen des Webshops stehen auf der Erlaubnisliste', () => { + const shopRouter = MOUNTS.find(([prefix]) => prefix === '/api/shop')[1] + const routen = writeRoutes([['/api/shop', shopRouter]]) + assert.deepEqual( + routen.filter(({ method, path: p }) => allowed(method, p)).map(({ method, path: p }) => `${method} ${p}`).sort(), + ['DELETE /api/shop/produkte/x', 'POST /api/shop/bild', 'POST /api/shop/produkte', 'POST /api/shop/produkte/sortierung', + 'PUT /api/shop/einstellungen', 'PUT /api/shop/produkte/x', 'PUT /api/shop/rechtstexte'] + ) + const shopEintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('shop')) + assert.equal(shopEintraege.length, 7) + for (const [m, re] of shopEintraege) assert.equal(routen.filter((r) => r.method === m && re.test(r.path)).length, 1, `${m} ${re}`) + // gesperrt bleiben insbesondere Anfrage, Oeffnen und Bestaetigen + for (const p of ['/api/shop/anfrage', '/api/shop/anfrage/zurueckziehen', '/api/shop/oeffnen', '/api/shop/rechtstexte/bestaetigen']) { + assert.ok(routen.some((r) => r.method === 'POST' && r.path === p), `Route ${p} existiert`) + assert.ok(!allowed('POST', p), p) + } +}) + // Router-Abgleich: die Erlaubnisliste und die echten Schreib-Routen des Terminplaners decken sich genau; eine neue Route faellt // im Test "jede Schreib-Route ausserhalb der Erlaubnisliste -> 403" auf (sie muss gesperrt sein), ein toter Eintrag hier test('Router-Abgleich: genau sechs Schreib-Routen des Terminplaners stehen auf der Erlaubnisliste', () => { diff --git a/test/shopAnfrage.test.js b/test/shopAnfrage.test.js index 2006163..84a834f 100644 --- a/test/shopAnfrage.test.js +++ b/test/shopAnfrage.test.js @@ -105,7 +105,10 @@ test('GET /status: ohne Dokumente off und leer, legt nichts an; Schalter, fremde await leereAlles() const res = await status() assert.equal(res.status, 200) - assert.deepEqual(res.body, { verfuegbar: true, state: 'off', anfrage: null }) + assert.deepEqual(res.body, { + verfuegbar: true, state: 'off', ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null, + snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null, + }) assert.equal(res.headers.get('cache-control'), 'no-store') assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0, 'GET legt nichts an') diff --git a/test/shopBilder.test.js b/test/shopBilder.test.js new file mode 100644 index 0000000..fbf7996 --- /dev/null +++ b/test/shopBilder.test.js @@ -0,0 +1,140 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import os from 'node:os' +import path from 'node:path' +import { mkdtempSync, existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import sharp from 'sharp' + +// Webshop C7: Artikelbilder (services/shop/bilder.js), verallgemeinert aus newsImages.js +process.env.SHOP_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'shop-bilder-')) +const { + speichereBild, bildPfad, bildMeta, beanspruche, loescheBild, loescheAlleBilder, raeumeUnbeanspruchteAuf, bildLimitErreicht, + setzeBildLimitZurueck, BILD_MAX_BYTES, +} = await import('../server/services/shop/bilder.js') + +const KEY = 'abcdefghijklmnop' +const KEY2 = 'qrstuvwxyz234567' +const solid = (width, height) => sharp({ create: { width, height, channels: 3, background: '#36c' } }) +const speichere = (buffer, extra = {}) => speichereBild({ customerId: 'c1', shopId: 's1', siteKey: KEY, buffer, ...extra }) + +test('EXIF/GPS entfernt und gedreht; Sidecar mit Besitzer, Shop und Groesse', async () => { + const input = await solid(1200, 800) + .withMetadata({ orientation: 6 }) + .withExif({ IFD3: { GPSLatitudeRef: 'N', GPSLatitude: '48/1 30/1 0/1' } }) + .jpeg().toBuffer() + assert.ok((await sharp(input).metadata()).exif, 'Fixture hat EXIF') + const { imageId, w, h } = await speichere(input) + assert.ok(w < h, 'gedreht') + for (const size of ['l', 's']) { + const meta = await sharp(bildPfad(KEY, imageId, size)).metadata() + assert.equal(meta.exif, undefined) + assert.equal(meta.format, 'webp') + } + const meta = await bildMeta(KEY, imageId) + assert.deepEqual([meta.customerId, meta.shopId, meta.claimed, meta.w, meta.h], ['c1', 's1', false, w, h]) + assert.match(meta.createdAt, /^\d{4}-\d\d-\d\dT/) +}) + +test('verkleinert auf 1600 und 480, Dateinamen -l.webp und -s.webp unter //', async () => { + const { imageId, w, h } = await speichere(await solid(4000, 3000).png().toBuffer()) + assert.deepEqual([w, h], [1600, 1200]) + const l = await sharp(bildPfad(KEY, imageId, 'l')).metadata() + const s = await sharp(bildPfad(KEY, imageId, 's')).metadata() + assert.deepEqual([l.width, l.height, s.width, s.height], [1600, 1200, 480, 360]) + assert.equal(bildPfad(KEY, imageId, 'l'), path.join(process.env.SHOP_UPLOAD_DIR, KEY, `${imageId}-l.webp`)) + // kleine Bilder werden nicht vergroessert + const klein = await speichere(await solid(100, 50).webp().toBuffer()) + assert.deepEqual([klein.w, klein.h], [100, 50]) +}) + +test('zu gross 413, falsches Format 415 (PDF, GIF, kaputt, leer, kein Buffer), ungueltiger Shop 400', async () => { + await assert.rejects(speichere(Buffer.alloc(BILD_MAX_BYTES + 1)), { status: 413 }) + assert.equal(BILD_MAX_BYTES, 8 * 1024 * 1024) + await assert.rejects(speichere(Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n')), { status: 415 }) + await assert.rejects(speichere(await solid(10, 10).gif().toBuffer()), { status: 415 }) + await assert.rejects(speichere(Buffer.from('kein Bild')), { status: 415 }) + await assert.rejects(speichere(Buffer.alloc(0)), { status: 415 }) + await assert.rejects(speichere({}), { status: 415 }) + await assert.rejects(speichere(await solid(10, 10).png().toBuffer(), { siteKey: '../x' }), { status: 400 }) +}) + +test('Pfade nur fuer saubere siteKeys, IDs und Groessen', () => { + assert.equal(bildPfad(KEY, '../x', 'l'), null) + assert.equal(bildPfad(KEY, 'a/b', 'l'), null) + assert.equal(bildPfad(KEY, 'ABC', 'l'), null) + assert.equal(bildPfad('../abcdefghijkl', 'abc-1', 'l'), null) + assert.equal(bildPfad('abcdefghijklmno', 'abc-1', 'l'), null, 'zu kurz') + assert.equal(bildPfad('abcdefghijklmnop1', 'abc-1', 'l'), null, 'zu lang') + assert.equal(bildPfad('abcdefghijklmno1', 'abc-1', 'l'), null, 'Ziffer 1 ist kein base32') + assert.equal(bildPfad(KEY, 'abc-1', 'xl'), null) + assert.equal(bildPfad(KEY, 'x'.repeat(65), 'l'), null) + assert.ok(bildPfad(KEY, 'abc-1', 's').endsWith(path.join(KEY, 'abc-1-s.webp'))) +}) + +test('bildMeta: unbekannte oder unsaubere IDs => null; beanspruche setzt claimed, ignoriert Unbekanntes und Wiederholung', async () => { + const { imageId } = await speichere(await solid(50, 50).png().toBuffer()) + assert.equal(await bildMeta(KEY, 'gibtsnicht'), null) + assert.equal(await bildMeta(KEY, '../x'), null) + assert.equal(await bildMeta('zu-kurz', imageId), null) + await beanspruche(KEY, [imageId, 'gibtsnicht', '../x', 42]) + assert.equal((await bildMeta(KEY, imageId)).claimed, true) + await beanspruche(KEY, [imageId]) + assert.equal((await bildMeta(KEY, imageId)).claimed, true) + await beanspruche(KEY, undefined) +}) + +test('loescheBild entfernt beide Groessen und den Sidecar; loescheAlleBilder den ganzen Shop; Fehlendes ist kein Fehler', async () => { + const a = await speichere(await solid(50, 50).png().toBuffer()) + const b = await speichere(await solid(50, 50).png().toBuffer()) + const andere = await speichereBild({ customerId: 'c2', shopId: 's2', siteKey: KEY2, buffer: await solid(50, 50).png().toBuffer() }) + await loescheBild(KEY, a.imageId) + await loescheBild(KEY, a.imageId) + await loescheBild(KEY, '../x') + for (const size of ['l', 's']) assert.equal(existsSync(bildPfad(KEY, a.imageId, size)), false) + assert.equal(await bildMeta(KEY, a.imageId), null) + assert.ok(await bildMeta(KEY, b.imageId)) + await loescheAlleBilder(KEY) + await loescheAlleBilder('../x') + await loescheAlleBilder('gibtsnicht1234567') + assert.equal(await bildMeta(KEY, b.imageId), null) + assert.equal(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, KEY)), false) + assert.ok(await bildMeta(KEY2, andere.imageId), 'anderer Shop bleibt') +}) + +test('raeumeUnbeanspruchteAuf: nur Unbeanspruchte aelter als 24 h, in allen Shops', async () => { + const png = await solid(50, 50).png().toBuffer() + const alt = await speichere(png) + const behalten = await speichere(png) + const frisch = await speichere(png) + const altAnderer = await speichereBild({ customerId: 'c2', shopId: 's2', siteKey: KEY2, buffer: png }) + await beanspruche(KEY, [behalten.imageId]) + const zurueckdatieren = async (key, id, stunden) => { + const datei = path.join(process.env.SHOP_UPLOAD_DIR, key, `${id}.json`) + const meta = JSON.parse(await readFile(datei, 'utf8')) + meta.createdAt = new Date(Date.now() - stunden * 3600 * 1000).toISOString() + await writeFile(datei, JSON.stringify(meta)) + } + await zurueckdatieren(KEY, alt.imageId, 25) + await zurueckdatieren(KEY, behalten.imageId, 48) + await zurueckdatieren(KEY, frisch.imageId, 23) + await zurueckdatieren(KEY2, altAnderer.imageId, 30) + assert.equal(await raeumeUnbeanspruchteAuf(), 2) + assert.equal(await bildMeta(KEY, alt.imageId), null) + assert.equal(existsSync(bildPfad(KEY, alt.imageId, 'l')), false) + assert.equal(await bildMeta(KEY2, altAnderer.imageId), null) + assert.ok(await bildMeta(KEY, behalten.imageId), 'beansprucht bleibt') + assert.ok(await bildMeta(KEY, frisch.imageId), 'frisch bleibt') + // mit spaeterem Zeitpunkt wird auch das frische geraeumt + assert.equal(await raeumeUnbeanspruchteAuf(new Date(Date.now() + 3 * 3600 * 1000)), 1) + assert.equal(await bildMeta(KEY, frisch.imageId), null) +}) + +test('bildLimitErreicht: 40 je Stunde und Kunde, danach true; andere Kunden unberuehrt', () => { + setzeBildLimitZurueck() + for (let i = 0; i < 40; i++) assert.equal(bildLimitErreicht('c1'), false, `Aufruf ${i + 1}`) + assert.equal(bildLimitErreicht('c1'), true) + assert.equal(bildLimitErreicht('c2'), false) + setzeBildLimitZurueck() + assert.equal(bildLimitErreicht('c1'), false) +}) diff --git a/test/shopRoutes.test.js b/test/shopRoutes.test.js new file mode 100644 index 0000000..514935d --- /dev/null +++ b/test/shopRoutes.test.js @@ -0,0 +1,631 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import os from 'node:os' +import path from 'node:path' +import { mkdtempSync, existsSync } from 'node:fs' +import { readdir, rm } from 'node:fs/promises' +import sharp from 'sharp' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Webshop C7: Verwaltungs-Routen /api/shop (Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder), Kundenansicht +process.env.SHOP_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'shop-routes-')) +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + customerViewLog: schemaKeys('customerViewLog'), + }, + datetimes: SHOP_DATETIMES, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, send, post } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js') +const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const sf = await import('../server/services/siteFeatures.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { shopPerSiteKey } = await import('../server/services/shop/kontext.js') +const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +seedFixtures(aw) +aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Töpferei', archived: false }) +aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' }) +aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' }) +aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Altprojekt', archived: true }) + +const cookie = customerCookie('c1') +const KV = { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } } +const EINSTELLUNGEN = { + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Töpferei Keramik', + anbieter: { name: 'Töpferei Keramik', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@toepferei.example', telefon: '', ustId: '' }, +} +const TEXTE = { + agb: 'Unsere AGB.', + widerrufsbelehrung: 'Unsere Widerrufsbelehrung.', + widerrufsformular: 'Unser Widerrufsformular.', + impressumUrl: 'https://toepferei.example/impressum', + datenschutzUrl: 'https://toepferei.example/datenschutz', +} +const ARTIKEL = { name: 'Tasse', preisCent: 1800 } + +const q = (projectId = 'pt1') => `projectId=${projectId}` +const lesen = (pfad, projectId = 'pt1', opts = { cookie }) => send('GET', `/api/shop/${pfad}${pfad.includes('?') ? '&' : '?'}${q(projectId)}`, opts) +const schreiben = (method, pfad, body = {}, opts = { cookie }) => send(method, `/api/shop/${pfad}`, { ...opts, body: { projectId: 'pt1', ...body } }) + +async function aktiviere(projectId = 'pt1', state = 'active') { + const feature = await sf.ensureFeatureDoc('c1', projectId, 'shop') + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state)) + return sf.getFeatureDoc('c1', projectId, 'shop') +} +const shopConfigDoc = (feature) => aw.docs('shopConfig').get(feature.$id) +const dateien = async (siteKey) => (await readdir(path.join(process.env.SHOP_UPLOAD_DIR, siteKey)).catch(() => [])).sort() + +async function png(w = 64, h = 48) { + return sharp({ create: { width: w, height: h, channels: 3, background: '#c63' } }).png().toBuffer() +} +async function lade(buffer, { projectId = 'pt1', opts = { cookie }, type = 'application/octet-stream' } = {}) { + const res = await fetch(`${app.url}/api/shop/bild?projectId=${projectId}`, { + method: 'POST', + headers: { 'content-type': type, ...(opts.cookie ? { cookie: opts.cookie } : {}), ...(opts.headers || {}) }, + body: buffer, + }) + const text = await res.text() + let body = text + try { + body = JSON.parse(text) + } catch { + /* Text */ + } + return { status: res.status, body } +} + +/** Shop bis "bereit zum Oeffnen": active, Einstellungen, Rechtstexte bestaetigt, Stripe bereit, ein Artikel. */ +async function machBereit({ mitArtikel = true } = {}) { + const feature = await aktiviere() + assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200) + if (mitArtikel) assert.equal((await schreiben('POST', 'produkte', { produkt: ARTIKEL })).status, 201) + assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE })).status, 200) + const hash = (await lesen('rechtstexte')).body.hash + assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash })).status, 200) + await updateDocument(config.collections.shopConfig, feature.$id, { + stripeAccountId: 'acct_test1', + stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true, details_submitted: true, currently_due: [], past_due: [], disabled_reason: null, geheim: 'nie', geprueftAt: '2026-10-05T10:00:00.000Z' }), + }) + return feature +} + +let origin +beforeEach(async () => { + origin ??= { mode: config.shop.mode, pilotEmails: config.shop.pilotEmails } + config.shop.mode = 'pilot' + config.shop.pilotEmails = [customers.c1.email] + resetShopRoutesForTests() + for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'customerViewLog']) { + for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id) + } + await rm(process.env.SHOP_UPLOAD_DIR, { recursive: true, force: true }) + const { leereCache } = await import('../server/services/shop/kontext.js') + leereCache() +}) + +// ---------------------------------------------------------------- Zugang + +test('Zugang: ohne Session 401, ohne Projekt 400, fremdes oder unbekanntes Projekt 403, Schalter aus 409, Funktion nicht eingerichtet 409', async () => { + await aktiviere() + assert.equal((await send('GET', '/api/shop/einstellungen?projectId=pt1')).status, 401) + assert.equal((await send('GET', '/api/shop/einstellungen', { cookie })).status, 400) + assert.equal((await lesen('einstellungen', 'pt3')).status, 403) + assert.equal((await lesen('einstellungen', 'gibtsnicht')).status, 403) + for (const pfad of ['einstellungen', 'produkte', 'rechtstexte']) assert.equal((await lesen(pfad, 'pt2')).status, 409, `${pfad}: pt2 hat keine Funktion`) + const keine = await lesen('einstellungen', 'pt2') + assert.deepEqual([keine.body.code, keine.body.error], ['nicht_eingerichtet', 'Der Webshop ist für dieses Projekt noch nicht eingerichtet.']) + config.shop.mode = 'off' + const aus = await lesen('einstellungen') + assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_verfuegbar']) + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['jemand@example.invalid'] + assert.equal((await lesen('produkte')).status, 409) +}) + +test('Zustaende: ordered und off => 409; disabled nur lesend; setup legt shopConfig an (ID = portalFeatures-ID, siteKey kopiert)', async () => { + const feature = await aktiviere('pt1', 'ordered') + assert.equal((await lesen('einstellungen')).status, 409) + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('setup')) + assert.equal(aw.docs('shopConfig').size, 0) + const setup = await lesen('einstellungen') + assert.equal(setup.status, 200) + assert.equal(setup.body.einstellungen.lieferzeit, '2–4 Werktage') + assert.equal(setup.body.vollstaendig, false) + const angelegt = shopConfigDoc(feature) + assert.deepEqual([angelegt.customerId, angelegt.projectId, angelegt.siteKey], ['c1', 'pt1', feature.siteKey]) + assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200, 'setup darf schreiben') + + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled')) + for (const pfad of ['einstellungen', 'produkte', 'rechtstexte']) assert.equal((await lesen(pfad)).status, 200, `${pfad} lesbar`) + for (const [m, pfad, body] of [ + ['PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }], ['POST', 'produkte', { produkt: ARTIKEL }], ['PUT', 'rechtstexte', { rechtstexte: TEXTE }], + ['POST', 'oeffnen', { offen: false }], ['POST', 'produkte/sortierung', { ids: [] }], + ]) { + const res = await schreiben(m, pfad, body) + assert.deepEqual([res.status, res.body.code], [409, 'nicht_eingerichtet'], `${m} ${pfad}`) + } + assert.equal((await lesen('einstellungen', 'pt4')).status, 409) +}) + +test('archiviertes Projekt: Lesen ja (falls Funktion da), Schreiben 409 wie bei Bestellungen', async () => { + const feature = await sf.ensureFeatureDoc('c1', 'pt4', 'shop') + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('active')) + assert.equal((await lesen('einstellungen', 'pt4')).status, 200) + const res = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, projectId: 'pt4' }) + assert.equal(res.status, 409) +}) + +// ---------------------------------------------------------------- Status + +test('GET /status ohne Funktion: gleiche Form mit leeren Werten, legt nichts an', async () => { + const res = await lesen('status') + assert.equal(res.status, 200) + assert.deepEqual(res.body, { + verfuegbar: true, state: 'off', ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null, + snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null, + }) + assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0) + assert.equal((await lesen('status', 'pt3')).status, 403) +}) + +test('GET /status mit Shop: Bereitschaft, Snippet, Vorschau, Stripe ohne Konto-ID und fremde Felder, Zahlen; geoeffnet erst nach Oeffnen', async () => { + const feature = await machBereit() + const res = await lesen('status') + assert.equal(res.status, 200) + const b = res.body + assert.deepEqual([b.verfuegbar, b.state, b.siteKey, b.schalter, b.geoeffnet], [true, 'active', feature.siteKey, false, false]) + assert.equal(b.bereitschaft.bereit, true) + assert.deepEqual(b.bereitschaft.punkte.map((p) => [p.id, p.ok]), [['einbau', true], ['stripe', true], ['einstellungen', true], ['rechtstexte', true], ['artikel', true]]) + assert.equal(b.snippet, `
\n`) + assert.equal(b.vorschauUrl, `${config.publicBaseUrl}/shop/${feature.siteKey}`) + assert.deepEqual(b.stripe, { + verbunden: true, + status: { charges_enabled: true, payouts_enabled: true, details_submitted: true, currently_due: [], past_due: [], disabled_reason: null, geprueftAt: '2026-10-05T10:00:00.000Z' }, + }) + assert.ok(!JSON.stringify(b).includes('acct_test1') && !JSON.stringify(b).includes('geheim')) + assert.deepEqual(b.zahlen, { artikel: 1, aktiveArtikel: 1, offeneBestellungen: 0, bestellungen30: 0, umsatz30Cent: 0 }) + assert.equal(b.ticketWoid, null) + + assert.equal((await schreiben('POST', 'oeffnen', { offen: true })).status, 200) + assert.equal((await lesen('status')).body.geoeffnet, true) + assert.equal((await lesen('status')).body.schalter, true) +}) + +// ---------------------------------------------------------------- Einstellungen + +test('Einstellungen: speichern, Version zaehlt, veraltete Version 409, Fehler je Feld 400', async () => { + await aktiviere() + const leer = await lesen('einstellungen') + assert.equal(leer.body.version, 0) + const gut = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, version: 0 }) + assert.equal(gut.status, 200) + assert.deepEqual([gut.body.version, gut.body.vollstaendig, gut.body.einstellungen.shopname], [1, true, 'Töpferei Keramik']) + assert.equal((await lesen('einstellungen')).body.einstellungen.anbieter.ort, 'Kaiserslautern') + + const veraltet = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, version: 0 }) + assert.deepEqual([veraltet.status, veraltet.body.code], [409, 'EINSTELLUNG_VERALTET']) + assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200, 'ohne Version keine Pruefung') + + const schlecht = await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, akzent: 'rot' } }) + assert.equal(schlecht.status, 400) + assert.ok(schlecht.body.fehler && Object.keys(schlecht.body.fehler).length > 0) + assert.equal(schlecht.body.error, Object.values(schlecht.body.fehler)[0]) + assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: 'text' })).status, 400) +}) + +test('Einstellungen speichern leert den 30-s-Speicher von shopPerSiteKey', async () => { + const feature = await aktiviere() + await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) + const vorher = await shopPerSiteKey(feature.siteKey) + assert.equal(vorher.shop.einstellungen.shopname, 'Töpferei Keramik') + await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, shopname: 'Neu' } }) + assert.equal((await shopPerSiteKey(feature.siteKey)).shop.einstellungen.shopname, 'Neu') +}) + +// ---------------------------------------------------------------- Artikel + +test('Artikel: anlegen 201, lesen, aendern, Fehler 400 je Feld, unbekannte ID 404, Kleinunternehmer-Steuer', async () => { + await aktiviere() + await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) // Kleinunternehmer => 0 % + const neu = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, kurz: 'Handgemacht', extra: 'ignoriert' } }) + assert.equal(neu.status, 201) + const id = neu.body.produkt.id + assert.deepEqual([neu.body.produkt.name, neu.body.produkt.preisCent, neu.body.produkt.steuersatz, neu.body.produkt.aktiv, neu.body.produkt.sort], ['Tasse', 1800, 0, true, 0]) + assert.equal(neu.body.produkt.extra, undefined) + + const liste = await lesen('produkte') + assert.equal(liste.status, 200) + assert.deepEqual(liste.body.produkte.map((p) => p.id), [id]) + assert.deepEqual([liste.body.maxArtikel, liste.body.maxBilder], [config.shop.maxArtikel, config.shop.maxBilder]) + + const geaendert = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Große Tasse', aktiv: false } }) + assert.equal(geaendert.status, 200) + assert.deepEqual([geaendert.body.produkt.id, geaendert.body.produkt.name, geaendert.body.produkt.aktiv], [id, 'Große Tasse', false]) + assert.equal((await lesen('produkte')).body.produkte[0].aktiv, false) + assert.equal(aw.docs('shopProducts').size, 1) + + const schlecht = await schreiben('POST', 'produkte', { produkt: { name: '', preisCent: -5 } }) + assert.equal(schlecht.status, 400) + assert.ok(schlecht.body.fehler.name && schlecht.body.fehler.preisCent) + assert.equal((await schreiben('POST', 'produkte', { produkt: 'text' })).status, 400) + assert.equal((await schreiben('POST', 'produkte', {})).status, 400) + assert.equal((await schreiben('PUT', 'produkte/gibtsnicht', { produkt: ARTIKEL })).status, 404) + assert.equal((await schreiben('DELETE', 'produkte/gibtsnicht')).status, 404) + assert.equal(aw.docs('shopProducts').size, 1) +}) + +test('Artikel mit Regelbesteuerung: Steuersatz kommt aus den gespeicherten Einstellungen', async () => { + await aktiviere() + await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, kleinunternehmer: false } }) + const res = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, steuersatz: 7 } }) + assert.equal(res.body.produkt.steuersatz, 7) +}) + +test('Artikel gehoeren zu ihrem Shop: fremder Shop kann sie weder sehen noch aendern', async () => { + await aktiviere() + const neu = await schreiben('POST', 'produkte', { produkt: ARTIKEL }) + await aktiviere('pt2') + assert.deepEqual((await lesen('produkte', 'pt2')).body.produkte, []) + assert.equal((await schreiben('PUT', `produkte/${neu.body.produkt.id}`, { produkt: ARTIKEL, projectId: 'pt2' })).status, 404) + assert.equal((await schreiben('DELETE', `produkte/${neu.body.produkt.id}`, {}, { cookie }) ).status, 200) +}) + +test('Sortierung: neue Reihenfolge, falsche Liste 400', async () => { + await aktiviere() + const ids = [] + for (const name of ['A', 'B', 'C']) ids.push((await schreiben('POST', 'produkte', { produkt: { name, preisCent: 100 } })).body.produkt.id) + const res = await schreiben('POST', 'produkte/sortierung', { ids: [ids[2], ids[0], ids[1]] }) + assert.equal(res.status, 200) + assert.deepEqual(res.body.produkte.map((p) => p.name), ['C', 'A', 'B']) + assert.deepEqual((await lesen('produkte')).body.produkte.map((p) => p.name), ['C', 'A', 'B']) + assert.equal((await schreiben('POST', 'produkte/sortierung', { ids: [ids[0]] })).status, 400) + assert.equal((await schreiben('POST', 'produkte/sortierung', {})).status, 400) +}) + +// ---------------------------------------------------------------- Bilder + +test('Bild hochladen: 201, Sidecar mit Shop und Kunde, Vorschau lesbar; Typfehler 415, zu gross 413, ohne Session 401, fremdes Projekt 403', async () => { + const feature = await aktiviere() + const res = await lade(await png(2000, 1000)) + assert.equal(res.status, 201) + assert.deepEqual([res.body.w, res.body.h], [1600, 800]) + assert.match(res.body.imageId, /^[a-z0-9-]{36}$/) + const dateiListe = await dateien(feature.siteKey) + assert.deepEqual(dateiListe, [`${res.body.imageId}-l.webp`, `${res.body.imageId}-s.webp`, `${res.body.imageId}.json`].sort()) + const meta = JSON.parse(await (await import('node:fs/promises')).readFile(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${res.body.imageId}.json`), 'utf8')) + assert.deepEqual([meta.customerId, meta.shopId, meta.claimed], ['c1', feature.$id, false]) + + for (const datei of [`${res.body.imageId}-s.webp`, `${res.body.imageId}-l.webp`]) { + const bild = await fetch(`${app.url}/api/shop/bild/pt1/${datei}`, { headers: { cookie } }) + assert.equal(bild.status, 200) + assert.equal(bild.headers.get('content-type'), 'image/webp') + assert.equal(bild.headers.get('x-content-type-options'), 'nosniff') + assert.equal(bild.headers.get('cache-control'), 'private, max-age=3600') + assert.equal((await sharp(Buffer.from(await bild.arrayBuffer())).metadata()).format, 'webp') + } + + assert.equal((await lade(Buffer.from('%PDF-1.4 kein Bild'))).status, 415) + assert.equal((await lade(await png(), { type: 'text/plain' })).status, 415, 'kein octet-stream') + const zuGross = await lade(Buffer.alloc(8 * 1024 * 1024 + 1)) + assert.deepEqual([zuGross.status, zuGross.body.error], [413, 'Das Bild ist größer als 8 MB.']) + assert.equal((await lade(await png(), { opts: {} })).status, 401) + assert.equal((await lade(await png(), { projectId: 'pt3' })).status, 403) + assert.equal((await lade(await png(), { projectId: 'pt2' })).status, 409, 'pt2 hat keine Funktion') + assert.equal((await dateien(feature.siteKey)).length, 3, 'nichts Weiteres abgelegt') +}) + +test('Bild-Vorschau: nur eigene Projekte, nur gueltige Dateinamen, nur vorhandene Bilder', async () => { + const feature = await aktiviere() + const { body } = await lade(await png()) + const url = (projekt, datei) => `${app.url}/api/shop/bild/${projekt}/${datei}` + const hole = (projekt, datei, opts = { cookie }) => fetch(url(projekt, datei), { headers: opts.cookie ? { cookie: opts.cookie } : {} }) + assert.equal((await hole('pt1', `${body.imageId}-s.webp`, {})).status, 401) + assert.equal((await hole('pt3', `${body.imageId}-s.webp`)).status, 403) + assert.equal((await hole('pt1', `${body.imageId}-xl.webp`)).status, 404) + assert.equal((await hole('pt1', `${body.imageId}.json`)).status, 404, 'Sidecar wird nicht ausgeliefert') + assert.equal((await hole('pt1', '..%2F..%2Fetc%2Fpasswd')).status, 404) + assert.equal((await hole('pt1', 'gibtsnicht-s.webp')).status, 404) + assert.equal((await hole('pt1', `${body.imageId}-s.webp`, { cookie: customerCookie('c2') })).status, 403) + // Datei ohne Sidecar gilt als nicht vorhanden + await rm(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${body.imageId}.json`)) + assert.equal((await hole('pt1', `${body.imageId}-s.webp`)).status, 404) +}) + +test('Bilder: 40 Uploads je Stunde, der 41. wird mit 429 abgelehnt', async () => { + await aktiviere() + const klein = await png(8, 8) + for (let i = 0; i < 40; i++) assert.equal((await lade(klein)).status, 201, `Upload ${i + 1}`) + const zuViele = await lade(klein) + assert.deepEqual([zuViele.status, zuViele.body.error], [429, 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.']) + assert.equal((await lade(klein, { opts: { cookie: customerCookie('c1') } })).status, 429) +}) + +test('Artikel mit Bildern: unbekannte oder fremde ID 400, Beanspruchen, Entfernen loescht, geteiltes Bild bleibt bis zuletzt, Loeschen des Artikels raeumt auf', async () => { + const feature = await aktiviere() + const a = (await lade(await png())).body.imageId + const b = (await lade(await png())).body.imageId + const c = (await lade(await png())).body.imageId + const meta = async (id) => JSON.parse(await (await import('node:fs/promises')).readFile(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${id}.json`), 'utf8')) + const da = (id) => existsSync(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${id}-l.webp`)) + + const unbekannt = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [a, 'gibtsnicht'] } }) + assert.equal(unbekannt.status, 400) + assert.ok(unbekannt.body.fehler.bilder) + assert.equal(aw.docs('shopProducts').size, 0) + assert.equal((await meta(a)).claimed, false, 'nichts beansprucht, wenn der Artikel nicht gespeichert wurde') + + // Bild eines anderen Shops + const f2 = await aktiviere('pt2') + const fremd = (await lade(await png(), { projectId: 'pt2' })).body.imageId + const mitFremd = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [fremd] } }) + assert.equal(mitFremd.status, 400) + assert.ok(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, f2.siteKey, `${fremd}-l.webp`))) + const mitPfad = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: ['../x'] } }) + assert.equal(mitPfad.status, 400) + + const p1 = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [a, b] } }) + assert.equal(p1.status, 201) + assert.deepEqual(p1.body.produkt.bilder, [a, b]) + assert.deepEqual(p1.body.produkt.bildUrls[0], { id: a, klein: `/api/shop/bild/pt1/${a}-s.webp`, gross: `/api/shop/bild/pt1/${a}-l.webp` }) + assert.equal((await meta(a)).claimed, true) + assert.equal((await meta(b)).claimed, true) + assert.equal((await meta(c)).claimed, false) + const p2 = await schreiben('POST', 'produkte', { produkt: { name: 'Zweiter', preisCent: 500, bilder: [b] } }) + assert.equal(p2.status, 201) + + // a entfernen => weg; b entfernen aus p1, aber p2 nutzt es noch => bleibt + const ohne = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: [] } }) + assert.equal(ohne.status, 200) + assert.equal(da(a), false) + assert.equal(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${a}.json`)), false) + assert.equal(da(b), true, 'p2 nutzt b noch') + assert.equal(da(c), true, 'unbenutzt hochgeladene Bilder raeumt nur der Nachtjob auf') + + // p2 loeschen => b weg + assert.equal((await schreiben('DELETE', `produkte/${p2.body.produkt.id}`)).status, 200) + assert.equal(da(b), false) + assert.equal(aw.docs('shopProducts').size, 1) + + // Reihenfolge und Anzahl der Bilder bleiben, mehr als maxBilder abgelehnt + const viele = [] + for (let i = 0; i < config.shop.maxBilder + 1; i++) viele.push((await lade(await png(8, 8))).body.imageId) + const zuViele = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: viele } }) + assert.equal(zuViele.status, 400) + assert.ok(zuViele.body.fehler.bilder) + const ok = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: viele.slice(0, 2).reverse() } }) + assert.deepEqual(ok.body.produkt.bilder, viele.slice(0, 2).reverse()) +}) + +// ---------------------------------------------------------------- Rechtstexte + +test('Rechtstexte GET: Entwurf leer, nichts bestaetigt, Muster, erzeugte Texte, Gewaehrleistung, Hash, Bestaetigungstext', async () => { + const feature = await aktiviere() + await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) + const res = await lesen('rechtstexte') + assert.equal(res.status, 200) + const b = res.body + assert.deepEqual(Object.keys(b).sort(), ['bestaetigt', 'bestaetigungText', 'entwurf', 'gewaehrleistung', 'hash', 'muster', 'versandUndZahlung', 'vollstaendig']) + assert.deepEqual(b.entwurf, { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }) + assert.equal(b.bestaetigt, null) + assert.equal(b.vollstaendig, false) + assert.match(b.hash, /^[0-9a-f]{64}$/) + assert.equal(b.hash, rechtstexteHash(b.entwurf, (await lesen('einstellungen')).body.einstellungen)) + for (const k of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) assert.ok(b.muster[k].length > 100, k) + assert.ok(b.muster.agb.includes('Töpferei Keramik')) + assert.ok(b.muster.widerrufsbelehrung.includes(`${config.publicBaseUrl}/shop/${feature.siteKey}/widerruf`)) + assert.ok(b.versandUndZahlung.includes('Versand')) + assert.deepEqual([b.gewaehrleistung.bild, b.gewaehrleistung.text.startsWith('GESETZLICHE GEWÄHRLEISTUNG')], ['/img/gewaehrleistung-eu.webp', true]) + assert.match(b.bestaetigungText, /^Ich habe die Texte geprüft/) +}) + +test('Rechtstexte PUT: speichert den Entwurf, normalisiert, Fehler 400; Hash aendert sich mit dem Text', async () => { + const feature = await aktiviere() + const leer = (await lesen('rechtstexte')).body.hash + const res = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: ' Neue AGB \r\n\r\n\r\n\r\nZeile', extra: 'x' } }) + assert.equal(res.status, 200) + assert.equal(res.body.entwurf.agb, 'Neue AGB\n\nZeile') + assert.equal(res.body.vollstaendig, true) + assert.notEqual(res.body.hash, leer) + assert.equal((await lesen('rechtstexte')).body.entwurf.widerrufsformular, 'Unser Widerrufsformular.') + assert.equal(JSON.parse(shopConfigDoc(feature).rechtstexte).bestaetigt, null) + + const schlecht = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, impressumUrl: 'http://unsicher.example', datenschutzUrl: 'javascript:alert(1)' } }) + assert.equal(schlecht.status, 400) + assert.ok(schlecht.body.fehler.impressumUrl && schlecht.body.fehler.datenschutzUrl) + assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: 'text' })).status, 400) + assert.equal((await schreiben('PUT', 'rechtstexte', {})).status, 400) + const zuLang = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: 'x'.repeat(30001) } }) + assert.equal(zuLang.status, 400) + assert.equal((await lesen('rechtstexte')).body.entwurf.agb, 'Neue AGB\n\nZeile', 'Fehler aendert nichts') +}) + +test('Rechtstexte bestaetigen: falscher oder alter Hash 409, unvollstaendig 400, sonst Kopie mit Zeit, Person und Hash', async () => { + const feature = await aktiviere() + await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) + const unvollstaendig = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, datenschutzUrl: '' } }) + const hashUnvollstaendig = unvollstaendig.body.hash + const leer = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: hashUnvollstaendig }) + assert.equal(leer.status, 400) + assert.equal(shopConfigDoc(feature).rechtstexteHash || '', '') + + const voll = await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE }) + const falsch = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: 'abc' }) + assert.deepEqual([falsch.status, falsch.body.code], [409, 'texte_geaendert']) + assert.match(falsch.body.error, /^Die Texte wurden inzwischen geändert/) + assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', {})).status, 409) + assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: hashUnvollstaendig })).status, 409, 'Hash des frueheren Entwurfs') + + const ok = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: voll.body.hash }) + assert.equal(ok.status, 200) + assert.deepEqual(ok.body.bestaetigt.texte, TEXTE) + assert.equal(ok.body.bestaetigt.von, `${customers.c1.name} <${customers.c1.email}>`) + assert.equal(ok.body.bestaetigt.aktuell, true) + assert.ok(Math.abs(Date.now() - Date.parse(ok.body.bestaetigt.at)) < 10_000) + const doc = shopConfigDoc(feature) + assert.equal(doc.rechtstexteHash, voll.body.hash) + assert.equal(doc.rechtstexteBestaetigtVon, `${customers.c1.name} <${customers.c1.email}>`) + assert.equal(new Date(doc.rechtstexteBestaetigtAt).toISOString(), new Date(ok.body.bestaetigt.at).toISOString()) + const gespeichert = JSON.parse(doc.rechtstexte) + assert.deepEqual(gespeichert.entwurf, TEXTE) + assert.deepEqual([gespeichert.bestaetigt.texte, gespeichert.bestaetigt.hash], [TEXTE, voll.body.hash]) + assert.equal((await shopPerSiteKey(feature.siteKey)).shop.rechtstexte.bestaetigt.hash, voll.body.hash, 'Speicher wurde geleert') + + // Entwurf aendern: die bestaetigte Fassung bleibt, ist aber nicht mehr aktuell; Bereitschaft verlangt neue Bestaetigung + const geaendert = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: 'Geaenderte AGB' } }) + assert.deepEqual(geaendert.body.bestaetigt.texte, TEXTE) + assert.equal(geaendert.body.bestaetigt.aktuell, false) + assert.equal(geaendert.body.entwurf.agb, 'Geaenderte AGB') + const stand = (await lesen('status')).body.bereitschaft.punkte.find((p) => p.id === 'rechtstexte') + assert.equal(stand.ok, false) + assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: voll.body.hash })).status, 409, 'alter Hash') +}) + +test('Rechtstexte: Hash haengt auch an den Einstellungen (Versand und Zahlung), eine Aenderung dort verlangt neue Bestaetigung', async () => { + await aktiviere() + await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) + await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE }) + const alt = (await lesen('rechtstexte')).body.hash + await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, versand: { ...EINSTELLUNGEN.versand, kostenCent: { DE: 690 } } } }) + const neu = (await lesen('rechtstexte')).body.hash + assert.notEqual(alt, neu) + assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: alt })).status, 409) + assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: neu })).status, 200) +}) + +// ---------------------------------------------------------------- Oeffnen + +test('Oeffnen: nur wenn bereit (sonst 409 mit Punkten), Schliessen immer, Speicher geleert, Eingabe geprueft', async () => { + const feature = await machBereit({ mitArtikel: false }) + const nicht = await schreiben('POST', 'oeffnen', { offen: true }) + assert.deepEqual([nicht.status, nicht.body.code], [409, 'nicht_bereit']) + assert.deepEqual(nicht.body.punkte.filter((p) => !p.ok).map((p) => p.id), ['artikel']) + assert.ok(nicht.body.punkte.every((p) => typeof p.text === 'string')) + assert.equal(shopConfigDoc(feature).geoeffnet !== true, true) + assert.equal((await schreiben('POST', 'oeffnen', { offen: 'ja' })).status, 400) + assert.equal((await schreiben('POST', 'oeffnen', {})).status, 400) + + await schreiben('POST', 'produkte', { produkt: ARTIKEL }) + const vorher = await shopPerSiteKey(feature.siteKey) + assert.equal(vorher.shop.geoeffnet, false) + const auf = await schreiben('POST', 'oeffnen', { offen: true }) + assert.equal(auf.status, 200) + assert.deepEqual([auf.body.schalter, auf.body.geoeffnet, auf.body.bereitschaft.bereit], [true, true, true]) + assert.equal(shopConfigDoc(feature).geoeffnet, true) + assert.match(shopConfigDoc(feature).geoeffnetAt, /^\d{4}-\d\d-\d\d/) + assert.equal((await shopPerSiteKey(feature.siteKey)).shop.geoeffnet, true, 'Speicher geleert') + + const zu = await schreiben('POST', 'oeffnen', { offen: false }) + assert.deepEqual([zu.status, zu.body.schalter, zu.body.geoeffnet], [200, false, false]) + assert.equal((await shopPerSiteKey(feature.siteKey)).shop.geoeffnet, false) + + // Stripe faellt aus: Oeffnen scheitert, Schliessen geht trotzdem + await updateDocument(config.collections.shopConfig, feature.$id, { stripeStatus: JSON.stringify({ charges_enabled: false }) }) + const stripeAus = await schreiben('POST', 'oeffnen', { offen: true }) + assert.deepEqual([stripeAus.status, stripeAus.body.punkte.filter((p) => !p.ok).map((p) => p.id)], [409, ['stripe']]) + assert.equal((await schreiben('POST', 'oeffnen', { offen: false })).status, 200) +}) + +// ---------------------------------------------------------------- Kundenansicht + +test('Kundenansicht: Einstellungen, Artikel, Rechtstexte-Entwurf und Bilder erlaubt und protokolliert; Lesen ebenfalls', async () => { + const feature = await aktiviere() + assert.equal((await lesen('einstellungen', 'pt1', KV)).status, 200) + assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }, KV)).status, 200) + const neu = await schreiben('POST', 'produkte', { produkt: ARTIKEL }, KV) + assert.equal(neu.status, 201) + const id = neu.body.produkt.id + assert.equal((await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Kanne' } }, KV)).status, 200) + assert.equal((await schreiben('POST', 'produkte/sortierung', { ids: [id] }, KV)).status, 200) + assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE }, KV)).status, 200) + const bild = await lade(await png(), { opts: KV }) + assert.equal(bild.status, 201) + assert.equal((await schreiben('DELETE', `produkte/${id}`, {}, KV)).status, 200) + assert.equal((await dateien(feature.siteKey)).length, 3, 'das hochgeladene Bild gehoert keinem Artikel und wartet auf den Nachtjob') + + const eintraege = [...aw.docs('customerViewLog').values()] + assert.ok(eintraege.every((e) => e.action === 'shop_aenderung' && e.staffName === 'Andrej' && e.customerId === 'c1')) + assert.deepEqual(eintraege.map((e) => e.detail).sort(), [ + 'Artikel angelegt: Tasse', 'Artikel geändert: Kanne', 'Artikel gelöscht: Kanne', 'Artikel sortiert', 'Bild hochgeladen', + 'Einstellungen gespeichert', 'Rechtstexte-Entwurf gespeichert', + ].sort()) +}) + +test('Kundenansicht: Anfrage, Oeffnen und Bestaetigen sind gesperrt (Waechter), Stripe-Pfade auch', async () => { + await machBereit() + const hash = (await lesen('rechtstexte')).body.hash + for (const [pfad, body] of [['oeffnen', { offen: true }], ['rechtstexte/bestaetigen', { hash }], ['anfrage', {}], ['stripe/verbinden', {}]]) { + const res = await schreiben('POST', pfad, body, KV) + assert.deepEqual([res.status, res.body.code], [403, 'view_blocked'], pfad) + } + assert.equal(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).geoeffnet !== true, true) +}) + +test('Kundenansicht: der Router sperrt Oeffnen und Bestaetigen selbst, auch ohne Waechter (alte Ansicht und Kundenansicht)', async () => { + await machBereit() + const hash = (await lesen('rechtstexte')).body.hash + const express = (await import('express')).default + const { sessionMiddleware } = await import('../server/middleware/session.js') + const roh = express() + roh.use(sessionMiddleware()) + roh.use(express.json()) + roh.use('/api/shop', shopRoutes) + const server = await new Promise((resolve) => { + const s = roh.listen(0, '127.0.0.1', () => resolve(s)) + }) + try { + const url = `http://127.0.0.1:${server.address().port}` + for (const [pfad, body] of [['/api/shop/oeffnen', { projectId: 'pt1', offen: true }], ['/api/shop/rechtstexte/bestaetigen', { projectId: 'pt1', hash }]]) { + for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) { + const res = await fetch(url + pfad, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify(body) }) + assert.equal(res.status, 403, pfad) + assert.equal((await res.json()).error, 'In der Kundenansicht kann das nicht geändert werden.') + } + } + } finally { + await new Promise((resolve) => server.close(resolve)) + } + assert.equal(JSON.parse(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).rechtstexte).bestaetigt.hash, hash, 'unveraendert') + assert.equal(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).geoeffnet !== true, true) +}) + +// ---------------------------------------------------------------- Rate-Limit und Fehler + +test('Schreib-Limit: 300 je Stunde und Kunde, danach 429; Lesen zaehlt nicht; anderer Kunde unberuehrt', async () => { + await aktiviere() + for (let i = 0; i < 300; i++) { + const res = await send('POST', '/api/shop/oeffnen', { cookie, body: { projectId: 'pt1' } }) + assert.equal(res.status, 400, `Aufruf ${i + 1}`) + } + const zuViele = await send('POST', '/api/shop/oeffnen', { cookie, body: { projectId: 'pt1', offen: false } }) + assert.deepEqual([zuViele.status, zuViele.body.error], [429, 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.']) + assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 429) + assert.equal((await lesen('einstellungen')).status, 200) + assert.equal((await lesen('status')).status, 200) + assert.equal((await schreiben('POST', 'oeffnen', { offen: false }, { cookie: customerCookie('c2') })).status !== 429, true) +}) + +test('Fehlertexte von Appwrite verlassen den Server nie (500 ohne Rohtext)', async () => { + await aktiviere() + aw.failNext({ collection: 'shopProducts', method: 'GET', status: 500, message: 'GEHEIM interner Appwrite-Text' }) + const res = await lesen('produkte') + assert.equal(res.status, 500) + assert.deepEqual(res.body, { error: 'Interner Serverfehler' }) + assert.equal((await lesen('produkte')).status, 200) +}) From 98d1e6613da0233c2233f1ac764e1a67c33f17f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:54:06 +0000 Subject: [PATCH 050/105] =?UTF-8?q?Shop=20F1:=20=C3=B6ffentliche=20Schnitt?= =?UTF-8?q?stelle=20zum=20Lesen=20(Konfiguration=20mit=20Artikeln,=20Artik?= =?UTF-8?q?elbilder,=20Grenzen,=20Formular-Token)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/index.js | 3 + server/routes/publicShop.js | 374 +++++++++++++++++++++++++++++ server/services/shop/token.js | 64 +++++ test/publicShop.test.js | 427 ++++++++++++++++++++++++++++++++++ 4 files changed, 868 insertions(+) create mode 100644 server/routes/publicShop.js create mode 100644 server/services/shop/token.js create mode 100644 test/publicShop.test.js diff --git a/server/index.js b/server/index.js index 7c83540..68c527c 100644 --- a/server/index.js +++ b/server/index.js @@ -38,6 +38,7 @@ import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js' import newsRoutes from './routes/news.js' import publicNewsRoutes from './routes/publicNews.js' import publicTermineRoutes from './routes/publicTermine.js' +import publicShopRoutes from './routes/publicShop.js' import adminAnalyseRoutes from './routes/admin/analyse.js' import { legalRouter } from './services/legalDocs.js' import adminFinanzenRoutes from './routes/admin/finanzen.js' @@ -60,6 +61,8 @@ app.use(kundenansichtGuard) // Oeffentliche Schnittstelle des Terminplaners (Gaeste ohne Session): bringt Parser (16 KB) und Fehlerbehandlung mit und // haengt deshalb VOR dem globalen Parser (2 MB), der ihre Rumpfe sonst zuerst lesen wuerde app.use('/api/public/termine/v1', publicTermineRoutes) +// Oeffentliche Schnittstelle des Webshops (Kaeufer ohne Session): wie die des Terminplaners mit eigenem Parser (16 KB), daher ebenfalls VOR dem globalen Parser +app.use('/api/public/shop/v1', publicShopRoutes) // Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit, // muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung // zuerst, damit niemand ohne Session grosse Bodies parsen laesst. diff --git a/server/routes/publicShop.js b/server/routes/publicShop.js new file mode 100644 index 0000000..4f05fbe --- /dev/null +++ b/server/routes/publicShop.js @@ -0,0 +1,374 @@ +import express, { Router } from 'express' +import { config } from '../config.js' +import { getDocument } from '../services/appwriteAdmin.js' +import { bildMeta, bildPfad } from '../services/shop/bilder.js' +import { reserviert } from '../services/shop/bestellungen.js' +import { istGeoeffnet, oeffentlicheRechtstexte, shopPerSiteKey } from '../services/shop/kontext.js' +import { euro } from '../services/shop/geld.js' +import { listeProdukte, parseProdukt, produktOeffentlich } from '../services/shop/produkte.js' +import { GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, versandUndZahlung, zahlungsartenText } from '../services/shop/rechtstexte.js' +import { shopFuerKunde, shopModus } from '../services/shop/schalter.js' +import { formToken } from '../services/shop/token.js' +import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js' + +/** + * Oeffentliche Schnittstelle des Webshops: /api/public/shop/v1 (Plan 2026-10-05-webshop.md, Block F; Muster + * routes/publicTermine.js). Ohne Session, ohne Captcha. Hier steht das Lesen (F1: Konfiguration samt Artikeln, Artikelbilder); + * Bestellung, Status und Widerruf (F2/F3) kommen als POST/GET-Routen in dieselbe Datei und nutzen route(), schreibe() und die + * Fehlerhelfer. + * + * - Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlerbehandlung mit und haengt in server/index.js VOR dem + * globalen express.json (2 MB). Antworten sind immer JSON { ... } oder { fehler: { : Text }, code? }; Rohtexte von + * Fehlern verlassen den Server nie (unbekannter Fehler => 503 "Gerade nicht erreichbar"). + * - Antworten enthalten nie customerId, projectId, featureId, $id, Stripe-IDs oder E-Mail-Adressen ausser der oeffentlichen + * Anbieter-Adresse des Haendlers. + * - Unbekannter siteKey, Modus off, Pilotmodus fuer einen Kunden ausserhalb der Pilotliste oder eine Funktion, die nicht active + * oder disabled ist: immer 200 { aktiv: false } (kein Orakel). Eine abgeschaltete Funktion (disabled) antwortet mit + * { aktiv: true, offen: false, nurWiderruf: true, ... }, damit Kaeufer den Widerruf weiter erklaeren koennen. + * - CORS: GET fuer alle (*). Meldungen ohne Daten (429, 503, aktiv:false) tragen `*`. + * - Grenzen: je IP 120 je Minute (darfAnfrage), je Shop 600 je Minute (Schluessel 'shop:'); die Artikelbilder haben + * eigene, groessere Zaehler, damit eine Seite mit vielen Bildern die Konfiguration nicht aussperrt. + */ + +const router = Router() + +const BODY_MAX = 16 * 1024 +const MINUTE_MS = 60 * 1000 +const SHOP_JE_MINUTE = 600 +const BILD_JE_IP = 600 +const BILD_JE_SHOP = 6000 +const BILD_RE = /^([a-z0-9-]{1,64})-(s|l)\.webp$/ +const MIN_ANTWORT_STANDARD_MS = 300 +const LAENDER_NAMEN = { DE: 'Deutschland', AT: 'Österreich' } +const ZAHLUNGSARTEN_REIHENFOLGE = ['card', 'klarna', 'sepa_debit'] + +const NICHT_ERREICHBAR = { fehler: { _: 'Gerade nicht erreichbar' } } +const NICHT_GEFUNDEN = { fehler: { _: 'Nicht gefunden' } } +const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } } +const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } } +const MELDUNG_ZU_VIELE = 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.' + +// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter). +let uhr = () => new Date() +export function setzeUhrFuerTests(neueUhr) { + uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() +} + +/** Besitzer-Adressen fuer den Pilotmodus je ctx (ein ctx aus shopPerSiteKey lebt 30 Sekunden): ctx -> Promise. */ +const besitzer = new WeakMap() +export function resetPublicShopForTests() { + uhr = () => new Date() +} + +// ---------------------------------------------------------------- Antworten und Fehler + +/** Fehler mit Statuscode fuer den Besucher (4xx); `fehlerErgebnis` macht daraus die Antwort. */ +const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(Object.values(fehler)[0]), { status, fehler, ...extra }) +const zuViele = (retryAfterS) => fehlerMit(429, { _: MELDUNG_ZU_VIELE }, { retryAfter: retryAfterS }) +const nichtGefunden = () => fehlerMit(404, NICHT_GEFUNDEN.fehler) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const nurTexte = (wert) => (istObjekt(wert) ? Object.fromEntries(Object.entries(wert).filter(([, t]) => typeof t === 'string')) : {}) + +/** Antwort fuer einen Fehler: Fehler des Besuchers (4xx mit `fehler`) wie sie sind, alles andere 503 ohne Rohtext. */ +function fehlerErgebnis(err) { + const status = err?.status + const fehler = nurTexte(err?.fehler) + if (Number.isInteger(status) && status >= 400 && status < 500 && Object.keys(fehler).length > 0) { + const body = { fehler } + if (typeof err.code === 'string') body.code = err.code + return { status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} } + } + console.error(`[publicShop] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + return { status: 503, body: NICHT_ERREICHBAR } +} + +/** + * Schreibt eine Antwort { status, body, typ, cache, headers }; ohne ETag, immer mit nosniff. cors = { erlaubt: Origin|null } + * (fuer POST, F2/F3). GET und HEAD tragen immer `*`; Meldungen ohne Daten (429, 503, aktiv:false) auch bei POST. + */ +function schreibe(req, res, ergebnis, cors = { erlaubt: null }) { + const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis + const lesen = req.method === 'GET' || req.method === 'HEAD' + if (lesen) res.set('Access-Control-Allow-Origin', '*') + else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' }) + else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*') + res.status(status) + res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers }) + if (status === 204) return res.end() + const inhalt = typeof body === 'string' ? body : JSON.stringify(body) + res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) }) + return res.end(inhalt) +} + +/** Sendet eine Datei (Ergebnis { datei: absoluter Pfad }); fehlt sie, antwortet 404 wie sonst. */ +function sendeDatei(req, res, ergebnis) { + return new Promise((resolve) => { + res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' }) + res.sendFile( + ergebnis.datei, + { cacheControl: false, dotfiles: 'deny', headers: { 'Cache-Control': ergebnis.cache, 'X-Content-Type-Options': 'nosniff' } }, + (err) => { + if (err && !res.headersSent) { + for (const kopf of ['Cache-Control', 'Content-Type', 'Content-Length', 'Cross-Origin-Resource-Policy']) res.removeHeader(kopf) + schreibe(req, res, { status: 404, body: NICHT_GEFUNDEN }) + } else if (err) res.destroy() + resolve() + }, + ) + }) +} + +/** Letzte Absicherung, wenn schon das Schreiben der Antwort scheitert: schlichtes 503, sofern noch nichts gesendet wurde. */ +function notantwort(res, err) { + try { + console.error(`[publicShop] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + } catch { + // das Log darf die Notantwort nicht verhindern + } + try { + if (res.headersSent) return void res.end() + const inhalt = JSON.stringify(NICHT_ERREICHBAR) + res.statusCode = 503 + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Length', String(inhalt.length)) // nur ASCII + return void res.end(inhalt) + } catch { + try { + res.destroy() + } catch { + // nichts mehr zu tun + } + } +} + +function schreibeSicher(req, res, ergebnis, cors) { + try { + schreibe(req, res, ergebnis, cors) + } catch (err) { + notantwort(res, err) + } +} + +/** { aktiv: false }: dieselbe Antwort fuer unbekannte, inaktive und abgeschaltete Shops (GET kurz im Browser-Cache). */ +const aktivFalse = (req) => ({ body: { aktiv: false }, cache: req.method === 'GET' ? 'public, max-age=15' : 'no-store' }) + +const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const minAntwortMs = () => (Number.isFinite(config.shop?.minAntwortMs) && config.shop.minAntwortMs >= 0 ? config.shop.minAntwortMs : MIN_ANTWORT_STANDARD_MS) + +/** + * Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe + * (oder { datei, cache } fuer sendeDatei) oder wirft; cors.erlaubt setzt fn, sobald die Herkunft geprueft ist (F2/F3). + * Mit mindestdauer antwortet auch ein Fehler nicht schneller als minAntwortMs (ab Beginn der Anfrage). + */ +const route = (fn, { mindestdauer = false } = {}) => async (req, res) => { + try { + const cors = { erlaubt: null } + let ergebnis + try { + ergebnis = await fn(req, uhr(), cors) + } catch (err) { + ergebnis = fehlerErgebnis(err) + } + if (mindestdauer) { + const rest = minAntwortMs() - (performance.now() - req.shopStart) + if (rest > 0) await warte(rest) + } + if (ergebnis.datei) await sendeDatei(req, res, ergebnis) + else schreibe(req, res, ergebnis, cors) + } catch (err) { + notantwort(res, err) + } +} + +// ---------------------------------------------------------------- Beginn: Zeit, Rumpf + +router.use((req, res, next) => { + req.shopStart = performance.now() + // Zu grosse Rumpfe gar nicht erst lesen; die Verbindung wird danach geschlossen (der Rumpf bleibt ungelesen). + if (Number(req.get('content-length')) > BODY_MAX) { + res.set('Connection', 'close') + return schreibeSicher(req, res, { status: 413, body: ZU_GROSS }) + } + return next() +}) +router.use(express.json({ limit: BODY_MAX })) + +// ---------------------------------------------------------------- Zugang + +/** E-Mail-Adresse des Shop-Besitzers (leer, wenn der Kunde fehlt); je ctx einmal geladen. */ +function besitzerEmail(ctx) { + let gemerkt = besitzer.get(ctx) + if (!gemerkt) { + gemerkt = getDocument(config.collections.customers, ctx.shop.customerId).then( + (kunde) => String(kunde?.email ?? ''), + (err) => { + if (err?.status === 404) return '' + besitzer.delete(ctx) // Fehler nicht merken + throw err + }, + ) + besitzer.set(ctx, gemerkt) + } + return gemerkt +} + +/** + * Der Kontext des Shops, wenn er ueber die Schnittstelle erreichbar ist; sonst null. Modus off => null ohne jede Abfrage; + * im Pilotmodus muss der Besitzer auf der Pilotliste stehen (SHOP_PILOT_EMAILS). + */ +async function erreichbarerShop(siteKey, now) { + const modus = shopModus() + if (modus === 'off') return null + const ctx = await shopPerSiteKey(siteKey, { now }) + if (!ctx) return null + if (modus === 'pilot' && !shopFuerKunde(await besitzerEmail(ctx))) return null + return ctx +} + +// ---------------------------------------------------------------- Konfiguration + +const text = (wert) => (typeof wert === 'string' ? wert.trim() : '') +const basisUrl = () => String(config.publicBaseUrl ?? '').replace(/\/+$/, '') + +function anbieterOeffentlich(a) { + return { name: a.name, strasse: a.strasse, plz: a.plz, ort: a.ort, land: a.land, email: a.email, telefon: a.telefon } +} + +/** Kurzer Versandtext, z. B. 'Versand nach Deutschland 4,90 €, ab 50,00 € versandkostenfrei'. */ +function versandText(e) { + if (!e.versand.aktiv) return e.abholung.aktiv ? 'Nur Abholung' : '' + const laender = (Array.isArray(e.versand.laender) ? e.versand.laender : []).map((code) => { + const name = LAENDER_NAMEN[code] ?? code + const kosten = e.versand.kostenCent?.[code] + return Number.isInteger(kosten) ? `${name} ${euro(kosten)}` : name + }) + let t = laender.length > 0 ? `Versand nach ${laender.join(', ')}` : 'Versand' + if (Number.isInteger(e.versand.freiAbCent)) t += `, ab ${euro(e.versand.freiAbCent)} versandkostenfrei` + return t +} + +function steuerHinweisKurz(e) { + return e.kleinunternehmer ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : 'Alle Preise inkl. MwSt.' +} + +/** Der Teil `shop` der Antwort (Anzeige- und Kasseninformationen des Haendlers). */ +function shopOeffentlich(e) { + const arten = Array.isArray(e.zahlungsarten) ? e.zahlungsarten : [] + return { + name: e.shopname, + akzent: e.akzent, + anbieter: anbieterOeffentlich(e.anbieter), + lieferzeit: e.lieferzeit, + lieferlaender: e.versand.aktiv ? (Array.isArray(e.versand.laender) ? e.versand.laender : []).map((code) => ({ code, name: LAENDER_NAMEN[code] ?? code })) : [], + abholung: { aktiv: e.abholung.aktiv === true, adresse: e.abholung.adresse, hinweis: e.abholung.hinweis }, + zahlungsarten: zahlungsartenText(e), + zahlungsartenListe: ZAHLUNGSARTEN_REIHENFOLGE.filter((a) => arten.includes(a)).map((a) => zahlungsartenText({ zahlungsarten: [a] })), + versandText: versandText(e), + mindestbestellText: Number.isInteger(e.mindestbestellCent) ? `Mindestbestellwert ${euro(e.mindestbestellCent)}` : '', + kleinunternehmer: e.kleinunternehmer === true, + steuerHinweisKurz: steuerHinweisKurz(e), + } +} + +/** Die bestaetigten Rechtstexte (Fassung, die Kaeufer sehen) oder null, solange keine Fassung bestaetigt ist. */ +function rechtstexteOeffentlich(ctx) { + const t = oeffentlicheRechtstexte(ctx) + if (!t) return null + return { + agb: t.agb, + widerrufsbelehrung: t.widerrufsbelehrung, + widerrufsformular: t.widerrufsformular, + versandUndZahlung: versandUndZahlung(ctx.shop.einstellungen), + impressumUrl: t.impressumUrl, + datenschutzUrl: t.datenschutzUrl, + } +} + +router.get( + '/:siteKey', + route(async (req, now) => { + const { siteKey } = req.params + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await erreichbarerShop(siteKey, now) + if (!ctx) return aktivFalse(req) + if (!zaehle(`shop:${siteKey}`, SHOP_JE_MINUTE, MINUTE_MS, now)) throw zuViele(60) + + const e = ctx.shop.einstellungen + const widerrufUrl = `${basisUrl()}/shop/${siteKey}/widerruf` + const token = formToken(siteKey, now) + + // Abgeschaltet: nichts mehr zu kaufen, aber der Widerruf bleibt erreichbar + if (ctx.doc.state === 'disabled') { + return { + body: { + aktiv: true, + offen: false, + nurWiderruf: true, + shop: { name: e.shopname, akzent: e.akzent, anbieter: anbieterOeffentlich(e.anbieter) }, + widerrufUrl, + formToken: token, + }, + } + } + + const dokumente = await listeProdukte(ctx.shop.id) + const [offen, belegt] = await Promise.all([istGeoeffnet(ctx, { produkte: dokumente }), reserviert(ctx.shop.id, now)]) + const produkte = [] + for (const doc of dokumente) { + if (doc.aktiv === false) continue + const p = produktOeffentlich(doc, { einstellungen: e, reserviert: belegt, siteKey }) + if (p) produkte.push(p) + } + const kategorien = [...new Set(produkte.map((p) => text(p.kategorie)).filter(Boolean))] + return { + body: { + aktiv: true, + offen, + shop: shopOeffentlich(e), + kategorien, + produkte, + rechtstexte: rechtstexteOeffentlich(ctx), + gewaehrleistung: { bild: `${basisUrl()}${GEWAEHRLEISTUNG_BILD}`, text: gewaehrleistungsHinweis() }, + widerrufUrl, + formToken: token, + }, + } + }), +) + +// ---------------------------------------------------------------- Artikelbilder + +router.get( + '/:siteKey/bild/:datei', + route(async (req, now) => { + const { siteKey, datei } = req.params + const ip = ipSchluessel(req.ip) + if (!zaehle(`ip:shop-bild:${ip}`, BILD_JE_IP, MINUTE_MS, now)) throw zuViele(60) + const format = BILD_RE.exec(datei) + const ctx = format ? await erreichbarerShop(siteKey, now) : null + if (!ctx || ctx.doc.state !== 'active') throw nichtGefunden() // ohne Hinweis, was nicht stimmt + if (!zaehle(`shop-bild:${siteKey}`, BILD_JE_SHOP, MINUTE_MS, now)) throw zuViele(60) + + // Das Bild muss zu einem aktiven Artikel dieses Shops gehoeren + const [, bildId, groesse] = format + const gehoert = (await listeProdukte(ctx.shop.id)).some((doc) => doc.aktiv !== false && (parseProdukt(doc)?.bilder ?? []).includes(bildId)) + const pfad = gehoert && (await bildMeta(siteKey, bildId)) ? bildPfad(siteKey, bildId, groesse) : null + if (!pfad) throw nichtGefunden() + return { datei: pfad, cache: 'public, max-age=86400' } + }), +) + +// ---------------------------------------------------------------- Rest: unbekannte Pfade, Fehler des Parsers + +router.use((req, res) => schreibeSicher(req, res, { status: 404, body: NICHT_GEFUNDEN })) + +// Fehlerbehandlung des Routers (Parser): 413 zu gross, 400 kaputt oder nicht lesbar, sonst 503; nie der Text des Fehlers +router.use((err, req, res, _next) => { + if (err?.type === 'entity.too.large' || err?.status === 413) return schreibeSicher(req, res, { status: 413, body: ZU_GROSS }) + if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibeSicher(req, res, { status: 400, body: UNGUELTIG }) + return schreibeSicher(req, res, fehlerErgebnis(err)) +}) + +export default router diff --git a/server/services/shop/token.js b/server/services/shop/token.js new file mode 100644 index 0000000..307d2f7 --- /dev/null +++ b/server/services/shop/token.js @@ -0,0 +1,64 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import { config } from '../../config.js' + +/** + * Formular-Token des Webshops (Plan 2026-10-05-webshop.md, Block F): "." je siteKey, wie das + * Formular-Token des Terminplaners, aber mit eigenem Schluessel (Beschriftung 'shop-form', abgeleitet aus config.sessionSecret): + * ein Terminplaner-Token gilt im Shop nicht und umgekehrt. Das Token beweist nur Alter und Herkunft (kein Geheimnis fuer den + * Besucher). Zu schnell abgeschickt (config.shop.formMinSekunden, Standard 3) = Bot, aelter als 2 Stunden = Seite neu laden. + */ + +const FORM_MAX_ALTER_MS = 2 * 60 * 60 * 1000 +const FORM_TOKEN_MAX_LAENGE = 100 +const MAC_RE = /^[A-Za-z0-9_-]{43}$/ +const ZEIT_RE = /^[0-9a-z]{1,13}$/ +const MIN_STANDARD_SEKUNDEN = 3 + +/** Mindestalter in Sekunden; wird bei jedem Aufruf gelesen (Tests aendern den Wert). */ +export const formMinSekunden = () => { + const wert = config.shop?.formMinSekunden + return Number.isFinite(wert) && wert >= 0 ? wert : MIN_STANDARD_SEKUNDEN +} + +function schluessel() { + const geheimnis = config.sessionSecret + if (typeof geheimnis !== 'string' || geheimnis.length < 32) { + throw new Error('Shop-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).') + } + return createHmac('sha256', geheimnis).update('shop-form').digest() +} + +const zeitMs = (now, was) => { + const ms = now instanceof Date ? now.getTime() : Number(now) + if (!Number.isFinite(ms) || ms < 0) throw new TypeError(`${was}: now ist kein gueltiger Zeitpunkt`) + return ms +} + +function formMac(siteKey, zeit) { + return createHmac('sha256', schluessel()).update(`${siteKey}|${zeit}`).digest('base64url') +} + +/** Formular-Token fuer ein Widget dieses siteKeys, ausgestellt zum Zeitpunkt now. */ +export function formToken(siteKey, now = new Date()) { + if (typeof siteKey !== 'string' || siteKey === '') throw new TypeError('formToken: siteKey fehlt') + const zeit = zeitMs(now, 'formToken').toString(36) + return `${zeit}.${formMac(siteKey, zeit)}` +} + +/** 'ok' | 'zu_schnell' (juenger als formMinSekunden) | 'abgelaufen' (aelter als 2 h) | 'ungueltig' (fremd/gefaelscht). */ +export function pruefeFormToken(siteKey, token, now = new Date()) { + if (typeof siteKey !== 'string' || siteKey === '') return 'ungueltig' + if (typeof token !== 'string' || token.length > FORM_TOKEN_MAX_LAENGE) return 'ungueltig' + const teile = token.split('.') + if (teile.length !== 2) return 'ungueltig' + const [zeit, mac] = teile + if (!ZEIT_RE.test(zeit) || !MAC_RE.test(mac)) return 'ungueltig' + const erwartet = Buffer.from(formMac(siteKey, zeit)) + const gegeben = Buffer.from(mac) + if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig' + + const alter = zeitMs(now, 'pruefeFormToken') - parseInt(zeit, 36) + if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen' + if (alter < formMinSekunden() * 1000) return 'zu_schnell' + return 'ok' +} diff --git a/test/publicShop.test.js b/test/publicShop.test.js new file mode 100644 index 0000000..f95e9d6 --- /dev/null +++ b/test/publicShop.test.js @@ -0,0 +1,427 @@ +import { test, after, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import http from 'node:http' +import path from 'node:path' +import express from 'express' +import sharp from 'sharp' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Oeffentliche Schnittstelle des Webshops, lesender Teil (F1): Konfiguration samt Artikeln und Artikelbilder. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + shopOrders: schemaKeys('shopOrders'), + }, + datetimes: SHOP_DATETIMES, + defaultLimit: 25, +}) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const K = await import('../server/services/shop/kontext.js') +const { normalisiereProdukt } = await import('../server/services/shop/produkte.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const { rechtstexteHash, GEWAEHRLEISTUNG_BILD } = await import('../server/services/shop/rechtstexte.js') +const { formToken, pruefeFormToken } = await import('../server/services/shop/token.js') +const { darfAnfrage, resetTermineLimitsForTests, zaehle } = await import('../server/services/termine/limits.js') +const { default: router, setzeUhrFuerTests, resetPublicShopForTests } = await import('../server/routes/publicShop.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/shop/v1': router }) +const uploadDir = mkdtempSync(path.join(tmpdir(), 'shop-public-')) +after(() => Promise.all([app.close(), aw.close()]).then(() => rmSync(uploadDir, { recursive: true, force: true }))) + +const BASIS = '/api/public/shop/v1' +const JETZT = new Date('2026-10-05T12:00:00.000Z') +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const FEATURE = 'f_p_projekt01_shop' +const SITE_KEY = 'abcdefghijklmnop' +const BILD_ID = 'b1b1b1b1-aaaa-bbbb-cccc-111122223333' +const FREMD_BILD = 'f0f0f0f0-aaaa-bbbb-cccc-111122223333' +const VORHER = { + mode: config.shop.mode, + pilot: [...config.shop.pilotEmails], + uploadDir: config.shop.uploadDir, + basis: config.publicBaseUrl, + formMin: config.shop.formMinSekunden, +} + +const EINSTELLUNGEN = { + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Brot & Mehr', + akzent: '#aa3300', + anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123', ustId: 'DE123456789' }, + versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 }, + abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }, + zahlungsarten: ['card', 'sepa_debit'], + mindestbestellCent: 1000, + kleinunternehmer: false, +} +const TEXTE = { + agb: 'AGB-Text', + widerrufsbelehrung: 'Widerruf-Text', + widerrufsformular: 'Formular-Text', + impressumUrl: 'https://brot.example/impressum', + datenschutzUrl: 'https://brot.example/datenschutz', +} + +const featureDoc = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over }) +const shopDoc = (over = {}) => ({ + $id: FEATURE, + customerId: KUNDE, + projectId: PROJEKT, + siteKey: SITE_KEY, + anfrageStatus: 'angenommen', + stripeAccountId: 'acct_geheim123', + stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }), + einstellungen: JSON.stringify(EINSTELLUNGEN), + rechtstexte: JSON.stringify({ + entwurf: { ...TEXTE, agb: 'ENTWURF-AGB' }, + bestaetigt: { texte: TEXTE, hash: rechtstexteHash(TEXTE, EINSTELLUNGEN), at: '2026-10-05T10:00:00.000Z', von: 'kunde@example.invalid' }, + }), + geoeffnet: true, + version: 3, + ...over, +}) +let zaehler = 0 +const artikel = (id, over = {}, produktOver = {}) => { + const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 450, ...produktOver }, { einstellungen: EINSTELLUNGEN }) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: ++zaehler, name: r.produkt.name, produkt: JSON.stringify({ ...r.produkt, ...(produktOver.bilder ? { bilder: produktOver.bilder } : {}) }), ...over } +} + +async function aufbau({ feature = {}, shop = {}, produkte, kunde = { email: 'inhaber@brot.example' } } = {}) { + for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects', 'customers']) { + for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id) + } + K.leereCache() + resetTermineLimitsForTests() + aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen' }) + if (kunde) aw.seed('customers', { $id: KUNDE, ...kunde }) + aw.seed('portalFeatures', featureDoc(feature)) + if (shop !== null) aw.seed('shopConfig', shopDoc(shop)) + const liste = produkte ?? [ + artikel('a1', {}, { kategorie: 'Brot', bilder: [BILD_ID] }), + artikel('a2', {}, { name: 'Zweites Brot', kategorie: 'Brot', bestand: 3 }), + artikel('a3', {}, { name: 'Kuchen', kategorie: 'Süßes' }), + artikel('a4', { aktiv: false }, { name: 'Verstecktes', kategorie: 'Geheim' }), + artikel('a5', {}, { name: 'Ohne Kategorie' }), + ] + for (const p of liste) aw.seed('shopProducts', p) +} + +const kopfzeile = (res, name) => res.headers.get(name) +let ip +let n = 0 +const api = (pfad, { ip: adresse = ip, headers = {} } = {}) => send('GET', BASIS + pfad, { headers: { 'x-forwarded-for': adresse, ...headers } }) + +// Nichts Internes in irgendeiner Antwort +const VERBOTEN = ['customerId', 'projectId', 'featureId', 'acct_', 'stripeAccountId', 'stripeStatus', '$id', '$collectionId', '$createdAt', 'inhaber@brot.example', KUNDE, PROJEKT, FEATURE, 'DE123456789', 'ENTWURF-AGB'] +const ohneInternes = (body) => { + const s = JSON.stringify(body) + for (const v of VERBOTEN) assert.ok(!s.includes(v), `interner Wert in der Antwort: ${v}`) +} + +beforeEach(async () => { + zaehler = 0 + ip = `10.1.${Math.floor(++n / 250)}.${(n % 250) + 1}` + config.shop.mode = 'on' + config.shop.pilotEmails = [] + config.shop.uploadDir = uploadDir + config.publicBaseUrl = 'https://portal.example' + setzeUhrFuerTests(() => JETZT) + await aufbau() +}) +afterEach(() => { + resetPublicShopForTests() + config.shop.mode = VORHER.mode + config.shop.pilotEmails = VORHER.pilot + config.shop.uploadDir = VORHER.uploadDir + config.publicBaseUrl = VORHER.basis + config.shop.formMinSekunden = VORHER.formMin +}) + +test('GET /:siteKey: Shop offen, Aufbau der Antwort und Kopfzeilen', async () => { + const res = await api(`/${SITE_KEY}`) + assert.equal(res.status, 200) + assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*') + assert.equal(kopfzeile(res, 'cache-control'), 'no-store') + assert.equal(kopfzeile(res, 'x-content-type-options'), 'nosniff') + const b = res.body + assert.equal(b.aktiv, true) + assert.equal(b.offen, true) + assert.equal(b.nurWiderruf, undefined) + assert.equal(b.shop.name, 'Brot & Mehr') + assert.equal(b.shop.akzent, '#aa3300') + assert.deepEqual(b.shop.anbieter, { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123' }) + assert.deepEqual(b.shop.lieferlaender, [{ code: 'DE', name: 'Deutschland' }, { code: 'AT', name: 'Österreich' }]) + assert.deepEqual(b.shop.abholung, { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }) + assert.equal(b.shop.lieferzeit, EINSTELLUNGEN.lieferzeit) + assert.equal(b.shop.zahlungsarten, 'Kreditkarte, Apple Pay, Google Pay, SEPA-Lastschrift') + assert.deepEqual(b.shop.zahlungsartenListe, ['Kreditkarte, Apple Pay, Google Pay', 'SEPA-Lastschrift']) + assert.equal(b.shop.versandText, 'Versand nach Deutschland 4,90 €, Österreich 9,90 €, ab 50,00 € versandkostenfrei') + assert.equal(b.shop.mindestbestellText, 'Mindestbestellwert 10,00 €') + assert.equal(b.shop.kleinunternehmer, false) + assert.equal(b.shop.steuerHinweisKurz, 'Alle Preise inkl. MwSt.') + assert.deepEqual(b.kategorien, ['Brot', 'Süßes']) + assert.deepEqual(b.produkte.map((p) => p.id), ['a1', 'a2', 'a3', 'a5']) + assert.equal(b.produkte[0].preisText, '4,50 €') + assert.deepEqual(b.produkte[0].bilder, [{ klein: `${BASIS}/${SITE_KEY}/bild/${BILD_ID}-s.webp`, gross: `${BASIS}/${SITE_KEY}/bild/${BILD_ID}-l.webp` }]) + assert.equal(b.rechtstexte.agb, 'AGB-Text') + assert.equal(b.rechtstexte.widerrufsbelehrung, 'Widerruf-Text') + assert.equal(b.rechtstexte.widerrufsformular, 'Formular-Text') + assert.match(b.rechtstexte.versandUndZahlung, /Liefergebiet/) + assert.equal(b.rechtstexte.impressumUrl, 'https://brot.example/impressum') + assert.equal(b.rechtstexte.datenschutzUrl, 'https://brot.example/datenschutz') + assert.equal(b.gewaehrleistung.bild, `https://portal.example${GEWAEHRLEISTUNG_BILD}`) + assert.match(b.gewaehrleistung.text, /GESETZLICHE GEWÄHRLEISTUNG/) + assert.equal(b.widerrufUrl, `https://portal.example/shop/${SITE_KEY}/widerruf`) + ohneInternes(b) +}) + +test('Kleinunternehmer-Hinweis, nur Abholung, ohne Mindestbestellwert', async () => { + const e = { ...EINSTELLUNGEN, kleinunternehmer: true, mindestbestellCent: null, versand: { ...EINSTELLUNGEN.versand, aktiv: false } } + await aufbau({ shop: { einstellungen: JSON.stringify(e) } }) + const b = (await api(`/${SITE_KEY}`)).body + assert.equal(b.shop.steuerHinweisKurz, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + assert.equal(b.shop.kleinunternehmer, true) + assert.deepEqual(b.shop.lieferlaender, []) + assert.equal(b.shop.versandText, 'Nur Abholung') + assert.equal(b.shop.mindestbestellText, '') +}) + +test('Formular-Token der Antwort gehoert zum siteKey und ist nach der Wartezeit gueltig', async () => { + const b = (await api(`/${SITE_KEY}`)).body + assert.match(b.formToken, /^[0-9a-z]+\.[A-Za-z0-9_-]{43}$/) + const spaeter = new Date(JETZT.getTime() + 10_000) + assert.equal(pruefeFormToken(SITE_KEY, b.formToken, spaeter), 'ok') + assert.equal(pruefeFormToken(SITE_KEY, b.formToken, new Date(JETZT.getTime() + 1000)), 'zu_schnell') + assert.equal(pruefeFormToken('zzzzzzzzzzzzzzzz', b.formToken, spaeter), 'ungueltig') + assert.equal(pruefeFormToken(SITE_KEY, `${b.formToken}x`, spaeter), 'ungueltig') + assert.equal(pruefeFormToken(SITE_KEY, b.formToken, new Date(JETZT.getTime() + 3 * 3600_000)), 'abgelaufen') + config.shop.formMinSekunden = 0 + assert.equal(pruefeFormToken(SITE_KEY, formToken(SITE_KEY, JETZT), JETZT), 'ok') +}) + +test('Reservierungen offener Bestellungen mindern den Bestand in der Antwort', async () => { + const bestand = (await api(`/${SITE_KEY}`)).body.produkte.find((p) => p.id === 'a2') + assert.equal(bestand.verfuegbar, true) + aw.seed('shopOrders', { + $id: 'o_1', + shopId: FEATURE, + status: 'offen', + ablaufAt: new Date(JETZT.getTime() + 10 * 60_000).toISOString(), + bestellung: JSON.stringify({ positionen: [{ produktId: 'a2', menge: 3 }] }), + }) + const ausverkauft = (await api(`/${SITE_KEY}`)).body.produkte.find((p) => p.id === 'a2') + assert.equal(ausverkauft.verfuegbar, false) +}) + +test('noch keine bestaetigten Rechtstexte: rechtstexte null, Shop geschlossen', async () => { + await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: null }) } }) + const b = (await api(`/${SITE_KEY}`)).body + assert.equal(b.aktiv, true) + assert.equal(b.offen, false) + assert.equal(b.rechtstexte, null) + assert.ok(b.produkte.length > 0) +}) + +test('nicht geoeffnet (Haendler hat nicht geoeffnet): offen false', async () => { + await aufbau({ shop: { geoeffnet: false } }) + const b = (await api(`/${SITE_KEY}`)).body + assert.equal(b.aktiv, true) + assert.equal(b.offen, false) +}) + +test('abgeschalteter Shop (disabled): nur Widerruf, keine Artikel', async () => { + await aufbau({ feature: { state: 'disabled' } }) + const res = await api(`/${SITE_KEY}`) + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body).sort(), ['aktiv', 'formToken', 'nurWiderruf', 'offen', 'shop', 'widerrufUrl']) + assert.equal(res.body.aktiv, true) + assert.equal(res.body.offen, false) + assert.equal(res.body.nurWiderruf, true) + assert.deepEqual(Object.keys(res.body.shop).sort(), ['akzent', 'anbieter', 'name']) + assert.equal(res.body.shop.name, 'Brot & Mehr') + assert.equal(res.body.widerrufUrl, `https://portal.example/shop/${SITE_KEY}/widerruf`) + ohneInternes(res.body) +}) + +test('aktiv:false ohne Orakel: unbekannt, ungueltig, falscher Zustand, Modus off', async () => { + const nein = async (pfad) => { + const res = await api(pfad) + assert.equal(res.status, 200, pfad) + assert.deepEqual(res.body, { aktiv: false }, pfad) + assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*') + } + await nein('/zzzzzzzzzzzzzzzz') // unbekannt + await nein('/abc') // kein siteKey + await nein('/ABCDEFGHIJKLMNOP') // falsche Schreibweise + await aufbau({ feature: { state: 'ordered' } }) + await nein(`/${SITE_KEY}`) + await aufbau({ feature: { state: 'off' } }) + await nein(`/${SITE_KEY}`) + await aufbau({ shop: null }) // kein shopConfig + await nein(`/${SITE_KEY}`) + await aufbau() + config.shop.mode = 'off' + await nein(`/${SITE_KEY}`) + config.shop.mode = 'on' + assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true) +}) + +test('Pilotmodus: nur Besitzer auf der Pilotliste', async () => { + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['andere@example.com'] + assert.deepEqual((await api(`/${SITE_KEY}`)).body, { aktiv: false }) + K.leereCache() + config.shop.pilotEmails = ['inhaber@brot.example'] + assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true) + K.leereCache() + config.shop.pilotEmails = ['*'] + assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true) + // Kunde ohne Dokument: nie freigeschaltet + await aufbau({ kunde: null }) + config.shop.pilotEmails = ['inhaber@brot.example'] + assert.deepEqual((await api(`/${SITE_KEY}`)).body, { aktiv: false }) + // im Pilotmodus gilt dasselbe fuer die Bilder + await aufbau() + config.shop.pilotEmails = ['andere@example.com'] + assert.equal((await api(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)).status, 404) +}) + +// ---------------------------------------------------------------- Bilder + +async function legeBildAb(id = BILD_ID, siteKey = SITE_KEY) { + const ordner = path.join(uploadDir, siteKey) + mkdirSync(ordner, { recursive: true }) + const webp = await sharp({ create: { width: 4, height: 4, channels: 3, background: '#cc3300' } }).webp().toBuffer() + writeFileSync(path.join(ordner, `${id}-s.webp`), webp) + writeFileSync(path.join(ordner, `${id}-l.webp`), webp) + writeFileSync(path.join(ordner, `${id}.json`), JSON.stringify({ customerId: KUNDE, shopId: FEATURE, createdAt: JETZT.toISOString(), claimed: true, w: 4, h: 4 })) + return webp +} + +test('Bild: Auslieferung mit Cache, nosniff und CORS', async () => { + const webp = await legeBildAb() + for (const g of ['s', 'l']) { + const res = await fetch(`${app.url}${BASIS}/${SITE_KEY}/bild/${BILD_ID}-${g}.webp`, { headers: { 'x-forwarded-for': ip } }) + assert.equal(res.status, 200) + assert.equal(res.headers.get('content-type'), 'image/webp') + assert.equal(res.headers.get('cache-control'), 'public, max-age=86400') + assert.equal(res.headers.get('x-content-type-options'), 'nosniff') + assert.equal(res.headers.get('access-control-allow-origin'), '*') + assert.deepEqual(Buffer.from(await res.arrayBuffer()), webp) + } +}) + +test('Bild: 404 bei fremdem, unbekanntem, inaktivem Artikel, schlechtem Namen und fehlender Datei', async () => { + await legeBildAb() + await legeBildAb(FREMD_BILD) // liegt im Ordner, gehoert aber zu keinem Artikel + const vier = async (pfad) => { + const res = await api(pfad) + assert.equal(res.status, 404, pfad) + assert.deepEqual(res.body, { fehler: { _: 'Nicht gefunden' } }, pfad) + assert.equal(kopfzeile(res, 'cache-control'), 'no-store') + return res + } + await vier(`/${SITE_KEY}/bild/${FREMD_BILD}-s.webp`) + await vier(`/${SITE_KEY}/bild/${BILD_ID}-m.webp`) // Groesse + await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.png`) + await vier(`/${SITE_KEY}/bild/..%2F${BILD_ID}-s.webp`) + await vier(`/${SITE_KEY}/bild/${BILD_ID.toUpperCase()}-s.webp`) + await vier(`/zzzzzzzzzzzzzzzz/bild/${BILD_ID}-s.webp`) // fremder siteKey + // Artikel inaktiv + await aufbau({ produkte: [artikel('a1', { aktiv: false }, { bilder: [BILD_ID] })] }) + await legeBildAb() + await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`) + // Artikel mit Bild, aber Datei fehlt (kein Sidecar) + await aufbau() + rmSync(path.join(uploadDir, SITE_KEY), { recursive: true, force: true }) + await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`) + // abgeschalteter Shop liefert keine Bilder + await legeBildAb() + await aufbau({ feature: { state: 'disabled' } }) + await legeBildAb() + await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`) +}) + +// ---------------------------------------------------------------- Grenzen + +test('Grenze je IP: 120 je Minute, danach 429 mit Retry-After', async () => { + for (let i = 0; i < 120; i++) assert.equal(darfAnfrage(ip, JETZT), true) + const res = await api(`/${SITE_KEY}`) + assert.equal(res.status, 429) + assert.equal(kopfzeile(res, 'retry-after'), '60') + assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*') + assert.deepEqual(Object.keys(res.body), ['fehler']) + // andere IP ist nicht betroffen + assert.equal((await api(`/${SITE_KEY}`, { ip: '203.0.113.9' })).status, 200) +}) + +test('Grenze je Shop: 600 je Minute ueber alle IPs, unbekannte Shops zaehlen nicht', async () => { + for (let i = 0; i < 600; i++) assert.equal(zaehle(`shop:${SITE_KEY}`, 600, 60_000, JETZT), true) + const res = await api(`/${SITE_KEY}`) + assert.equal(res.status, 429) + assert.equal(kopfzeile(res, 'retry-after'), '60') + // unbekannter Shop: weiter aktiv:false statt 429 (kein Zaehler je unbekanntem Schluessel) + assert.deepEqual((await api('/zzzzzzzzzzzzzzzz')).body, { aktiv: false }) +}) + +test('Fenster laeuft ab: nach einer Minute ist die Grenze weg', async () => { + for (let i = 0; i < 600; i++) zaehle(`shop:${SITE_KEY}`, 600, 60_000, JETZT) + assert.equal((await api(`/${SITE_KEY}`)).status, 429) + setzeUhrFuerTests(() => new Date(JETZT.getTime() + 61_000)) + assert.equal((await api(`/${SITE_KEY}`)).status, 200) +}) + +// ---------------------------------------------------------------- Rest + +test('unbekannter Pfad: 404 ohne Rohtext', async () => { + const r1 = await api(`/${SITE_KEY}/gibtsnicht`) + assert.equal(r1.status, 404) + assert.deepEqual(r1.body, { fehler: { _: 'Nicht gefunden' } }) + assert.equal(kopfzeile(r1, 'x-content-type-options'), 'nosniff') +}) + +test('eigener Parser: kaputter Rumpf 400, zu grosser Rumpf 413 (Router allein, ohne globalen Parser)', async () => { + const server = http.createServer(express().use('/api/public/shop/v1', router)) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const url = `http://127.0.0.1:${server.address().port}${BASIS}/${SITE_KEY}` + try { + const kaputt = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{kaputt' }) + assert.equal(kaputt.status, 400) + assert.deepEqual(await kaputt.json(), { fehler: { _: 'Ungültige Anfrage.' } }) + assert.equal(kaputt.headers.get('x-content-type-options'), 'nosniff') + const gross = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ x: 'a'.repeat(20_000) }) }) + assert.equal(gross.status, 413) + assert.deepEqual(await gross.json(), { fehler: { _: 'Die Anfrage ist zu groß.' } }) + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(resolve)) + } +}) + +test('Fehler der Datenhaltung: 503 ohne Rohtext', async () => { + // Datenhaltung nicht erreichbar: der Fehler (mit Adresse) gelangt nie nach aussen + const alt = config.appwrite.endpoint + config.appwrite.endpoint = 'http://127.0.0.1:1/v1' + try { + const res = await api(`/${SITE_KEY}`) + assert.equal(res.status, 503) + assert.deepEqual(res.body, { fehler: { _: 'Gerade nicht erreichbar' } }) + assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*') + } finally { + config.appwrite.endpoint = alt + } +}) From 38bd56fe8be03e471f93077564b21038f6754bed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:56:04 +0000 Subject: [PATCH 051/105] Shop D2: Kundenrouten Stripe-Konto (einrichten, Link, aktualisieren) mit Sperre gegen Doppelklick Co-Authored-By: Claude Opus 5.5 --- server/routes/shop.js | 76 ++++++++++- test/shopStripeRoutes.test.js | 245 ++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 test/shopStripeRoutes.test.js diff --git a/server/routes/shop.js b/server/routes/shop.js index 99fb22e..73a8b03 100644 --- a/server/routes/shop.js +++ b/server/routes/shop.js @@ -8,6 +8,7 @@ import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.j import { createWriteLimiter } from '../services/writeLimiter.js' import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js' import { shopFuerKunde } from '../services/shop/schalter.js' +import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js' import { uploadSlot } from './chat.js' import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js' import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js' @@ -24,7 +25,7 @@ import { /** * Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Stand: Anfrage (B1) und - * Verwaltung (C7): Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder. Stripe (D2) und Bestellungen (E) kommen dazu. + * Verwaltung (C7): Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder. Stripe-Konto (D2) und Bestellungen (E) kommen dazu. * * Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Die Verwaltungs-Routen laufen ueber mitShop: Schalter * (409 nicht verfuegbar), Funktion im Zustand setup|active (lesend auch disabled), shopConfig vorhanden (setup|active legt es an). @@ -50,11 +51,14 @@ const MELDUNG = { zuVieleBilder: 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.', bildFehlt: 'Bild nicht gefunden.', eingabe: 'Die Eingabe fehlt oder ist unlesbar.', + stripeKundenansicht: 'In der Kundenansicht kann das Stripe-Konto nicht eingerichtet werden.', + stripeFern: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.', + keinKonto: 'Es ist noch kein Stripe-Konto angelegt.', } /** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */ function fail(res, err) { - const own = ((err?.status >= 400 && err.status < 500) || err?.status === 503) && !err.type + const own = ((err?.status >= 400 && err.status < 500) || err?.status === 502 || err?.status === 503) && !err.type if (!own) { console.error(`[shop] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim()) return res.status(500).json({ error: 'Interner Serverfehler' }) @@ -449,6 +453,74 @@ router.post('/oeffnen', requireSession, nurKunde, limitWrites, route(async (req, res.json({ schalter: req.body.offen, geoeffnet: await istGeoeffnet({ doc, shop: neu, project: null }, { produkte }), bereitschaft: await bereitschaft({ doc, shop: neu, project: null }, { produkte }) }) })) +// ---- Stripe-Konto (D2) + +/** Nur der Kunde selbst richtet sein Stripe-Konto ein (weder Kundenansicht noch "Ansehen als"). */ +const nurKundeStripe = (req, res, next) => { + if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ error: MELDUNG.stripeKundenansicht }) + return next() +} + +/** Stripe-Aufruf: jeder Fehler von Stripe wird zu 502 mit festem Text (Stripe-Texte verlassen den Server nie, geloggt wird nur Typ und Code). */ +async function ueberStripe(fn) { + try { + return await fn() + } catch (err) { + if (err?.status >= 400 && err.status < 500 && !err.type) throw err + console.error(`[shop] Stripe ${String(err?.type ?? err?.name ?? 'Fehler').slice(0, 60)} ${String(err?.code ?? '').slice(0, 60)}`.trim()) + throw stop(502, MELDUNG.stripeFern) + } +} + +const speichereStripe = async (shop, felder) => { + await updateDocument(config.collections.shopConfig, shop.id, felder) + leereCache(shop.siteKey) +} + +const stripeRueckUrl = (projectId, was) => `${config.publicBaseUrl}/dashboard.html#webshop?stripe=${was}&projekt=${encodeURIComponent(projectId)}` +const onboardingFuer = (accountId, projectId) => + ueberStripe(() => onboardingLink(accountId, { returnUrl: stripeRueckUrl(projectId, 'zurueck'), refreshUrl: stripeRueckUrl(projectId, 'neu') })) + +// Konto anlegen (einmalig, unter Sperre) und Onboarding-Link liefern +router.post('/stripe/einrichten', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { + const { shop, customerId, projectId, project } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) + const accountId = await withLocks([`shop-stripe:${shop.id}`], async () => { + const frisch = await frischerShop(shop.id) + if (frisch.stripeAccountId) return frisch.stripeAccountId + const customer = await getDocument(config.collections.customers, customerId).catch(() => null) + const neu = await ueberStripe(() => kontoAnlegen({ customer: customer || { $id: customerId, email: req.session?.email }, project })) + let status + try { + status = await ueberStripe(() => kontoStatus(neu)) + } catch { + status = statusAusKonto({}) + } + await speichereStripe(shop, { stripeAccountId: neu, stripeStatus: JSON.stringify(status) }) + return neu + }) + res.json({ url: await onboardingFuer(accountId, projectId) }) +})) + +// Neuer Onboarding-Link (der alte laeuft nach wenigen Minuten ab) +router.post('/stripe/link', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { + const { shop, projectId } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) + if (!shop.stripeAccountId) throw stop(409, MELDUNG.keinKonto, 'kein_konto') + res.json({ url: await onboardingFuer(shop.stripeAccountId, projectId) }) +})) + +// Status bei Stripe neu lesen und speichern +router.post('/stripe/aktualisieren', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) + if (!shop.stripeAccountId) throw stop(409, MELDUNG.keinKonto, 'kein_konto') + const status = await ueberStripe(() => kontoStatus(shop.stripeAccountId)) + const alt = shop.stripeStatus + const zahlungsarten = Array.isArray(shop.einstellungen?.zahlungsarten) ? shop.einstellungen.zahlungsarten : ['card'] + const behalteHinweis = typeof alt?.hinweis === 'string' && alt.hinweis !== '' && status.charges_enabled === true && zahlungsarten.some((z) => z === 'klarna' || z === 'sepa') + const neu = behalteHinweis ? { ...status, hinweis: alt.hinweis } : status + await speichereStripe(shop, { stripeStatus: JSON.stringify(neu) }) + res.json({ stripe: stripeAnsicht({ ...shop, stripeStatus: neu }) }) +})) + // ---- Bilder // Bild als rohe Bytes (application/octet-stream), hoechstens 8 MB; die Pruefung des Shops laeuft vor dem Einlesen diff --git a/test/shopStripeRoutes.test.js b/test/shopStripeRoutes.test.js new file mode 100644 index 0000000..0b10ecd --- /dev/null +++ b/test/shopStripeRoutes.test.js @@ -0,0 +1,245 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Webshop D2: Stripe-Konto einrichten, neuer Onboarding-Link, Status aktualisieren (Fake-Stripe) +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + customerViewLog: schemaKeys('customerViewLog'), + }, + datetimes: SHOP_DATETIMES, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js') +const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const sf = await import('../server/services/siteFeatures.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { leereCache } = await import('../server/services/shop/kontext.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +seedFixtures(aw) +aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' }) +aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' }) + +// Je Test ein frisches Projekt: Kontoanlage ist je Projekt idempotent (Idempotency-Key), alte Konten wuerden sonst wiederkehren +let P = 'pt1' +let zaehler = 0 +const cookie = customerCookie('c1') +const GEDULD = 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.' +const post = (pfad, body = { projectId: P }, opts = { cookie }) => send('POST', `/api/shop/stripe/${pfad}`, { ...opts, body }) +const status = (projectId = P) => send('GET', `/api/shop/status?projectId=${projectId}`, { cookie }) +const plattform = () => st.calls.filter((c) => c.path === '/v1/accounts' || c.path === '/v1/account_links' || c.path.startsWith('/v1/accounts/')) + +async function aktiviere(projectId = P, state = 'active') { + const feature = await sf.ensureFeatureDoc('c1', projectId, 'shop') + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state)) + return sf.getFeatureDoc('c1', projectId, 'shop') +} +const kontenFuer = (projectId) => [...st.accounts.values()].filter((a) => a.metadata?.projectId === projectId) +const shopConfigDoc = (feature) => aw.docs('shopConfig').get(feature.$id) + +beforeEach(async () => { + P = `pn${(zaehler += 1)}` + aw.seed('websiteProjects', { $id: P, customerId: 'c1', projectName: 'Töpferei', liveDomain: 'toepferei.example', archived: false }) + config.shop.mode = 'pilot' + config.shop.pilotEmails = [customers.c1.email] + resetShopRoutesForTests() + for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'customerViewLog']) { + for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id) + } + st.calls.length = 0 + leereCache() +}) + +test('einrichten: legt ein Konto an, speichert Konto-ID und Status, liefert den Onboarding-Link mit Rücksprung-Adressen', async () => { + const feature = await aktiviere() + const res = await post('einrichten') + assert.equal(res.status, 200) + assert.match(res.body.url, /^https:\/\/connect\.stripe\.test\/setup\/acct_/) + assert.deepEqual(Object.keys(res.body), ['url']) + const doc = shopConfigDoc(feature) + assert.match(doc.stripeAccountId, /^acct_/) + assert.equal(kontenFuer(P).length, 1) + const gespeichert = JSON.parse(doc.stripeStatus) + assert.equal(gespeichert.charges_enabled, false) + assert.deepEqual(gespeichert.currently_due, ['external_account']) + // Plattform-Aufrufe ohne Stripe-Account-Kopfzeile, Konto mit Kunden-E-Mail und Projekt in den Metadaten + assert.ok(plattform().length >= 3) + for (const c of plattform()) assert.equal(c.account, undefined, `${c.method} ${c.path} darf keine Stripe-Account-Kopfzeile tragen`) + const anlage = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/accounts') + assert.equal(anlage.body.email, customers.c1.email) + assert.equal(anlage.body['metadata[projectId]'], P) + const link = st.calls.find((c) => c.path === '/v1/account_links') + assert.equal(link.body.account, doc.stripeAccountId) + assert.equal(link.body.return_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${P}`) + assert.equal(link.body.refresh_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=neu&projekt=${P}`) + // GET /status zeigt das Konto als verbunden, ohne die Konto-ID + const s = (await status()).body + assert.equal(s.stripe.verbunden, true) + assert.equal(JSON.stringify(s).includes(doc.stripeAccountId), false) +}) + +test('einrichten: zwei parallele Aufrufe (Doppelklick) legen nur ein Konto an', async () => { + const feature = await aktiviere(P, 'setup') + const [a, b] = await Promise.all([post('einrichten'), post('einrichten')]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/accounts').length, 1) + assert.equal(kontenFuer(P).length, 1) + assert.equal(st.calls.filter((c) => c.path === '/v1/account_links').length, 2) + assert.ok(a.body.url && b.body.url) + // weiterer Aufruf: Konto bleibt dasselbe + const id = shopConfigDoc(feature).stripeAccountId + assert.equal((await post('einrichten')).status, 200) + assert.equal(shopConfigDoc(feature).stripeAccountId, id) + assert.equal(kontenFuer(P).length, 1) +}) + +test('link: braucht ein Konto (409), liefert danach einen neuen Link mit den Rücksprung-Adressen', async () => { + await aktiviere() + const ohne = await post('link') + assert.deepEqual([ohne.status, ohne.body.error, ohne.body.code], [409, 'Es ist noch kein Stripe-Konto angelegt.', 'kein_konto']) + assert.equal((await post('einrichten')).status, 200) + st.calls.length = 0 + const res = await post('link') + assert.equal(res.status, 200) + assert.match(res.body.url, /^https:\/\/connect\.stripe\.test\/setup\//) + assert.equal(st.calls.length, 1) + assert.equal(st.calls[0].account, undefined) + assert.equal(st.calls[0].body.return_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${P}`) + assert.equal(kontenFuer(P).length, 1) +}) + +test('aktualisieren: braucht ein Konto (409), liest den Status neu und speichert ihn', async () => { + const feature = await aktiviere() + assert.equal((await post('aktualisieren')).status, 409) + await post('einrichten') + const id = shopConfigDoc(feature).stripeAccountId + st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) + const res = await post('aktualisieren') + assert.equal(res.status, 200) + assert.equal(res.body.stripe.verbunden, true) + assert.equal(res.body.stripe.status.charges_enabled, true) + assert.deepEqual(res.body.stripe.status.currently_due, []) + assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).charges_enabled, true) + assert.equal(JSON.stringify(res.body).includes(id), false) + // gleiche Form wie GET /status + assert.deepEqual((await status()).body.stripe, res.body.stripe) +}) + +test('aktualisieren: Hinweis zu Klarna/SEPA bleibt nur, solange Zahlungen möglich sind und eine der Zahlungsarten gewählt ist', async () => { + const feature = await aktiviere() + await post('einrichten') + const id = shopConfigDoc(feature).stripeAccountId + const setze = async (stripeStatus, zahlungsarten) => { + const e = { zahlungsarten } + await updateDocument(config.collections.shopConfig, feature.$id, { stripeStatus: JSON.stringify(stripeStatus), einstellungen: JSON.stringify(e) }) + leereCache() + } + st.setAccount(id, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) + await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card', 'klarna']) + await post('aktualisieren') + assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, 'Klarna ist noch nicht freigeschaltet.') + await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card']) + await post('aktualisieren') + assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, undefined) + st.setAccount(id, { charges_enabled: false }) + await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card', 'sepa']) + await post('aktualisieren') + assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, undefined) +}) + +test('Stripe-Fehler: 502 mit festem Du-Text, nie der Stripe-Text; bei gescheiterter Kontoanlage bleibt kein Konto gespeichert', async () => { + const feature = await aktiviere() + const log = [] + const orig = console.error + console.error = (...a) => log.push(a.join(' ')) + try { + st.failNext({ method: 'POST', path: '/v1/accounts', status: 400, message: 'Geheimer Stripe-Text sk_live_123' }) + const a = await post('einrichten') + assert.deepEqual([a.status, a.body.error], [502, GEDULD]) + assert.equal(JSON.stringify(a.body).includes('Geheim'), false) + assert.equal(shopConfigDoc(feature).stripeAccountId || '', '') + // Konto da, Link scheitert: Konto bleibt gespeichert, 502 + st.failNext({ method: 'POST', path: '/v1/account_links', status: 400, message: 'Geheimer Linktext' }) + const b = await post('einrichten') + assert.deepEqual([b.status, b.body.error], [502, GEDULD]) + assert.match(shopConfigDoc(feature).stripeAccountId, /^acct_/) + st.failNext({ method: 'POST', path: '/v1/account_links', status: 400, message: 'Geheimer Linktext' }) + assert.deepEqual([(await post('link')).status], [502]) + st.failNext({ method: 'GET', path: /^\/v1\/accounts\//, status: 400, message: 'Geheimer Lesetext' }) + const c = await post('aktualisieren') + assert.deepEqual([c.status, c.body.error], [502, GEDULD]) + } finally { + console.error = orig + } + assert.equal(log.some((l) => l.includes('Geheim') || l.includes('sk_live')), false) +}) + +test('Kundenansicht: der Wächter sperrt die Routen (nicht auf der Erlaubnisliste), der Router selbst auch ohne Wächter (Kundenansicht und Ansehen als)', async () => { + const feature = await aktiviere() + for (const pfad of ['einrichten', 'link', 'aktualisieren']) { + const res = await post(pfad, { projectId: P }, { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } }) + assert.deepEqual([res.status, res.body.code], [403, 'view_blocked'], pfad) + } + const express = (await import('express')).default + const { sessionMiddleware } = await import('../server/middleware/session.js') + const roh = express() + roh.use(sessionMiddleware()) + roh.use(express.json()) + roh.use('/api/shop', shopRoutes) + const server = await new Promise((resolve) => { + const s = roh.listen(0, '127.0.0.1', () => resolve(s)) + }) + try { + const url = `http://127.0.0.1:${server.address().port}` + for (const pfad of ['einrichten', 'link', 'aktualisieren']) { + for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) { + const res = await fetch(`${url}/api/shop/stripe/${pfad}`, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify({ projectId: P }) }) + assert.equal(res.status, 403, pfad) + assert.equal((await res.json()).error, 'In der Kundenansicht kann das Stripe-Konto nicht eingerichtet werden.') + } + } + } finally { + await new Promise((resolve) => server.close(resolve)) + } + assert.equal(st.calls.length, 0) + assert.equal(shopConfigDoc(feature)?.stripeAccountId || '', '') +}) + +test('Zugang und Zustand: fremdes Projekt 403, ohne Projekt 400, ohne Session 401, nicht eingerichtet/deaktiviert/Schalter aus 409', async () => { + const feature = await aktiviere() + assert.equal((await post('einrichten', { projectId: 'pt3' })).status, 403) + assert.equal((await post('einrichten', {})).status, 400) + assert.equal((await send('POST', '/api/shop/stripe/einrichten', { body: { projectId: P } })).status, 401) + assert.equal((await post('einrichten', { projectId: 'pt2' })).status, 409) + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled')) + const aus = await post('einrichten') + assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_eingerichtet']) + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('off')) + assert.equal((await post('aktualisieren')).status, 409) + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('active')) + config.shop.mode = 'off' + const schalter = await post('link') + assert.deepEqual([schalter.status, schalter.body.code], [409, 'nicht_verfuegbar']) + assert.equal(st.calls.length, 0) +}) + +test('Rate-Limit: die 301. Schreibanfrage in der Stunde wird abgewiesen', async () => { + await aktiviere() + let letzter + for (let i = 0; i < 301; i += 1) letzter = await post('link') + assert.equal(letzter.status, 429) +}) From 0ebd2e66633e73adc86e73529d7e9dd1fa10707f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:56:40 +0000 Subject: [PATCH 052/105] =?UTF-8?q?Webshop=20E4:=20Mails=20(G09-G14=20an?= =?UTF-8?q?=20K=C3=A4ufer,=20K25/K26=20an=20H=C3=A4ndler),=20shop/mails.js?= =?UTF-8?q?,=20Mailversand=20der=20Warteschlange=20auch=20bei=20Terminplan?= =?UTF-8?q?er=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Käufermails laufen über die Warteschlange des Terminplaners (G09/G12 mit Vorrang), G09 wird bei Überlänge um den AGB-Text gekürzt (Link statt Text). Der Mailteil des Minutenjobs und die Aufräumschritte des Tagesjobs laufen auch bei TERMINE_ENABLED=off, solange SHOP_ENABLED nicht off ist. Co-Authored-By: Claude Opus 5.5 --- .../G09 Bestellbestaetigung.html | 84 ++++ server/mail-templates/G10 Versand.html | 53 +++ server/mail-templates/G11 Abholbereit.html | 49 +++ .../mail-templates/G12 Eingang Widerruf.html | 55 +++ server/mail-templates/G13 Erstattung.html | 53 +++ .../G14 Zahlung fehlgeschlagen.html | 47 +++ .../mail-templates/K25 Neue Bestellung.html | 64 +++ .../K26 Widerruf im Webshop.html | 61 +++ server/services/shop/mails.js | 390 ++++++++++++++++++ server/services/termine/jobs.js | 79 +++- server/services/termine/outbox.js | 14 +- test/mailTemplates.test.js | 194 ++++++++- test/shopMails.test.js | 360 ++++++++++++++++ test/termineJobs.test.js | 87 ++++ test/termineOutbox.test.js | 6 +- 15 files changed, 1570 insertions(+), 26 deletions(-) create mode 100644 server/mail-templates/G09 Bestellbestaetigung.html create mode 100644 server/mail-templates/G10 Versand.html create mode 100644 server/mail-templates/G11 Abholbereit.html create mode 100644 server/mail-templates/G12 Eingang Widerruf.html create mode 100644 server/mail-templates/G13 Erstattung.html create mode 100644 server/mail-templates/G14 Zahlung fehlgeschlagen.html create mode 100644 server/mail-templates/K25 Neue Bestellung.html create mode 100644 server/mail-templates/K26 Widerruf im Webshop.html create mode 100644 server/services/shop/mails.js create mode 100644 test/shopMails.test.js diff --git a/server/mail-templates/G09 Bestellbestaetigung.html b/server/mail-templates/G09 Bestellbestaetigung.html new file mode 100644 index 0000000..24265e9 --- /dev/null +++ b/server/mail-templates/G09 Bestellbestaetigung.html @@ -0,0 +1,84 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}} + + + + + + + + + diff --git a/server/mail-templates/G10 Versand.html b/server/mail-templates/G10 Versand.html new file mode 100644 index 0000000..0eaaa59 --- /dev/null +++ b/server/mail-templates/G10 Versand.html @@ -0,0 +1,53 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} wurde versendet + + + + + + + + + diff --git a/server/mail-templates/G11 Abholbereit.html b/server/mail-templates/G11 Abholbereit.html new file mode 100644 index 0000000..a2980c7 --- /dev/null +++ b/server/mail-templates/G11 Abholbereit.html @@ -0,0 +1,49 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} ist abholbereit + + + + + + + + + diff --git a/server/mail-templates/G12 Eingang Widerruf.html b/server/mail-templates/G12 Eingang Widerruf.html new file mode 100644 index 0000000..4587ca4 --- /dev/null +++ b/server/mail-templates/G12 Eingang Widerruf.html @@ -0,0 +1,55 @@ + + + + + + + + +Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}} + + + + + + + + + diff --git a/server/mail-templates/G13 Erstattung.html b/server/mail-templates/G13 Erstattung.html new file mode 100644 index 0000000..b2f725d --- /dev/null +++ b/server/mail-templates/G13 Erstattung.html @@ -0,0 +1,53 @@ + + + + + + + + +Erstattung zu Ihrer Bestellung {{bestellnummer}} + + + + + + + + + diff --git a/server/mail-templates/G14 Zahlung fehlgeschlagen.html b/server/mail-templates/G14 Zahlung fehlgeschlagen.html new file mode 100644 index 0000000..6f65b7f --- /dev/null +++ b/server/mail-templates/G14 Zahlung fehlgeschlagen.html @@ -0,0 +1,47 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen + + + + + + + + + diff --git a/server/mail-templates/K25 Neue Bestellung.html b/server/mail-templates/K25 Neue Bestellung.html new file mode 100644 index 0000000..43c48d6 --- /dev/null +++ b/server/mail-templates/K25 Neue Bestellung.html @@ -0,0 +1,64 @@ + + + + + + + + +Neue Bestellung {{bestellnummer}} in Deinem Webshop + + + + +In Deinem Webshop ist eine neue Bestellung eingegangen. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K26 Widerruf im Webshop.html b/server/mail-templates/K26 Widerruf im Webshop.html new file mode 100644 index 0000000..f6b7097 --- /dev/null +++ b/server/mail-templates/K26 Widerruf im Webshop.html @@ -0,0 +1,61 @@ + + + + + + + + +Widerruf in Deinem Webshop eingegangen + + + + +Ein Käufer hat in Deinem Webshop einen Vertrag widerrufen. ͏ ͏ ͏ + + + + + diff --git a/server/services/shop/mails.js b/server/services/shop/mails.js new file mode 100644 index 0000000..0bb0b54 --- /dev/null +++ b/server/services/shop/mails.js @@ -0,0 +1,390 @@ +import { config } from '../../config.js' +import { renderMail, loadTemplate } from '../mailTemplates.js' +import { sendCustomerMail } from '../mailer.js' +import { customerOf } from '../siteFeatures.js' +import { einreihen } from '../termine/outbox.js' +import { pruefeNow, zeitMs } from '../termine/util.js' +import { datumKurz, uhrzeit } from '../termine/zeit.js' +import { euro } from './geld.js' +import { oeffentlicheRechtstexte } from './kontext.js' +import { gewaehrleistungsHinweis } from './rechtstexte.js' + +/** + * Mails des Webshops (Spezifikation 2026-10-05-webshop-design.md, Abschnitte 5.3, 5.4 und 8; Plan Task E4). + * + * Kaeufer (G09 Bestellbestaetigung, G10 versendet, G11 abholbereit, G12 Eingang Widerruf, G13 Erstattung, G14 Zahlung + * fehlgeschlagen) laufen durch die Mail-Warteschlange des Terminplaners (termine/outbox.js einreihen): Absendername = Shopname, + * Absenderadresse bleibt SMTP_FROM, Antworten an die E-Mail des Haendlers, nie BCC. featureId = ID des portalFeatures-Dokuments des + * Shops, bookingId = Bestell-ID. Der Betreff steht in der Vorlage (G-Vorlagen des Shops haben keinen {{betreff}}-Wert). Die + * Empfaenger-Kappe der Terminplaner-Mails (limits.darfEmpfaenger) gilt hier nicht: G09 ist Vertragspflicht. + * Haendler (K25 neue Bestellung, K26 Widerruf) gehen direkt per sendCustomerMail an die Kunden-E-Mail, ohne BCC ans Team + * (die Mails enthalten Kaeuferdaten). + * + * Beide Wege werfen nie wegen eines Mailfehlers: Rueckgabe { ok: true, ... } | { ok: false, fehler }; der Fehlertext steht im Log (ohne + * Adresse), die Bestellung bleibt unberuehrt. + * + * Erwartete Form der Bestellung (`order` = shopOrders-Dokument; der JSON-Schnappschuss steht in `bestellung`, ersatzweise `daten`, + * als Text oder Objekt; alle Felder sind optional, Fehlendes erscheint als "–"): + * order.bestellnummer, order.kaeuferEmail, order.bezahltAt | order.erstelltAt + * snap.positionen[] { name, varianteText, menge, summeCent, summeText?, widerruf ('gesetzlich'|'verderblich'|'individuell'|'hygiene') } + * snap.summen | snap { zwischensummeCent, versandCent, gesamtCent, texte?: { zwischensumme, versand, gesamt, steuerHinweis } } + * snap.lieferart ('versand'|'abholung'), snap.land ('DE'|'AT') + * snap.kaeufer { name, email, telefon } (oder snap.name) + * snap.lieferadresse { name?, line1|strasse, line2?, postal_code|plz, city|ort, country|land } + * snap.zahlungsart 'card' | 'klarna' | 'sepa_debit' | Text + * snap.einstellungenAuszug { anbieter, lieferzeit, abholung } (sonst die aktuellen Einstellungen des Shops) + */ + +const LEER = '–' +const GAST_ARTEN = Object.freeze(['G09', 'G10', 'G11', 'G12', 'G13', 'G14']) +/** Grenzen des Outbox-Schemas (html 100000, text 50000) mit etwas Luft. */ +const MAX_HTML = 95000 +const MAX_TEXT = 47000 +const LANDNAMEN = { DE: 'Deutschland', AT: 'Österreich' } +const ZAHLUNGSARTEN = { card: 'Kreditkarte, Apple Pay, Google Pay', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' } +// Wie WIDERRUF_TEXTE in produkte.js (dort nicht exportiert) +const WIDERRUF_AUSSCHLUSS = { + verderblich: 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware', + individuell: 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', + hygiene: 'Vom Widerruf ausgeschlossen: versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde', +} +const ABHOLUNG_HINWEIS = 'Bitte bringen Sie Ihre Bestellnummer mit.' + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const hatText = (wert) => typeof wert === 'string' && wert.trim() !== '' +const text = (wert) => (typeof wert === 'string' ? wert.trim() : '') +const oderLeer = (wert) => (hatText(wert) ? wert.trim() : LEER) +const einzeilig = (wert) => text(wert).replace(/\s+/g, ' ') + +// ---------------------------------------------------------------- Bausteine + +/** Schnappschuss der Bestellung als Objekt ({} wenn es keinen oder keinen lesbaren gibt). */ +function schnappschuss(order) { + for (const feld of ['bestellung', 'daten']) { + const roh = order?.[feld] + if (istObjekt(roh)) return roh + if (typeof roh === 'string' && roh !== '') { + try { + const geparst = JSON.parse(roh) + if (istObjekt(geparst)) return geparst + } catch { + // unlesbar: naechstes Feld + } + } + } + return {} +} + +const einstellungenVon = (ctx) => ctx?.shop?.einstellungen ?? {} +const anbieterVon = (ctx, snap) => ({ ...(einstellungenVon(ctx).anbieter ?? {}), ...(istObjekt(snap?.einstellungenAuszug?.anbieter) ? snap.einstellungenAuszug.anbieter : {}) }) + +/** Name des Shops in Betreff, Kopf und Absender: Shopname, sonst Name des Anbieters. */ +function shopName(ctx, snap = {}) { + return einzeilig(snap?.einstellungenAuszug?.shopname) || einzeilig(einstellungenVon(ctx).shopname) || einzeilig(anbieterVon(ctx, snap).name) || 'Webshop' +} + +function anbieterZeile(a) { + const ort = [text(a.plz), text(a.ort)].filter(Boolean).join(' ') + const land = text(a.land) && a.land !== 'DE' ? (LANDNAMEN[a.land] ?? a.land) : '' + const anschrift = [text(a.strasse), ort, land].filter(Boolean).join(', ') + return [text(a.name), anschrift, text(a.email)].filter(Boolean).join(', ') || LEER +} + +const siteKeyVon = (ctx) => ctx?.shop?.siteKey || ctx?.doc?.siteKey || '' +const shopSeite = (ctx) => `${config.publicBaseUrl}/shop/${siteKeyVon(ctx)}` + +/** Akzentfarbe als #rrggbb (nur geprueft, damit nichts in das style-Attribut gelangt). */ +const farbeVon = (e) => (/^#[0-9a-fA-F]{6}$/.test(e?.akzent ?? '') ? e.akzent : '#111111') + +/** Menge × Name (Variante) – Summe, eine Zeile je Position. */ +function positionenText(positionen) { + const zeilen = (Array.isArray(positionen) ? positionen : []) + .filter(istObjekt) + .map((p) => { + const variante = text(p.varianteText) ? ` (${einzeilig(p.varianteText)})` : '' + const summe = hatText(p.summeText) ? p.summeText.trim() : Number.isInteger(p.summeCent) ? euro(p.summeCent) : LEER + return `${Number.isInteger(p.menge) ? p.menge : LEER} × ${einzeilig(p.name) || LEER}${variante} – ${summe}` + }) + return zeilen.length ? zeilen.join('\n') : LEER +} + +function adresseText(a, fallbackName) { + if (!istObjekt(a)) return LEER + const feld = (...namen) => text(namen.map((n) => a[n]).find(hatText)) + const land = feld('country', 'land') + const zeilen = [ + feld('name') || text(fallbackName), + feld('line1', 'strasse'), + feld('line2'), + [feld('postal_code', 'plz'), feld('city', 'ort')].filter(Boolean).join(' '), + land && land !== 'DE' ? (LANDNAMEN[land] ?? land) : '', + ].filter(Boolean) + return zeilen.length ? zeilen.join('\n') : LEER +} + +function zahlungsartText(snap, e) { + const roh = text(snap.zahlungsart) + if (roh) return ZAHLUNGSARTEN[roh] ?? roh + return Array.isArray(e.zahlungsarten) && e.zahlungsarten.length === 1 ? (ZAHLUNGSARTEN[e.zahlungsarten[0]] ?? LEER) : 'Online-Zahlung' +} + +function ausschlussText(positionen) { + return (Array.isArray(positionen) ? positionen : []) + .filter((p) => istObjekt(p) && Object.hasOwn(WIDERRUF_AUSSCHLUSS, p.widerruf)) + .map((p) => `${einzeilig(p.name) || LEER}: ${WIDERRUF_AUSSCHLUSS[p.widerruf]}`) + .join('\n') +} + +/** "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit); ungueltige Zeitpunkte werfen. */ +function eingangText(eingang) { + const ms = zeitMs(eingang) + if (!Number.isFinite(ms)) throw new TypeError('Eingang ist kein Zeitpunkt') + const d = new Date(ms) + return `${datumKurz(d)}, ${uhrzeit(d)} Uhr` +} + +const datumText = (order, now) => { + const ms = zeitMs(order?.bezahltAt || order?.erstelltAt) + return datumKurz(Number.isFinite(ms) ? new Date(ms) : now) +} + +// ---------------------------------------------------------------- Werte + +/** + * Gemeinsame Werte der Kaeufermails aus dem Schnappschuss der Bestellung und dem Shop-Kontext: Kopf und Fuss (farbe, betrieb_name, + * anbieter_zeile, widerruf_url, impressum_url, datenschutz_url) und alle Werte von G09 (bestellnummer, datum, positionen, Summen, + * Lieferart, Adresse, Zahlungsart, Rechtstexte). `order` darf null sein (G12 ohne passende Bestellung): dann sind die + * Bestellwerte "–". Es sind mehr Werte als eine einzelne Vorlage braucht; anKaeufer reicht nur die deklarierten weiter. + * @param {{ doc?: object, shop: object }} ctx + * @param {object|null} order + * @param {Date} [now] Ersatzdatum, wenn die Bestellung keinen Zeitpunkt hat + * @returns {Record} + */ +export function kaeuferWerte(ctx, order, now = new Date()) { + if (!istObjekt(ctx?.shop)) throw new TypeError('kaeuferWerte: ctx.shop fehlt') + const snap = schnappschuss(order) + const e = einstellungenVon(ctx) + const auszug = istObjekt(snap.einstellungenAuszug) ? snap.einstellungenAuszug : {} + const anbieter = anbieterVon(ctx, snap) + const abholung = { ...(e.abholung ?? {}), ...(istObjekt(auszug.abholung) ? auszug.abholung : {}) } + const rechtstexte = oeffentlicheRechtstexte(ctx) ?? {} + const summen = istObjekt(snap.summen) ? snap.summen : snap + const texte = istObjekt(summen.texte) ? summen.texte : {} + const cent = (wert) => (Number.isInteger(wert) ? euro(wert) : LEER) + const lieferart = snap.lieferart === 'abholung' ? 'abholung' : snap.lieferart === 'versand' ? 'versand' : '' + const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {} + const land = text(snap.land) + const shop = shopSeite(ctx) + + return { + farbe: farbeVon(e), + betrieb_name: shopName(ctx, snap), + anbieter_zeile: anbieterZeile(anbieter), + widerruf_url: `${shop}/widerruf`, + impressum_url: text(rechtstexte.impressumUrl) || shop, + datenschutz_url: text(rechtstexte.datenschutzUrl) || shop, + bestellnummer: oderLeer(order?.bestellnummer), + datum: datumText(order, now), + positionen: positionenText(snap.positionen), + zwischensumme: text(texte.zwischensumme) || cent(summen.zwischensummeCent), + versand: text(texte.versand) || (lieferart === 'abholung' ? 'Abholung – kostenlos' : cent(summen.versandCent)), + gesamt: text(texte.gesamt) || cent(summen.gesamtCent ?? order?.summeCent), + steuer_hinweis: text(texte.steuerHinweis) || (e.kleinunternehmer === true ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : LEER), + lieferart_text: lieferart === 'abholung' ? 'Abholung' : lieferart === 'versand' ? `Versand${land ? ` nach ${LANDNAMEN[land] ?? land}` : ''}` : LEER, + lieferadresse: adresseText(snap.lieferadresse ?? kaeufer.lieferadresse ?? kaeufer.adresse, kaeufer.name ?? snap.name), + abholort: oderLeer(abholung.adresse), + lieferzeit: oderLeer(auszug.lieferzeit ?? e.lieferzeit), + zahlungsart: zahlungsartText(snap, e), + ausschluss_text: ausschlussText(snap.positionen), + agb: oderLeer(rechtstexte.agb), + widerrufsbelehrung: oderLeer(rechtstexte.widerrufsbelehrung), + widerrufsformular: oderLeer(rechtstexte.widerrufsformular), + gewaehrleistung: gewaehrleistungsHinweis(), + // fuer G11 (Standard, wenn der Haendler keinen Hinweis hinterlegt hat) + hinweis: text(abholung.hinweis) || ABHOLUNG_HINWEIS, + } +} + +/** + * Werte und Bloecke einer Kaeufermail (alle Werte von kaeuferWerte plus die Werte der Aktion aus `extra`, gefiltert auf die + * Platzhalter der Vorlage). + * extra je Art: + * G10 versanddienst, sendungsnummer (beide optional; Block sendung, wenn eines da ist) + * G11 hinweis (ersetzt den Hinweis des Haendlers) + * G12 name, bestellnummer (Eingaben des Kaeufers; ohne Angabe die der Bestellung), eingang (Date | ISO, Pflicht), nachricht + * G13 betragCent (Pflicht, ganze Cent), grund + */ +function kaeuferMail(art, ctx, order, extra, now) { + const alle = kaeuferWerte(ctx, order, now) + const blocks = {} + switch (art) { + case 'G09': + blocks.versand = schnappschuss(order).lieferart === 'versand' + blocks.abholung = schnappschuss(order).lieferart === 'abholung' + blocks.ausschluss = alle.ausschluss_text !== '' + break + case 'G10': + alle.versanddienst = oderLeer(extra.versanddienst) + alle.sendungsnummer = oderLeer(extra.sendungsnummer) + blocks.sendung = hatText(extra.versanddienst) || hatText(extra.sendungsnummer) + break + case 'G11': + if (hatText(extra.hinweis)) alle.hinweis = extra.hinweis.trim() + break + case 'G12': + alle.name = text(extra.name) + if (hatText(extra.bestellnummer)) alle.bestellnummer = extra.bestellnummer.trim() + alle.eingang = eingangText(extra.eingang) + alle.nachricht = text(extra.nachricht) + blocks.nachricht = alle.nachricht !== '' + break + case 'G13': + if (!Number.isInteger(extra.betragCent) || extra.betragCent <= 0) throw new TypeError('extra.betragCent muss eine positive ganze Zahl sein') + alle.betrag = euro(extra.betragCent) + alle.grund = text(extra.grund) + blocks.grund = alle.grund !== '' + break + default: + break + } + const vorlage = loadTemplate(art) + const values = Object.fromEntries(Object.entries(alle).filter(([schluessel]) => vorlage.placeholders.includes(schluessel))) + return { values, blocks: Object.fromEntries(Object.entries(blocks).filter(([name]) => vorlage.blocks.includes(name))) } +} + +const zuLang = (mail) => mail.html.length > MAX_HTML || mail.text.length > MAX_TEXT + +// ---------------------------------------------------------------- Einreihen + +/** + * Reiht eine Kaeufermail (G09-G14) in die Warteschlange ein. Empfaenger ist extra.an, sonst order.kaeuferEmail. bookingId ist + * order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". Ist G09 zu lang fuer die Warteschlange + * (AGB, Widerrufsbelehrung und Formular koennen zusammen sehr lang sein), ersetzt der Server den AGB-Text durch einen Satz mit + * Link auf die AGB im Shop; Widerrufsbelehrung, Formular und Gewaehrleistungshinweis bleiben immer vollstaendig. + * @param {'G09'|'G10'|'G11'|'G12'|'G13'|'G14'} art + * @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js) + * @param {object|null} order shopOrders-Dokument (bei G12 ohne passende Bestellung null) + * @param {object} [extra] siehe kaeuferMail + * @param {Date} [now] + * @returns {Promise<{ ok: true, eintrag: object, gekuerzt: boolean } | { ok: false, fehler: string }>} + */ +export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) { + try { + if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Käufermail`) + pruefeNow(now, 'anKaeufer') + extra = extra ?? {} + const { values, blocks } = kaeuferMail(art, ctx, order, extra, now) + const an = hatText(extra.an) ? extra.an.trim() : text(order?.kaeuferEmail) + if (an === '') throw new TypeError('Empfänger fehlt') + + let gekuerzt = false + let mail = renderMail(art, values, blocks) + if (art === 'G09' && zuLang(mail)) { + values.agb = `Die vollständigen AGB finden Sie unter ${shopSeite(ctx)}?ansicht=agb` + mail = renderMail(art, values, blocks) + gekuerzt = true + } + if (zuLang(mail)) throw new TypeError('Die Mail ist zu lang für die Warteschlange') + + const e = einstellungenVon(ctx) + const eintrag = await einreihen( + { + featureId: ctx.doc.$id, + bookingId: order?.$id || (hatText(extra.bookingId) ? extra.bookingId : '-'), + art, + an, + fromName: shopName(ctx, schnappschuss(order)).slice(0, 120), + replyTo: text(anbieterVon(ctx, schnappschuss(order)).email) || text(e.anbieter?.email) || null, + template: art, + values, + blocks, + }, + now, + ) + return { ok: true, eintrag, gekuerzt } + } catch (err) { + console.warn(`[shop-mails] ${String(art).slice(0, 20)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`) + return { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' } + } +} + +// ---------------------------------------------------------------- Haendler + +/** Portal-Adresse der Webshop-Seite des Projekts. */ +const portalUrl = (ctx) => `${config.publicBaseUrl}/dashboard.html#webshop?projekt=${encodeURIComponent(ctx?.shop?.projectId || ctx?.doc?.projectId || '')}` + +/** + * Werte und Bloecke von K25 "Neue Bestellung" (an den Haendler) aus der bezahlten Bestellung. + * @param {{ ueberverkauft?: boolean }} [optionen] ueberverkauft: der Bestand reichte nicht (Block ueberverkauft) + * @returns {{ values: Record, blocks: Record }} + */ +export function haendlerK25(ctx, order, { ueberverkauft = false } = {}) { + const snap = schnappschuss(order) + const alle = kaeuferWerte(ctx, order) + const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {} + return { + values: { + shopname: alle.betrieb_name, + bestellnummer: alle.bestellnummer, + gesamt: alle.gesamt, + positionen: alle.positionen, + lieferart_text: alle.lieferart_text, + kaeufer_name: oderLeer(kaeufer.name ?? snap.name), + portal_url: portalUrl(ctx), + }, + blocks: { ueberverkauft: ueberverkauft === true, sepa: text(snap.zahlungsart) === 'sepa_debit' }, + } +} + +/** + * Werte und Bloecke von K26 "Widerruf eingegangen" (an den Haendler) aus dem Widerruf (shopWiderrufe-Dokument oder + * { name, email, bestellnummer, nachricht, eingang }); eingang = Date | ISO. + */ +export function haendlerK26(ctx, widerruf, now = new Date()) { + const nachricht = text(widerruf?.nachricht) + return { + values: { + shopname: shopName(ctx), + bestellnummer: oderLeer(widerruf?.bestellnummer), + kaeufer_name: oderLeer(widerruf?.name), + kaeufer_email: oderLeer(widerruf?.email), + eingang: eingangText(widerruf?.eingang ?? widerruf?.eingangAt ?? now), + nachricht, + portal_url: portalUrl(ctx), + }, + blocks: { nachricht: nachricht !== '' }, + } +} + +/** + * Schickt eine Haendlermail (K25, K26) an die E-Mail des Kundenkontos, ohne BCC ans Team. kunde_name, shopname und portal_url + * ergaenzt die Funktion, wenn die Vorlage sie kennt und values sie nicht setzt. + * @param {string} template 'K25' | 'K26' + * @param {{ doc: object, shop: object }} ctx + * @param {Record} values + * @param {Record} [blocks] + * @returns {Promise<{ ok: true } | { ok: false, fehler: string }>} + */ +export async function anHaendler(template, ctx, values = {}, blocks = {}) { + try { + const kunde = await customerOf(ctx?.shop?.customerId) + if (!hatText(kunde?.email)) { + console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: Kunde ohne E-Mail-Adresse`) + return { ok: false, fehler: 'Der Kunde hat keine E-Mail-Adresse.' } + } + const erlaubt = loadTemplate(template).placeholders + const standard = { kunde_name: text(kunde.name) || 'Kunde', shopname: shopName(ctx), portal_url: portalUrl(ctx) } + const alle = { ...standard, ...values } + await sendCustomerMail(kunde.email, { + template, + values: Object.fromEntries(Object.entries(alle).filter(([schluessel]) => erlaubt.includes(schluessel))), + blocks, + bccTeam: false, + }) + return { ok: true } + } catch (err) { + // Nur Art und Code des Fehlers: Meldungen des Mailservers koennen die Adresse enthalten + console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: ${err?.name} ${String(err?.code ?? err?.responseCode ?? err?.status ?? '').slice(0, 40)}`.trim()) + return { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' } + } +} diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index fc43e76..998ceb5 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -23,6 +23,7 @@ import { LOESCHFRIST_TAGE } from './frist.js' import { kontextPerFeature, ladeEinstellung } from './kontext.js' import { anGast } from './mails.js' import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js' +import { shopModus } from '../shop/schalter.js' import { termineAus } from './schalter.js' import { buchungsNummer } from './token.js' import { pruefeNow, zeitMs } from './util.js' @@ -57,6 +58,12 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js' * (`eingeschraenkt: true`): nur der Abo-Abgleich und die Loeschung abgeschalteter Funktionen, damit ein beendetes Abo auch bei * TERMINE_ENABLED=off zum Abschalten und zur Loeschung nach 30 Tagen fuehrt. Loeschfristen der Buchungen, Warteschlange und Zellen * bleiben aus. + * - Webshop (SHOP_ENABLED != off): die Mail-Warteschlange gehoert auch den Kaeufermails des Shops (G09-G14, shop/mails.js). Deshalb + * laufen bei Modus off des Terminplaners, aber aktivem Shop, der Mailversand-Teil des Minutenjobs (Warteschlange senden und + * Betriebspruefung) und im Tagesjob das Beenden festgefahrener und das Loeschen alter Warteschlangen-Eintraege weiter; alles + * andere bleibt an den Terminplaner-Modus gebunden. Der Tagesjob ist dann eingeschraenkt (`eingeschraenkt: true`, mit + * `festgefahren` und `aufgeraeumt` im Ergebnis); gibt es keine bezahlten Terminplaner, laufen Abo-Abgleich und Loeschung + * abgeschalteter Funktionen nicht. * - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im * Log je Ueberlappung). Die Sperre gilt je `job:teil` (fuehreAus mit { teil }): haengt beim Minutenjob der Verfall, versendet der * naechste Takt trotzdem die Warteschlange und umgekehrt; uebersprungen gilt der Takt erst, wenn beide Teile uebersprungen sind @@ -127,18 +134,21 @@ async function schritt(lauf, was, fn, ersatz) { * Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true } * `teil` (Name eines Teiljobs, z. B. „Verfall“) gibt dem Teil eine eigene Ueberlappungssperre `job:teil` und nennt ihn in der * Warnung (`job (teil)`); `lauf.job` bleibt der Jobname (Fehlerlog, Warnung bei fehlender Collection). - * `trotzAus` (async, => wahr/falsch): im Modus off laeuft der Job nur, wenn es wahr liefert; dann steht `lauf.eingeschraenkt` auf true - * und der Job beschraenkt sich auf das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen) + * `trotzAus` (async, => falsch/wahr/Objekt): im Modus off laeuft der Job nur, wenn es etwas Wahres liefert; dann steht + * `lauf.eingeschraenkt` auf true, `lauf.freigabe` traegt die Antwort (Tagesjob: { termine, mails }) und der Job beschraenkt sich auf + * das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen) * und { aus: true } geliefert; ohne `trotzAus` gilt Modus off wie bisher (sofort { aus: true }, ohne Abfrage). Die Ueberlappungssperre * gilt unveraendert je Job (bzw. `job:teil`), der eingeschraenkte Lauf teilt sie mit dem vollen. */ async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}) { const nowMs = pruefeNow(now, job) let eingeschraenkt = false + let freigabe = null if (termineAus()) { if (trotzAus === null) return { aus: true } try { - if (!(await trotzAus())) return { aus: true } + freigabe = await trotzAus() + if (!freigabe) return { aus: true } } catch (err) { console.warn(`[termine-jobs] ${job}: Modus off, die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, der Job bleibt aus: ${fehlerText(err)}`) return { aus: true } @@ -155,13 +165,16 @@ async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {} } laufend.add(sperre) try { - return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt }) + return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt, freigabe }) } finally { laufend.delete(sperre) ueberlappGemeldet.delete(sperre) } } +/** Guard des Mailversand-Teils (fuehreAus: trotzAus): bei Modus off des Terminplaners laeuft er, solange der Webshop aktiv ist. */ +const shopMailsLaufen = async () => shopModus() !== 'off' + /** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */ const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0, gedeckelt: 0 }) @@ -285,10 +298,15 @@ export async function minutenJob(now) { await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), []) return { faellig, gesendet, fehler: lauf.fehler } }, - { teil: 'Mailversand' }, + { teil: 'Mailversand', trotzAus: shopMailsLaufen }, ), ]) - if (verfall.aus || mail.aus) return { aus: true } + if (verfall.aus && mail.aus) return { aus: true } + // Terminplaner aus, Webshop aktiv: nur der Mailversand-Teil lief + if (verfall.aus) { + if (mail.uebersprungen) return { uebersprungen: true } + return { eingeschraenkt: true, verfallen: 0, faellig: mail.faellig ?? 0, gesendet: mail.gesendet ?? 0, fehler: mail.fehler ?? 0 } + } if (verfall.uebersprungen && mail.uebersprungen) return { uebersprungen: true } return { verfallen: verfall.verfallen ?? 0, @@ -792,6 +810,23 @@ async function gibtBezahlteTerminplaner() { return treffer.length > 0 } +/** + * Freigabe des Tagesjobs im Modus off (fuehreAus: trotzAus): false (aus) oder { termine, mails }. termine = es gibt ein bezahltes + * Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop ist aktiv (Warteschlange aufraeumen). + * Eine einzige Abfrage; scheitert sie bei aktivem Shop, laeuft nur der Mail-Teil (Warnung), sonst wirft sie wie bisher. + */ +async function tagesjobFreigabe() { + const mails = shopModus() !== 'off' + let termine = false + try { + termine = await gibtBezahlteTerminplaner() + } catch (err) { + if (!mails) throw err + console.warn(`[termine-jobs] taeglicherJob: die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, es läuft nur die Mail-Warteschlange: ${fehlerText(err)}`) + } + return termine || mails ? { termine, mails } : false +} + /** * Abo-Abgleich (aboAbgleich.js) als Schritt des Tagesjobs: ein Fehler stoppt weder die Loeschung noch den Rest. Die Fehler des * Abgleichs (Lese- und Versandfehler, schon im Log) kommen in lauf.fehler. => Zaehler des Abgleichs @@ -864,7 +899,7 @@ export async function taeglicherJob(now) { await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), []) return ergebnis }, - { trotzAus: gibtBezahlteTerminplaner }, + { trotzAus: tagesjobFreigabe }, ) } catch (err) { // Abbruch ausserhalb der Schritte: das Team erfaehrt es; der Fehler (Text im Log des Aufrufers) geht unveraendert weiter @@ -880,19 +915,28 @@ export async function taeglicherJob(now) { * und wird wie im vollen Lauf gemeldet (Fehler gehen als Vorfall ans Team). */ async function eingeschraenkterTagesjob(lauf, now) { - const aboAbgleich = await aboAbgleichSchritt(lauf, now) - const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { - funktionen: 0, - buchungen: 0, - gedeckelt: 0, - }) + // Ohne Freigabe-Angabe (Aufruf ohne tagesjobFreigabe) gilt das bisherige Verhalten: nur Terminplaner-Teile + const termine = lauf.freigabe?.termine !== false + const mails = lauf.freigabe?.mails === true + const aboAbgleich = termine ? await aboAbgleichSchritt(lauf, now) : aboErsatz() + const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = termine + ? await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0, gedeckelt: 0 }) + : { funktionen: 0, buchungen: 0, gedeckelt: 0 } + // Webshop aktiv: festgefahrene Mails beenden (sie bleiben 48 Stunden als Zustellfehler sichtbar), dann alte Eintraege loeschen + const mailErgebnis = mails + ? { + festgefahren: await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0), + aufgeraeumt: await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0), + } + : {} const ergebnis = { eingeschraenkt: true, aboAbgleich, abgeschaltet, - gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 }, + gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt ?? 0, zellen: 0 }, geloescht: abgeschaltet.buchungen, aktiveBetriebe: null, + ...mailErgebnis, fehler: lauf.fehler, } await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), []) @@ -926,9 +970,12 @@ export function tagesbericht(ergebnis) { ? '' : `Abo-Abgleich ${zahl(abo.geprueft)} geprüft / ${zahl(abo.abgeschaltet)} abgeschaltet / ${zahl(abo.wiederEin)} wieder ein / ${zahl(abo.mails)} Mails / ${zahl(abo.ausgenommen)} ausgenommen, ` if (ergebnis.eingeschraenkt) { + // Webshop aktiv: dazu die Warteschlange (festgefahrene Mails beenden, alte Eintraege loeschen) + const mitMails = Number.isInteger(ergebnis.aufgeraeumt) + const mailSegment = mitMails ? `${ergebnis.aufgeraeumt} Outbox, ${zahl(ergebnis.festgefahren)} Mails nicht zugestellt, ` : '' return ( - `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` + - `${a.funktionen} Funktionen bereinigt, ${deckel}${aboSegment}${ergebnis.fehler} Fehler` + `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung${mitMails ? ' sowie Mail-Warteschlange' : ''}): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` + + `${a.funktionen} Funktionen bereinigt, ${mailSegment}${deckel}${aboSegment}${ergebnis.fehler} Fehler` ) } const l = ergebnis.loeschfristen diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js index df994b8..e12d26d 100644 --- a/server/services/termine/outbox.js +++ b/server/services/termine/outbox.js @@ -39,7 +39,7 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js' * pause Anmeldepause laeuft * budget Plaetze im Fenster >= mailsProStunde * reserve nachrangige Mails (G05, K20, K21) im Fenster >= max(1, floor(mailsProStunde x (1 - mailsReserve))): so bleibt - * ein Teil der Stunde den Vorrang-Mails (G01, G02, G08) und den normalen Mails vorbehalten + * ein Teil der Stunde den Vorrang-Mails (G01, G02, G08, Webshop G09, G12) und den normalen Mails vorbehalten * kontingent Mails dieser Funktion im Fenster >= mailsProFunktion: eine Funktion kann die Stunde nicht allein fuellen * * Minutenjob (verarbeite): liest nie alle wartenden Eintraege. Bei Pause oder leerem Gesamtbudget endet er ohne Abfrage. @@ -71,8 +71,12 @@ const ID_BLOCK = 50 const iso = (wertMs) => new Date(wertMs).toISOString() -/** Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). */ -export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08']) +/** + * Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). Dazu die beiden + * Pflichtmails des Webshops: G09 Bestellbestaetigung (dauerhafter Datentraeger) und G12 Eingangsbestaetigung des Widerrufs. Die + * uebrigen Shop-Mails G10, G11, G13, G14 sind 'normal'. + */ +export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08', 'G09', 'G12']) /** Mails, die zuerst warten (Erinnerung, Hinweise an den Betrieb): sie nehmen nie die ganze Stunde. */ export const MAIL_NACHRANGIG = Object.freeze(['G05', 'K20', 'K21']) @@ -115,7 +119,7 @@ function gesamtBremse(nowMs) { * 'pause' | 'budget' | 'reserve' | 'kontingent'. Nur lesen: belegt keinen Platz. * @param {number} nowMs * @param {string} featureId - * @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21') + * @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21', Shop: 'G09' ... 'G14') * @returns {null|'pause'|'budget'|'reserve'|'kontingent'} */ export function pruefePlatz(nowMs, featureId, art) { @@ -438,7 +442,7 @@ async function verarbeiteKlasse(klasse, nowMs, now, lauf) { } /** - * Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08) vor normalen vor nachrangigen (G05, K20, K21), + * Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08, G09, G12) vor normalen vor nachrangigen (G05, K20, K21), * in jeder Klasse die faelligsten zuerst, bis Pause oder Stundenbudget bremsen. Er liest begrenzte Listen statt aller Eintraege * (siehe Kopfkommentar); Eintraege, die Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet`, und die uebrigen * kommen trotzdem dran. Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht. diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index c909cbf..b6ce0cd 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,10 +6,12 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. const DIR = new URL('../server/mail-templates/', import.meta.url) // G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb; -// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) +// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) -const EXPECTED = [...ids('G', 8), ...ids('K', 24), ...ids('T', 19), 'T20', 'T21'] +const EXPECTED = [...ids('G', 14), ...ids('K', 26), ...ids('T', 19), 'T20', 'T21'] +// GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten) const GUEST = ids('G', 8) +const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14'] const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on])) @@ -1073,3 +1075,191 @@ test('K24 abgelehnt: nennt die Begründung (maskiert, Zeilenumbruch), keinen Pre assert.match(mail.html, /Wir verkaufen keine <Waffen>\.
Bitte melde Dich\./) assert.doesNotMatch(mail.text, /299 €|UStG|angenommen/) }) + +// --------------------------------------------------------------------------- +// Webshop E4: Käufermails G09–G14 (Sie-Form, Betreff in der Vorlage, Fuß mit Anbieter und Widerrufsfunktion) +// und K25, K26 an den Händler (Du-Form) +// --------------------------------------------------------------------------- + +const SHOP_GEMEIN = ['farbe', 'betrieb_name', 'anbieter_zeile', 'impressum_url', 'datenschutz_url', 'widerruf_url'] + +test('G09–G14: Platzhalter, Blöcke und Betreff wie im Plan (G-Vorlagen des Webshops haben keine Anrede-Blöcke)', () => { + const erwartet = { + G09: { + s: 'Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}', + p: ['bestellnummer', 'datum', 'positionen', 'zwischensumme', 'versand', 'gesamt', 'steuer_hinweis', 'lieferart_text', 'lieferadresse', 'abholort', 'lieferzeit', 'zahlungsart', 'ausschluss_text', 'agb', 'widerrufsbelehrung', 'widerrufsformular', 'gewaehrleistung'], + b: ['versand', 'abholung', 'ausschluss'], + }, + G10: { s: 'Ihre Bestellung {{bestellnummer}} wurde versendet', p: ['bestellnummer', 'sendungsnummer', 'versanddienst'], b: ['sendung'] }, + G11: { s: 'Ihre Bestellung {{bestellnummer}} ist abholbereit', p: ['bestellnummer', 'abholort', 'hinweis'], b: [] }, + G12: { s: 'Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}}', p: ['name', 'bestellnummer', 'eingang', 'nachricht'], b: ['nachricht'] }, + G13: { s: 'Erstattung zu Ihrer Bestellung {{bestellnummer}}', p: ['bestellnummer', 'betrag', 'grund'], b: ['grund'] }, + G14: { s: 'Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen', p: ['bestellnummer'], b: [] }, + } + assert.deepEqual(Object.keys(erwartet), SHOP_GUEST) + for (const [id, { s, p, b }] of Object.entries(erwartet)) { + const tpl = loadTemplate(id) + assert.equal(tpl.subject, s, id) + assert.deepEqual([...tpl.placeholders].sort(), [...p, ...SHOP_GEMEIN].sort(), `${id}: Platzhalter`) + assert.deepEqual([...tpl.blocks].sort(), [...b].sort(), `${id}: Blöcke`) + } +}) + +test('G09–G14: neutrales Layout in der Shop-Farbe, kein Logo, Fuß mit Anbieter, Impressum, Datenschutz und Widerrufsfunktion', () => { + for (const id of SHOP_GUEST) { + const tpl = loadTemplate(id) + const source = sourceOf(id) + assert.match(source, /]*style="[^"]*background:\{\{farbe\}\}/, `${id}: Kopfbalken in {{farbe}}`) + assert.doesNotMatch(source, /]*>Impressum]*>Datenschutz]*>Vertrag widerrufen', farbe: '#112233', anbieter_zeile: 'Töpferei Muster, Hauptstr. 1, 12345 Musterstadt, info@example.com', impressum_url: 'https://shop.example/impressum', datenschutz_url: 'https://shop.example/datenschutz', widerruf_url: 'https://project.webklar.com/shop/sk/widerruf' } +const g = (id, werte, blocks = {}) => renderMail(id, { ...G_WERTE, ...werte }, blocks) + +const G09_WERTE = { + datum: '05.10.2026', + positionen: '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €', + zwischensumme: '36,50 €', + versand: '4,90 €', + gesamt: '41,40 €', + steuer_hinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + lieferart_text: 'Versand nach Deutschland', + lieferadresse: 'Erika Beispiel\nMusterweg 3\n12345 Musterstadt', + abholort: 'Werkstatt, Hauptstr. 1', + lieferzeit: '2–4 Werktage', + zahlungsart: 'Kreditkarte', + ausschluss_text: 'Teller: Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', + agb: 'AGB-Text Zeile 1\nAGB-Text Zeile 2', + widerrufsbelehrung: 'Widerrufsbelehrung Zeile 1\nZeile 2', + widerrufsformular: 'An Töpferei Muster\nHiermit widerrufe ich', + gewaehrleistung: 'GESETZLICHE GEWÄHRLEISTUNG\n\nMindestens zwei Jahre', +} + +test('G09 Versand: Betreff mit Bestellnummer und Shop, Positionen, Summen, Lieferadresse, alle Rechtstexte, Links', () => { + const mail = g('G09', G09_WERTE, { versand: true, abholung: false, ausschluss: true }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X bei Töpferei ') + assert.match(mail.html, /Töpferei <Muster>/, 'Shopname maskiert') + assert.match(mail.html, /2 × Tasse \(blau\) – 24,00 €
1 × Teller – 12,50 €/) + assert.match(mail.html, /Erika Beispiel
Musterweg 3
12345 Musterstadt/) + for (const teil of ['26-K7QM2X', '05.10.2026', '36,50 €', '4,90 €', '41,40 €', '§ 19 UStG', 'Versand nach Deutschland', '2–4 Werktage', 'Kreditkarte', 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', 'AGB-Text Zeile 2', 'Widerrufsbelehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Hinweis zur gesetzlichen Gewährleistung', 'Muster-Widerrufsformular', 'Allgemeine Geschäftsbedingungen']) { + assert.ok(mail.text.includes(teil), teil) + } + assert.match(mail.text, /Lieferadresse: Erika Beispiel/) + assert.doesNotMatch(mail.text, /Abholort|Werkstatt, Hauptstr/) + assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/shop\/sk\/widerruf"/) +}) + +test('G09 Abholung ohne Ausschluss: Abholort statt Lieferadresse, kein Widerrufs-Ausschluss', () => { + const mail = g('G09', { ...G09_WERTE, lieferart_text: 'Abholung', versand: 'Abholung – kostenlos' }, { versand: false, abholung: true, ausschluss: false }) + assert.deepEqual(mail.problems, []) + assert.match(mail.text, /Abholort: Werkstatt, Hauptstr\. 1/) + assert.doesNotMatch(mail.text, /Lieferadresse|Musterweg|Hinweis zum Widerrufsrecht|ausgeschlossen/) + assert.match(mail.text, /Abholung – kostenlos/) +}) + +test('G10: Sendungsnummer und Versanddienst nur im Block sendung', () => { + const mit = g('G10', { sendungsnummer: '0034<1>', versanddienst: 'DHL' }, { sendung: true }) + assert.deepEqual(mit.problems, []) + assert.equal(mit.subject, 'Ihre Bestellung 26-K7QM2X wurde versendet') + assert.match(mit.text, /Versanddienst: DHL/) + assert.match(mit.text, /Sendungsnummer: 0034<1>/) + assert.match(mit.html, /0034<1>/) + const ohne = g('G10', { sendungsnummer: '', versanddienst: '' }, {}) + assert.deepEqual(ohne.problems, []) + assert.doesNotMatch(ohne.text, /Sendungsnummer|Versanddienst/) + assert.match(ohne.text, /versendet/) +}) + +test('G11: Abholort und Hinweis', () => { + const mail = g('G11', { abholort: 'Werkstatt, Hauptstr. 1', hinweis: 'Di–Fr 10–16 Uhr' }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X ist abholbereit') + assert.match(mail.text, /Abholort: Werkstatt, Hauptstr\. 1/) + assert.match(mail.text, /Hinweis: Di–Fr 10–16 Uhr/) +}) + +test('G12: Eingang mit Datum und Uhrzeit, Erklärung nur im Block nachricht, Händler meldet sich', () => { + const werte = { name: 'Erika ', bestellnummer: '26-ZZZZZZ', eingang: '05.10.2026, 14:03 Uhr', nachricht: 'Bitte stornieren.\nDanke' } + const mit = g('G12', werte, { nachricht: true }) + assert.deepEqual(mit.problems, []) + assert.equal(mit.subject, 'Eingangsbestätigung Ihres Widerrufs bei Töpferei ') + assert.match(mit.text, /Guten Tag Erika ,/) + assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/) + assert.match(mit.text, /Bestellnummer: 26-ZZZZZZ/) + assert.match(mit.html, /Bitte stornieren\.
Danke/) + assert.match(mit.text, /meldet sich bei Ihnen/) + const ohne = g('G12', werte, {}) + assert.doesNotMatch(ohne.text, /Bitte stornieren|Ihre Erklärung:/) +}) + +test('G13: Betrag, Grund nur im Block, Gutschrift über die ursprüngliche Zahlungsart', () => { + const mit = g('G13', { betrag: '12,50 €', grund: 'Artikel beschädigt' }, { grund: true }) + assert.deepEqual(mit.problems, []) + assert.equal(mit.subject, 'Erstattung zu Ihrer Bestellung 26-K7QM2X') + assert.match(mit.text, /Erstatteter Betrag: 12,50 €/) + assert.match(mit.text, /Grund: Artikel beschädigt/) + assert.match(mit.text, /Die Gutschrift erfolgt über die ursprüngliche Zahlungsart\./) + assert.doesNotMatch(g('G13', { betrag: '12,50 €', grund: 'x' }, {}).text, /Grund:/) +}) + +test('G14: nichts berechnet, kein Betrag', () => { + const mail = g('G14', {}) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X wurde nicht abgeschlossen') + assert.match(mail.text, /wurde nicht abgeschlossen; es wurde nichts berechnet\./) +}) + +const K25_WERTE = { kunde_name: 'Erika', shopname: 'Töpferei', bestellnummer: '26-K7QM2X', gesamt: '41,40 €', positionen: '2 × Tasse – 24,00 €\n1 × Teller – 12,50 €', lieferart_text: 'Versand nach Deutschland', kaeufer_name: 'Max ', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' } + +test('K25: Betreff, Platzhalter, Blöcke; Bestand-Warnung und Lastschrift-Hinweis nur im Block', () => { + const tpl = loadTemplate('K25') + assert.equal(tpl.file, 'K25 Neue Bestellung.html') + assert.equal(tpl.subject, 'Neue Bestellung {{bestellnummer}} in Deinem Webshop') + assert.deepEqual([...tpl.placeholders].sort(), Object.keys(K25_WERTE).sort()) + assert.deepEqual(tpl.blocks, ['ueberverkauft', 'sepa']) + const normal = renderMail('K25', K25_WERTE, {}) + assert.deepEqual(normal.problems, []) + assert.equal(normal.subject, 'Neue Bestellung 26-K7QM2X in Deinem Webshop') + assert.match(normal.text, /Hallo Erika/) + assert.match(normal.text, /Käufer: Max /) + assert.match(normal.html, /2 × Tasse – 24,00 €
1 × Teller – 12,50 €/) + assert.match(normal.html, /href="https:\/\/portal\.example\/dashboard\.html#webshop\?projekt=p1"/) + assert.doesNotMatch(normal.text, /Achtung|Lastschrift/) + const voll = renderMail('K25', K25_WERTE, { ueberverkauft: true, sepa: true }) + assert.match(voll.text, /Achtung: Bestand reichte nicht/) + assert.match(voll.text, /Zahlung per Lastschrift/) + assert.doesNotMatch(renderMail('K25', K25_WERTE, { sepa: true }).text, /Achtung/) + assert.doesNotMatch(renderMail('K25', K25_WERTE, { ueberverkauft: true }).text, /Lastschrift/) +}) + +const K26_WERTE = { kunde_name: 'Erika', shopname: 'Töpferei', bestellnummer: '26-K7QM2X', kaeufer_name: 'Max ', kaeufer_email: 'max@example.com', eingang: '05.10.2026, 14:03 Uhr', nachricht: 'Ich widerrufe.\nBitte melden', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' } + +test('K26: Betreff, Platzhalter, Erklärung nur im Block, Hinweis auf die 14 Tage', () => { + const tpl = loadTemplate('K26') + assert.equal(tpl.file, 'K26 Widerruf im Webshop.html') + assert.equal(tpl.subject, 'Widerruf in Deinem Webshop eingegangen') + assert.deepEqual([...tpl.placeholders].sort(), Object.keys(K26_WERTE).sort()) + assert.deepEqual(tpl.blocks, ['nachricht']) + const mit = renderMail('K26', K26_WERTE, { nachricht: true }) + assert.deepEqual(mit.problems, []) + assert.match(mit.text, /Hallo Erika/) + assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/) + assert.match(mit.text, /E-Mail: max@example\.com/) + assert.match(mit.html, /Ich widerrufe\.
Bitte melden/) + assert.match(mit.text, /innerhalb von 14 Tagen/) + assert.doesNotMatch(renderMail('K26', K26_WERTE, {}).text, /Ich widerrufe|Erklärung:/) +}) diff --git a/test/shopMails.test.js b/test/shopMails.test.js new file mode 100644 index 0000000..c93de2b --- /dev/null +++ b/test/shopMails.test.js @@ -0,0 +1,360 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Webshop E4: Mails des Shops (services/shop/mails.js). Käufermails laufen durch die Warteschlange des Terminplaners +// (termineOutbox), Händlermails gehen direkt an das Kundenkonto. Mails landen in der Senke, nichts verlässt den Prozess. +const aw = await startAppwriteMock({ + schema: { termineOutbox: schemaKeys('termineOutbox') }, + datetimes: TERMINE_DATETIMES, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const { gewaehrleistungsHinweis } = await import('../server/services/shop/rechtstexte.js') +const M = await import('../server/services/shop/mails.js') +const { anKaeufer, anHaendler, kaeuferWerte, haendlerK25, haendlerK26 } = M +after(() => setMailTransportForTests(null)) + +const JETZT = new Date('2026-10-05T12:03:00.000Z') // 14:03 Berlin +const SITE_KEY = 'abcdefghijklmnop' +const ABSENDER_VORHER = config.smtp.from +const BASIS_VORHER = config.publicBaseUrl + +const einstellungen = (over = {}) => ({ + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Töpferei Muster', + anbieter: { name: 'Erika Muster', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: 'DE', email: 'info@toepferei.example', telefon: '', ustId: '' }, + lieferzeit: '2–4 Werktage', + akzent: '#aa3300', + abholung: { aktiv: true, adresse: 'Werkstatt, Hauptstr. 1, 67722 Winnweiler', hinweis: 'Di–Fr 10–16 Uhr' }, + ...over, +}) +const texte = (over = {}) => ({ + agb: 'AGB Zeile 1\nAGB Zeile 2', + widerrufsbelehrung: 'Belehrung Zeile 1\nZeile 2', + widerrufsformular: 'An Töpferei Muster\nHiermit widerrufe ich', + impressumUrl: 'https://toepferei.example/impressum', + datenschutzUrl: 'https://toepferei.example/datenschutz', + ...over, +}) +const ctxMit = (e = einstellungen(), t = texte()) => ({ + doc: { $id: 'f_shop01', customerId: 'c1', projectId: 'p1', featureKey: 'shop', siteKey: SITE_KEY }, + shop: { + id: 'f_shop01', + customerId: 'c1', + projectId: 'p1', + siteKey: SITE_KEY, + einstellungen: e, + rechtstexte: { entwurf: texte(), bestaetigt: { texte: t, hash: 'h1', at: JETZT.toISOString(), von: 'c1' } }, + }, +}) + +const bestellung = (over = {}, snap = {}) => ({ + $id: 'o_bestellung01', + bestellnummer: '26-K7QM2X', + kaeuferEmail: 'max@example.com', + bezahltAt: JETZT.toISOString(), + erstelltAt: new Date(JETZT.getTime() - 600000).toISOString(), + summeCent: 4140, + bestellung: JSON.stringify({ + positionen: [ + { name: 'Tasse', varianteText: 'blau', menge: 2, summeCent: 2400, widerruf: 'gesetzlich' }, + { name: 'Teller', varianteText: '', menge: 1, summeCent: 1250, summeText: '12,50 €', widerruf: 'individuell' }, + ], + summen: { zwischensummeCent: 3650, versandCent: 490, gesamtCent: 4140, texte: { zwischensumme: '36,50 €', versand: '4,90 €', gesamt: '41,40 €', steuerHinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' } }, + lieferart: 'versand', + land: 'DE', + kaeufer: { name: 'Max Mustermann', email: 'max@example.com' }, + lieferadresse: { name: 'Max Mustermann', line1: 'Musterweg 3', postal_code: '12345', city: 'Musterstadt', country: 'DE' }, + zahlungsart: 'card', + ...snap, + }), + ...over, +}) + +let sink +let log +beforeEach(async () => { + for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(config.collections.termineOutbox, id) + for (const id of aw.docs('customers').keys()) await deleteDocument(config.collections.customers, id) + resetOutboxForTests() + config.smtp.from = 'noreply@webklar.com' + config.publicBaseUrl = 'https://project.webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } + aw.seed('customers', { $id: 'c1', name: 'Erika Muster', email: 'erika@toepferei.example' }) +}) +afterEach(async () => { + await warteAufVersand() + mock.restoreAll() + config.smtp.from = ABSENDER_VORHER + config.publicBaseUrl = BASIS_VORHER +}) + +const eintraege = () => [...aw.docs('termineOutbox').values()] +const warnLog = () => log.warn.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' ')) + +// ---------------------------------------------------------------- Werte + +test('kaeuferWerte: Kopf, Fuß, Positionen, Summen, Lieferadresse und Rechtstexte aus Schnappschuss und Shop', () => { + const w = kaeuferWerte(ctxMit(), bestellung(), JETZT) + // euro() setzt ein geschütztes Leerzeichen vor das €-Zeichen + for (const [schluessel, wert] of Object.entries(w)) w[schluessel] = wert.replaceAll('\u00a0', ' ') + assert.equal(w.farbe, '#aa3300') + assert.equal(w.betrieb_name, 'Töpferei Muster') + assert.equal(w.anbieter_zeile, 'Erika Muster, Hauptstr. 1, 67722 Winnweiler, info@toepferei.example') + assert.equal(w.widerruf_url, `https://project.webklar.com/shop/${SITE_KEY}/widerruf`) + assert.equal(w.impressum_url, 'https://toepferei.example/impressum') + assert.equal(w.datenschutz_url, 'https://toepferei.example/datenschutz') + assert.equal(w.bestellnummer, '26-K7QM2X') + assert.equal(w.datum, '05.10.2026') + assert.equal(w.positionen, '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €') + assert.deepEqual([w.zwischensumme, w.versand, w.gesamt], ['36,50 €', '4,90 €', '41,40 €']) + assert.equal(w.steuer_hinweis, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + assert.equal(w.lieferart_text, 'Versand nach Deutschland') + assert.equal(w.lieferadresse, 'Max Mustermann\nMusterweg 3\n12345 Musterstadt') + assert.equal(w.abholort, 'Werkstatt, Hauptstr. 1, 67722 Winnweiler') + assert.equal(w.lieferzeit, '2–4 Werktage') + assert.equal(w.zahlungsart, 'Kreditkarte, Apple Pay, Google Pay') + assert.equal(w.ausschluss_text, 'Teller: Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt') + assert.equal(w.agb, 'AGB Zeile 1\nAGB Zeile 2') + assert.equal(w.widerrufsbelehrung, 'Belehrung Zeile 1\nZeile 2') + assert.equal(w.widerrufsformular, 'An Töpferei Muster\nHiermit widerrufe ich') + assert.equal(w.gewaehrleistung, gewaehrleistungsHinweis()) +}) + +test('kaeuferWerte: Links fallen ohne Impressum-/Datenschutz-Adresse auf den Shop zurück, ungültige Farbe auf Schwarz, ohne Bestellung "–"', () => { + const w = kaeuferWerte(ctxMit(einstellungen({ akzent: 'red;x' }), texte({ impressumUrl: '', datenschutzUrl: '' })), null, JETZT) + assert.equal(w.farbe, '#111111') + assert.equal(w.impressum_url, `https://project.webklar.com/shop/${SITE_KEY}`) + assert.equal(w.datenschutz_url, `https://project.webklar.com/shop/${SITE_KEY}`) + assert.equal(w.bestellnummer, '–') + assert.equal(w.positionen, '–') + assert.equal(w.lieferadresse, '–') + assert.equal(w.datum, '05.10.2026', 'ohne Bestellung gilt das übergebene Datum') +}) + +// ---------------------------------------------------------------- Käufermails + +test('G09: wird mit Shopname als Absender und Antwort an den Händler eingereiht, kein BCC, Rechtstexte stehen in der Mail', async () => { + const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT) + assert.equal(erg.ok, true) + assert.equal(erg.gekuerzt, false) + await warteAufVersand() + + const [doc] = eintraege() + assert.equal(doc.art, 'G09') + assert.equal(doc.featureId, 'f_shop01') + assert.equal(doc.bookingId, 'o_bestellung01') + assert.equal(doc.fromName, 'Töpferei Muster') + assert.equal(doc.replyTo, 'info@toepferei.example') + assert.equal(doc.status, 'gesendet') + assert.equal(doc.an, '', 'nach dem Versand ist die Adresse gelöscht') + + assert.equal(sink.sent.length, 1) + const mail = sink.sent[0] + assert.deepEqual(mail.to.map((t) => t.address), ['max@example.com']) + assert.ok(!mail.bcc, 'kein BCC') + assert.equal(mail.from.name, 'Töpferei Muster') + assert.equal(mail.from.address, 'noreply@webklar.com') + assert.equal(mail.replyTo[0].address, 'info@toepferei.example') + assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X bei Töpferei Muster') + for (const teil of ['2 × Tasse (blau) – 24,00 €', '41,40 €', 'Max Mustermann', 'Musterweg 3', 'Vom Widerruf ausgeschlossen', 'AGB Zeile 2', 'Belehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Vertrag widerrufen']) { + assert.ok(mail.text.includes(teil), teil) + } + assert.match(mail.html, new RegExp(`href="https://project\\.webklar\\.com/shop/${SITE_KEY}/widerruf"`)) + assert.doesNotMatch(mail.text, /Abholort/) + assert.deepEqual(warnLog(), [], 'keine Platzhalter-Warnung der Vorlage') +}) + +test('G09 Abholung: Abholort statt Lieferadresse', async () => { + const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { lieferart: 'abholung', lieferadresse: null, land: null, positionen: [{ name: 'Tasse', menge: 1, summeCent: 1200, widerruf: 'gesetzlich' }] }), {}, JETZT) + assert.equal(erg.ok, true) + await warteAufVersand() + const text = sink.sent[0].text + assert.match(text, /Lieferart: Abholung/) + assert.match(text, /Abholort: Werkstatt, Hauptstr\. 1, 67722 Winnweiler/) + assert.doesNotMatch(text, /Lieferadresse|Hinweis zum Widerrufsrecht/) + assert.deepEqual(warnLog(), []) +}) + +test('G09: zu lang für die Warteschlange ⇒ der AGB-Text wird durch einen Link auf die AGB ersetzt, Belehrung und Formular bleiben', async () => { + const lang = 'Dies ist ein langer Satz der Geschäftsbedingungen.\n'.repeat(560) // etwa 28 000 Zeichen + const t = texte({ agb: lang, widerrufsbelehrung: 'Belehrung Satz.\n'.repeat(1000), widerrufsformular: 'Formular Zeile\n'.repeat(300) }) + const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT) + assert.equal(erg.ok, true) + assert.equal(erg.gekuerzt, true) + const doc = eintraege()[0] + assert.ok(doc.html.length <= 100000 && doc.text.length <= 50000, `${doc.html.length}/${doc.text.length}`) + assert.match(doc.text, new RegExp(`Die vollständigen AGB finden Sie unter https://project\\.webklar\\.com/shop/${SITE_KEY}\\?ansicht=agb`)) + assert.doesNotMatch(doc.text, /langer Satz der Geschäftsbedingungen/) + assert.match(doc.text, /Belehrung Satz\./) + assert.match(doc.text, /Formular Zeile/) + assert.match(doc.text, /GESETZLICHE GEWÄHRLEISTUNG/) +}) + +test('G09: auch gekürzt zu lang ⇒ { ok: false } ohne Wurf und ohne Eintrag', async () => { + const t = texte({ widerrufsbelehrung: 'x'.repeat(60000) }) + const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT) + assert.deepEqual(erg, { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' }) + assert.equal(eintraege().length, 0) + assert.equal(sink.sent.length, 0) + assert.doesNotMatch(warnLog().join('\n'), /max@example\.com/) +}) + +test('G10: Versanddienst und Sendungsnummer (Block sendung); ohne beide kein Block', async () => { + assert.equal((await anKaeufer('G10', ctxMit(), bestellung(), { versanddienst: 'DHL', sendungsnummer: '00340434' }, JETZT)).ok, true) + assert.equal((await anKaeufer('G10', ctxMit(), bestellung({ $id: 'o_zwei' }), {}, JETZT)).ok, true) + await warteAufVersand() + const [mit, ohne] = sink.sent + assert.equal(mit.subject, 'Ihre Bestellung 26-K7QM2X wurde versendet') + assert.match(mit.text, /Versanddienst: DHL/) + assert.match(mit.text, /Sendungsnummer: 00340434/) + assert.doesNotMatch(ohne.text, /Sendungsnummer/) +}) + +test('G11: Abholort und Hinweis des Händlers, ersatzweise ein Standardhinweis', async () => { + await anKaeufer('G11', ctxMit(), bestellung(), {}, JETZT) + const ohneHinweis = einstellungen({ abholung: { aktiv: true, adresse: 'Werkstatt', hinweis: '' } }) + await anKaeufer('G11', ctxMit(ohneHinweis), bestellung({ $id: 'o_zwei' }), {}, JETZT) + await warteAufVersand() + const texte11 = sink.sent.map((m) => m.text) + assert.match(texte11[0], /Abholort: Werkstatt, Hauptstr\. 1/) + assert.match(texte11[0], /Hinweis: Di–Fr 10–16 Uhr/) + assert.match(texte11[1], /Hinweis: Bitte bringen Sie Ihre Bestellnummer mit\./) + assert.equal(sink.sent[0].subject, 'Ihre Bestellung 26-K7QM2X ist abholbereit') +}) + +test('G12: ohne passende Bestellung, an die Adresse der Erklärung, mit Datum und Uhrzeit; Vorrang-Klasse', async () => { + const erg = await anKaeufer('G12', ctxMit(), null, { an: 'kaeufer@example.com', bookingId: 'w_widerruf01', name: 'Kai Käufer', bestellnummer: '26-ZZZZZZ', eingang: JETZT, nachricht: 'Ich widerrufe.' }, JETZT) + assert.equal(erg.ok, true) + assert.equal(erg.eintrag.bookingId, 'w_widerruf01') + await warteAufVersand() + const mail = sink.sent[0] + assert.deepEqual(mail.to.map((t) => t.address), ['kaeufer@example.com']) + assert.equal(mail.subject, 'Eingangsbestätigung Ihres Widerrufs bei Töpferei Muster') + assert.match(mail.text, /Guten Tag Kai Käufer,/) + assert.match(mail.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/) + assert.match(mail.text, /Bestellnummer: 26-ZZZZZZ/) + assert.match(mail.text, /Ihre Erklärung: Ich widerrufe\./) + assert.deepEqual(warnLog(), []) +}) + +test('G13: erstatteter Betrag in Euro, Grund nur wenn angegeben; ohne Betrag { ok: false }', async () => { + assert.equal((await anKaeufer('G13', ctxMit(), bestellung(), { betragCent: 1250, grund: 'Artikel beschädigt' }, JETZT)).ok, true) + assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_zwei' }), { betragCent: 4140 }, JETZT)).ok, true) + assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_drei' }), {}, JETZT)).ok, false) + assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_vier' }), { betragCent: 12.5 }, JETZT)).ok, false) + await warteAufVersand() + assert.equal(sink.sent.length, 2) + assert.match(sink.sent[0].text, /Erstatteter Betrag: 12,50 €/) + assert.match(sink.sent[0].text, /Grund: Artikel beschädigt/) + assert.match(sink.sent[1].text, /Erstatteter Betrag: 41,40 €/) + assert.doesNotMatch(sink.sent[1].text, /Grund:/) + assert.match(sink.sent[1].text, /Die Gutschrift erfolgt über die ursprüngliche Zahlungsart\./) +}) + +test('G14: Zahlung fehlgeschlagen, nichts berechnet', async () => { + assert.equal((await anKaeufer('G14', ctxMit(), bestellung(), {}, JETZT)).ok, true) + await warteAufVersand() + assert.equal(sink.sent[0].subject, 'Ihre Bestellung 26-K7QM2X wurde nicht abgeschlossen') + assert.match(sink.sent[0].text, /es wurde nichts berechnet/) +}) + +test('anKaeufer wirft nie: unbekannte Art, fehlende Adresse und Speicherfehler ergeben { ok: false }', async () => { + assert.equal((await anKaeufer('G01', ctxMit(), bestellung(), {}, JETZT)).ok, false) + assert.equal((await anKaeufer('G09', ctxMit(), bestellung({ kaeuferEmail: '' }), {}, JETZT)).ok, false) + assert.equal((await anKaeufer('G09', ctxMit(), bestellung(), {}, new Date('kaputt'))).ok, false) + aw.failNext({ collection: 'termineOutbox', method: 'POST', status: 500, message: 'Nachbau kaputt' }) + const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT) + assert.equal(erg.ok, false) + assert.equal(eintraege().length, 0) + assert.equal(sink.sent.length, 0) +}) + +test('Käufermails zählen als Gästemails: G09 und G12 mit Vorrang, die übrigen normal', async () => { + const { mailKlasse } = await import('../server/services/termine/outbox.js') + assert.deepEqual(['G09', 'G10', 'G11', 'G12', 'G13', 'G14'].map(mailKlasse), ['vorrang', 'normal', 'normal', 'vorrang', 'normal', 'normal']) +}) + +// ---------------------------------------------------------------- Händlermails + +test('K25: geht an das Kundenkonto, ohne BCC ans Team, mit Käufer, Positionen und Portal-Link', async () => { + const { values, blocks } = haendlerK25(ctxMit(), bestellung()) + assert.deepEqual(blocks, { ueberverkauft: false, sepa: false }) + const erg = await anHaendler('K25', ctxMit(), values, blocks) + assert.deepEqual(erg, { ok: true }) + assert.equal(sink.sent.length, 1) + const mail = sink.sent[0] + assert.deepEqual(mail.to.map((t) => t.address), ['erika@toepferei.example']) + assert.ok(!mail.bcc, 'kein BCC ans Team') + assert.equal(mail.subject, 'Neue Bestellung 26-K7QM2X in Deinem Webshop') + assert.match(mail.text, /Hallo Erika Muster/) + assert.match(mail.text, /Töpferei Muster/) + assert.match(mail.text, /Käufer: Max Mustermann/) + assert.match(mail.text, /Gesamt: 41,40 €/) + assert.match(mail.html.replaceAll('\u00a0', ' '), /2 × Tasse \(blau\) – 24,00 €
1 × Teller – 12,50 €/) + assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#webshop\?projekt=p1"/) + assert.doesNotMatch(mail.text, /Achtung|Lastschrift/) + assert.deepEqual(warnLog(), []) +}) + +test('K25: Blöcke ueberverkauft und sepa', async () => { + const { values, blocks } = haendlerK25(ctxMit(), bestellung({}, { zahlungsart: 'sepa_debit' }), { ueberverkauft: true }) + assert.deepEqual(blocks, { ueberverkauft: true, sepa: true }) + assert.equal((await anHaendler('K25', ctxMit(), values, blocks)).ok, true) + assert.match(sink.sent[0].text, /Achtung: Bestand reichte nicht/) + assert.match(sink.sent[0].text, /Zahlung per Lastschrift/) +}) + +test('K26: Widerruf mit Eingang in Berliner Zeit und Erklärung, ohne BCC; ohne Erklärung kein Block', async () => { + const widerruf = { name: 'Kai ', email: 'kai@example.com', bestellnummer: '26-ZZZZZZ', nachricht: 'Ich widerrufe.', eingangAt: JETZT.toISOString() } + const { values, blocks } = haendlerK26(ctxMit(), widerruf) + assert.equal(values.eingang, '05.10.2026, 14:03 Uhr') + assert.deepEqual(blocks, { nachricht: true }) + assert.equal((await anHaendler('K26', ctxMit(), values, blocks)).ok, true) + const ohne = haendlerK26(ctxMit(), { ...widerruf, nachricht: ' ' }) + assert.deepEqual(ohne.blocks, { nachricht: false }) + assert.equal((await anHaendler('K26', ctxMit(), ohne.values, ohne.blocks)).ok, true) + const [mit, kurz] = sink.sent + assert.ok(!mit.bcc) + assert.equal(mit.subject, 'Widerruf in Deinem Webshop eingegangen') + assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/) + assert.match(mit.text, /Käufer: Kai /) + assert.match(mit.text, /E-Mail: kai@example\.com/) + assert.match(mit.text, /Erklärung: Ich widerrufe\./) + assert.match(mit.html, /Kai <K>/) + assert.doesNotMatch(kurz.text, /Erklärung:/) +}) + +test('anHaendler wirft nie: Kunde fehlt oder ohne E-Mail, Versandfehler ⇒ { ok: false } und Warnung ohne Adresse', async () => { + const { values, blocks } = haendlerK25(ctxMit(), bestellung()) + const fremd = ctxMit() + fremd.shop.customerId = 'c_gibtsnicht' + assert.equal((await anHaendler('K25', fremd, values, blocks)).ok, false) + aw.seed('customers', { $id: 'c_ohne', name: 'Ohne', email: '' }) + const ohneMail = ctxMit() + ohneMail.shop.customerId = 'c_ohne' + assert.equal((await anHaendler('K25', ohneMail, values, blocks)).ok, false) + setMailTransportForTests({ + async sendMail() { + throw new Error('SMTP erika@toepferei.example kaputt') + }, + }) + assert.deepEqual(await anHaendler('K25', ctxMit(), values, blocks), { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' }) + assert.equal(sink.sent.length, 0) + assert.doesNotMatch(warnLog().join('\n'), /erika@toepferei\.example/, 'keine Adresse im Log') + assert.ok(warnLog().some((zeile) => /K25 nicht gesendet/.test(zeile))) +}) diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index 4e84318..55d222a 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -3016,3 +3016,90 @@ test('server/index.js: die drei Jobs laufen alle 60 s, alle 5 min und täglich ( } } }) + +// ---------------------------------------------------------------- Webshop: die Mail-Warteschlange gehört auch dem Shop (E4) + +const SHOP_MODUS_VORHER = config.shop.mode +afterEach(() => { + config.shop.mode = SHOP_MODUS_VORHER +}) + +test('Webshop aktiv, Terminplaner off: der Minutenjob sendet die Warteschlange, der Verfall und alles andere bleibt aus', async () => { + await richteFunktionEin() + await legeBuchung('b_abgelaufen', { status: 'gehalten', heldUntil: iso(plus(JETZT, -5 * MIN)) }) + legeEintrag('o_faellig', { art: 'G09' }) + legeEintrag('o_spaeter', { art: 'G09', naechsterVersuch: iso(plus(JETZT, 10 * MIN)) }) + config.termine.mode = 'off' + config.shop.mode = 'pilot' + + assert.deepEqual(await minutenJob(JETZT), { eingeschraenkt: true, verfallen: 0, faellig: 1, gesendet: 1, fehler: 0 }) + assert.equal(sink.sent.length, 1) + assert.equal(aw.docs('termineOutbox').get('o_faellig').status, 'gesendet') + assert.equal(aw.docs('termineOutbox').get('o_spaeter').status, 'wartet') + assert.equal(status('b_abgelaufen'), 'gehalten', 'der Verfall der Haltungen bleibt aus') + + // Shop aus, Terminplaner off: wie bisher nichts + config.shop.mode = 'off' + legeEintrag('o_nochmal', { art: 'G09' }) + assert.deepEqual(await minutenJob(JETZT), { aus: true }) + assert.equal(aw.docs('termineOutbox').get('o_nochmal').status, 'wartet') +}) + +test('Webshop aktiv, Terminplaner off und ohne bezahlte Terminplaner: der Tagesjob beendet festgefahrene Mails und räumt die Warteschlange auf, sonst nichts', async () => { + await richteFunktionEin() // aktiv, nicht bezahlt + await legeLoeschFall('b_verfallen', 'verfallen', 1) + legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) }) + legeEintrag('o_gesendet', { art: 'G09', status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) }) + legeEintrag('o_neu', { art: 'G09' }) + config.termine.mode = 'off' + config.shop.mode = 'on' + + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg, { + eingeschraenkt: true, + aboAbgleich: { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 }, + abgeschaltet: { funktionen: 0, buchungen: 0 }, + gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }, + geloescht: 0, + aktiveBetriebe: null, + festgefahren: 1, + aufgeraeumt: 1, + fehler: 0, + }) + assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'fehler') + assert.equal(aw.docs('termineOutbox').get('o_gesendet'), undefined, 'gesendeter Eintrag gelöscht') + assert.equal(aw.docs('termineOutbox').get('o_neu').status, 'wartet') + assert.ok(buchung('b_verfallen'), 'keine Löschfristen der Buchungen') + assert.equal( + tagesbericht(erg), + '[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung sowie Mail-Warteschlange): 0 Buchungen gelöscht (abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 1 Outbox, 1 Mails nicht zugestellt, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler', + ) +}) + +test('Webshop aktiv, Terminplaner off, bezahlter Terminplaner: Abo-Abgleich und Löschung laufen wie bisher, dazu die Warteschlange', async () => { + await richteFunktionEin(F1, BEZAHLT) + wartung('active') + legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) }) + config.termine.mode = 'off' + config.shop.mode = 'pilot' + + const erg = await taeglicherJob(JETZT) + assert.equal(erg.eingeschraenkt, true) + assert.equal(erg.aboAbgleich.geprueft, 1) + assert.equal(erg.festgefahren, 1) + assert.equal(erg.aufgeraeumt, 0) + assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'fehler') +}) + +test('Webshop aktiv, Terminplaner off: schlägt die Guard-Abfrage fehl, läuft nur die Warteschlange (Warnung ohne Kennungen)', async () => { + legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) }) + config.termine.mode = 'off' + config.shop.mode = 'on' + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt' }) + + const erg = await taeglicherJob(JETZT) + assert.equal(erg.eingeschraenkt, true) + assert.equal(erg.festgefahren, 1) + assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile) && /Nachbau kaputt/.test(zeile)).length, 1) + assert.doesNotMatch(warnLog().join('\n'), /\bb_|\bf_|\bp_|\bc_|@/) +}) diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js index abe80bb..faaf274 100644 --- a/test/termineOutbox.test.js +++ b/test/termineOutbox.test.js @@ -308,10 +308,10 @@ test('Fehlversuche verbrauchen kein Stundenbudget (auch nicht Anmelde- oder Verb /** Eintrag einer bestimmten Funktion und Art (Vorlage bleibt G01: die Warteschlange prueft Art und Vorlage nicht gegeneinander). */ const von = (featureId, art, nummer, extra = {}) => eintrag({ featureId, art, bookingId: `b_${featureId}_${art}_${nummer}`, ...extra }) -test('mailKlasse: G01/G02/G08 vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => { - assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08']) +test('mailKlasse: G01/G02/G08 (Webshop: G09/G12) vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => { + assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08', 'G09', 'G12']) assert.deepEqual(MAIL_NACHRANGIG, ['G05', 'K20', 'K21']) - const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', K20: 'nachrangig', K21: 'nachrangig' } + const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', G09: 'vorrang', G10: 'normal', G11: 'normal', G12: 'vorrang', G13: 'normal', G14: 'normal', K20: 'nachrangig', K21: 'nachrangig' } assert.deepEqual(Object.fromEntries(Object.keys(erwartet).map((art) => [art, mailKlasse(art)])), erwartet) assert.equal(mailKlasse('unbekannt'), 'normal') }) From 809da80c9b53f2fb6132250b30817ce5dcd7237a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:57:02 +0000 Subject: [PATCH 053/105] Shop G1: Verdrahtung #webshop, Webshop-Kachel, Bestellseite mit key und optionaler Zusammenfassung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard-Abschnitt view-webshop, shop.css und Platzhalter shop.js, app.js (Hash, Refresher, Label, Elternansicht), Kachel in funktionen.js nach shop.verfuegbar, GET /api/funktionen liefert shop.verfuegbar, funktion-bestellen.js nimmt key/name/zusammenfassung (Standard: Terminplaner unverändert). Co-Authored-By: Claude Opus 5.5 --- public/app.js | 7 +-- public/dashboard.html | 10 ++++ public/funktion-bestellen.js | 39 +++++++++------ public/funktionen.js | 56 ++++++++++++++++++++- public/shop.css | 9 ++++ public/shop.js | 16 ++++++ server/routes/funktionen.js | 4 ++ test/funktionBestellenKey.test.js | 82 +++++++++++++++++++++++++++++++ test/funktionenRoutes.test.js | 2 + test/funktionenTiles.test.js | 76 ++++++++++++++++++++++++++++ test/shopWiring.test.js | 51 +++++++++++++++++++ 11 files changed, 333 insertions(+), 19 deletions(-) create mode 100644 public/shop.css create mode 100644 public/shop.js create mode 100644 test/funktionBestellenKey.test.js create mode 100644 test/shopWiring.test.js diff --git a/public/app.js b/public/app.js index 02c9075..390bab2 100644 --- a/public/app.js +++ b/public/app.js @@ -139,7 +139,7 @@ function projectState(project) { return ['kb-state-off', 'Noch keine Website'] } -const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner' } +const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop' } function renderProjectCard(project, features) { const li = document.createElement('li') @@ -1910,7 +1910,7 @@ function initPasswordView() { // "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht. // "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt // "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe). -const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' } +const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop' } const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' } const viewLoaders = { chat: initChat } // Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden @@ -1919,6 +1919,7 @@ const viewRefreshers = { news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))), analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))), termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))), + webshop: () => import('/shop.js').then((m) => m.initShop(document.getElementById('webshop-body'))), } const loadedViews = new Set() @@ -2111,7 +2112,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and ( const coarsePointer = window.matchMedia('(pointer: coarse)') // Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil). // "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde. -const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' } +const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects', webshop: 'funktionen' } const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' } const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null } const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])' diff --git a/public/dashboard.html b/public/dashboard.html index e697b2e..c547e6b 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -24,6 +24,7 @@ +