From 4b1df8fbfa415af8327a5e01495f84cef2d159db Mon Sep 17 00:00:00 2001 From: knso Date: Wed, 30 Sep 2026 16:27:27 +0000 Subject: [PATCH] =?UTF-8?q?Funktionen:=20Zust=C3=A4nde,=20Ticket=20beim=20?= =?UTF-8?q?Kauf,=20Freischaltung=20bei=20geschlossenem=20Ticket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/routes/features.js | 1 + server/services/siteFeatures.js | 264 ++++++++++++++++++++++++++++++++ server/services/ticketAdmin.js | 39 +++++ test/siteFeatures.test.js | 112 ++++++++++++++ 4 files changed, 416 insertions(+) create mode 100644 server/services/siteFeatures.js create mode 100644 test/siteFeatures.test.js diff --git a/server/routes/features.js b/server/routes/features.js index ffde5a3..20a047b 100644 --- a/server/routes/features.js +++ b/server/routes/features.js @@ -22,6 +22,7 @@ router.get('/', requireSession, async (req, res) => { projectId: f.projectId || '', featureKey: f.featureKey || '', enabled: Boolean(f.enabled), + state: f.state || (f.enabled ? 'active' : 'off'), unlockedByPurchase: Boolean(f.unlockedByPurchase), purchaseStatus: f.purchaseStatus || '', })) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js new file mode 100644 index 0000000..3f3c4df --- /dev/null +++ b/server/services/siteFeatures.js @@ -0,0 +1,264 @@ +import { createHash, randomBytes } from 'node:crypto' +import { config } from '../config.js' +import { createDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js' +import { createFeatureTicket } from './ticketAdmin.js' + +export const FEATURE_KEYS = ['news'] +export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled'] + +const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567' + +function httpError(status, message) { + const err = new Error(message) + err.status = status + return err +} + +/** 16 Zeichen base32 (a-z, 2-7) aus 80 zufaelligen Bits. */ +export function newSiteKey() { + const bytes = randomBytes(10) + let bits = 0n + for (const b of bytes) bits = (bits << 8n) | BigInt(b) + let out = '' + for (let i = 15; i >= 0; i -= 1) out += BASE32[Number((bits >> BigInt(i * 5)) & 31n)] + return out +} + +/** + * Feste Dokument-ID je (Projekt, Funktion): macht (Kunde, Projekt, key) eindeutig. Passt sie nicht in + * Appwrites 36 Zeichen, wird sie per sha1 gekuerzt (wie eventIdFor). + */ +function featureDocId(projectId, key) { + const plain = `f_${projectId}_${key}` + if (plain.length <= 36 && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(plain)) return plain + return `f_${createHash('sha1').update(`${projectId}_${key}`).digest('hex').slice(0, 34)}` +} + +function assertKey(key) { + if (!FEATURE_KEYS.includes(key)) throw httpError(400, 'Unbekannte Funktion') +} + +/** state und enabled werden immer zusammen geschrieben. */ +function stateFields(state) { + return { state, enabled: state === 'active' } +} + +export async function getFeatureDoc(customerId, projectId, key) { + try { + const doc = await getDocument(config.collections.portalFeatures, featureDocId(projectId, key)) + return doc.customerId === customerId ? doc : null + } catch (err) { + if (err.status === 404) return null + throw err + } +} + +export async function assertOwnProject(customerId, projectId) { + let project = null + try { + project = await getDocument(config.collections.websiteProjects, projectId) + } catch (err) { + if (err.status !== 404) throw err + } + if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt') + return project +} + +/** Dokument holen oder mit state 'off' und dauerhaftem siteKey anlegen. */ +async function ensureFeatureDoc(customerId, projectId, key) { + const existing = await getFeatureDoc(customerId, projectId, key) + if (existing) return existing + try { + return await createDocument( + config.collections.portalFeatures, + { + customerId, + projectId, + featureKey: key, + ...stateFields('off'), + unlockedByPurchase: false, + purchaseStatus: '', + siteKey: newSiteKey(), + }, + featureDocId(projectId, key) + ) + } catch (err) { + if (err.status === 409) { + const again = await getFeatureDoc(customerId, projectId, key) + if (again) return again + } + throw err + } +} + +async function ticketWoid(ticketId) { + if (!ticketId) return null + try { + const ticket = await getDocument(config.collections.workorders, ticketId) + return ticket.woid || null + } catch (err) { + if (err.status === 404) return null + throw err + } +} + +async function featureView(doc) { + return { + state: doc?.state || 'off', + siteKey: doc?.siteKey || null, + ticketWoid: doc?.ticketId ? await ticketWoid(doc.ticketId) : null, + orderedAt: doc?.orderedAt || null, + activatedAt: doc?.activatedAt || null, + invoiceUrl: doc?.invoiceUrl || null, + } +} + +export async function listCustomerFeatures(customerId) { + const projects = await listDocuments(config.collections.websiteProjects, [ + Query.equal('customerId', customerId), + Query.orderDesc('$createdAt'), + ]) + const docs = await listDocuments(config.collections.portalFeatures, [ + Query.equal('customerId', customerId), + Query.limit(1000), + ]) + const result = [] + for (const p of projects) { + const features = {} + for (const key of FEATURE_KEYS) { + const doc = docs.find((d) => d.projectId === p.$id && d.featureKey === key) + features[key] = await featureView(doc) + } + result.push({ + projectId: p.$id, + projectName: p.projectName || '', + liveUrl: p.liveDomain ? `https://${p.liveDomain}` : '', + features, + }) + } + return result +} + +export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) { + assertKey(key) + const doc = await ensureFeatureDoc(customerId, projectId, key) + return updateDocument(config.collections.portalFeatures, doc.$id, { + featureKey: key, + ...stateFields('ordered'), + purchaseStatus: 'ordered', + stripeSessionId: sessionId, + orderedAt: new Date().toISOString(), + consentText: String(consent.text || '').slice(0, 1000), + consentAt: consent.at || null, + consentIp: String(consent.ip || '').slice(0, 64), + consentUserAgent: String(consent.userAgent || '').slice(0, 300), + }) +} + +async function findBySession(sessionId) { + if (!sessionId) return null + const [doc] = await listDocuments(config.collections.portalFeatures, [ + Query.equal('stripeSessionId', sessionId), + Query.limit(1), + ]) + return doc || null +} + +/** Idempotent: Ticket und Wechsel nach 'setup' nur beim Uebergang aus 'ordered'. */ +export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { + const doc = await findBySession(sessionId) + if (!doc) return { doc: null, created: false } + if (doc.state !== 'ordered') return { doc, created: false } + + const project = await getDocument(config.collections.websiteProjects, doc.projectId) + const { ticketId } = await createFeatureTicket({ + customerId: doc.customerId, + project, + featureKey: doc.featureKey, + siteKey: doc.siteKey, + }) + const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('setup'), + purchaseStatus: 'paid', + unlockedByPurchase: true, + stripeInvoiceId: invoiceId || '', + invoiceUrl: invoiceUrl || '', + ticketId, + }) + return { doc: updated, created: true } +} + +/** Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck. */ +export async function markCheckoutFailed(sessionId) { + const doc = await findBySession(sessionId) + if (!doc || doc.state !== 'ordered') return doc + return updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('off'), + purchaseStatus: 'failed', + }) +} + +function byNote(by, verb) { + return by ? `${verb} von ${by}`.slice(0, 300) : undefined +} + +export async function activate(docId, { by } = {}) { + const note = byNote(by, 'Freigeschaltet') + return updateDocument(config.collections.portalFeatures, docId, { + ...stateFields('active'), + activatedAt: new Date().toISOString(), + ...(note ? { adminNote: note } : {}), + }) +} + +export async function disable(docId, { by } = {}) { + const note = byNote(by, 'Abgeschaltet') + return updateDocument(config.collections.portalFeatures, docId, { + ...stateFields('disabled'), + disabledAt: new Date().toISOString(), + ...(note ? { adminNote: note } : {}), + }) +} + +/** Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). */ +export async function adminActivate(customerId, projectId, key, { by } = {}) { + assertKey(key) + await assertOwnProject(customerId, projectId) + const doc = await ensureFeatureDoc(customerId, projectId, key) + return activate(doc.$id, { by }) +} + +/** Alle 'setup'-Funktionen (optional nur eines Kunden), deren Ticket 'Closed' ist, werden 'active'. */ +export async function activateClosedTickets({ now = new Date(), customerId = null } = {}) { + const queries = [Query.equal('state', 'setup'), Query.limit(1000)] + if (customerId) queries.push(Query.equal('customerId', customerId)) + const pending = await listDocuments(config.collections.portalFeatures, queries) + const activated = [] + for (const doc of pending) { + if (!doc.ticketId) continue + let ticket = null + try { + ticket = await getDocument(config.collections.workorders, doc.ticketId) + } catch (err) { + if (err.status === 404) continue + throw err + } + if (ticket.status !== 'Closed') continue + const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('active'), + activatedAt: now.toISOString(), + }) + activated.push(updated) + } + return activated +} + +export async function findActiveBySiteKey(siteKey) { + if (!/^[a-z2-7]{16}$/.test(String(siteKey || ''))) return null + const [doc] = await listDocuments(config.collections.portalFeatures, [ + Query.equal('siteKey', siteKey), + Query.equal('state', 'active'), + Query.limit(1), + ]) + return doc || null +} diff --git a/server/services/ticketAdmin.js b/server/services/ticketAdmin.js index 936cafa..515a332 100644 --- a/server/services/ticketAdmin.js +++ b/server/services/ticketAdmin.js @@ -65,3 +65,42 @@ export async function ensureProjectTicket(project, { repoFullName } = {}) { console.log(`[ticketAdmin] Webpage-Ticket #${woid} fuer Projekt ${project.$id} angelegt`) return ticket.$id } + +/** + * Einrichtungs-Ticket fuer eine gekaufte Funktion (z. B. News-Pinnwand) einer Kundenwebsite. + * Wird geschlossen, sobald die Funktion eingebaut ist; danach schaltet das Portal sie frei. + */ +export async function createFeatureTicket({ customerId, project, featureKey, siteKey }) { + const customer = await getDocument(config.collections.customers, customerId).catch(() => null) + const woid = await generateNextWoid() + const projectName = project?.projectName || project?.subdomain || '' + const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || '' + const embed = `
+ https://project.webklar.com/embed/news.js` + + const ticket = await createDocument( + config.collections.workorders, + { + topic: `News-Pinnwand einbauen – ${projectName}`.trim(), + type: 'Webpage', + status: 'Open', + priority: 1, + serviceType: 'Remote', + systemType: 'n/a', + customerId, + customerName: customer?.name || '', + customerLocation: customer?.location || '', + woid, + details: [ + `Funktion: ${featureKey}`, + `Projekt: ${projectName}`, + `Domain: ${domain}`, + `siteKey: ${siteKey}`, + `Einbau: ${embed}`, + ].join('\n'), + createdAt: new Date().toISOString(), + }, + ID.unique() + ) + console.log(`[ticketAdmin] Funktions-Ticket #${woid} (${featureKey}) fuer Projekt ${project?.$id} angelegt`) + return { ticketId: ticket.$id, woid } +} diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js new file mode 100644 index 0000000..beeb5dd --- /dev/null +++ b/test/siteFeatures.test.js @@ -0,0 +1,112 @@ +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' + +// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau +const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } }) +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const sf = await import('../server/services/siteFeatures.js') + +aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin' }) +aw.seed('customers', { $id: 'c2', name: 'Kunde Zwei' }) +aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' }) +aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café', subdomain: 'cafe' }) +aw.seed('websiteProjects', { $id: 'p3', customerId: 'c2', projectName: 'Fremd' }) +aw.seed('websiteProjects', { $id: 'p_' + 'x'.repeat(40), customerId: 'c1', projectName: 'Lang' }) + +const consent = { text: 'Ich stimme zu', at: '2026-09-30T10:00:00.000Z', ip: '1.2.3.4', userAgent: 'UA' } +const tickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('News-Pinnwand einbauen')) + +test('ohne_dokument_ist_off', async () => { + const list = await sf.listCustomerFeatures('c1') + assert.equal(list.length, 3) + for (const p of list) assert.equal(p.features.news.state, 'off') + assert.equal(list.find((p) => p.projectId === 'p1').liveUrl, 'https://baeckerei.de') +}) + +test('bezahlt_legt_ticket_an', async () => { + const ordered = await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_1', consent }) + assert.equal(ordered.state, 'ordered') + assert.equal(ordered.enabled, false) + assert.equal(ordered.consentIp, '1.2.3.4') + assert.match(ordered.siteKey, /^[a-z2-7]{16}$/) + const { doc, created } = await sf.markPaid({ sessionId: 'cs_1', invoiceId: 'in_1', invoiceUrl: 'https://x/inv' }) + assert.equal(created, true) + assert.equal(doc.state, 'setup') + assert.equal(doc.enabled, false) + assert.equal(doc.unlockedByPurchase, true) + assert.ok(doc.ticketId) + assert.equal(tickets().length, 1) + assert.match(tickets()[0].topic, /^News-Pinnwand einbauen – Bäckerei/) + assert.ok(tickets()[0].details.includes(`data-webklar-news="${doc.siteKey}"`)) + const view = (await sf.listCustomerFeatures('c1')).find((p) => p.projectId === 'p1').features.news + assert.equal(view.state, 'setup') + assert.ok(view.ticketWoid) +}) + +test('webhook_idempotent', async () => { + const again = await sf.markPaid({ sessionId: 'cs_1', invoiceId: 'in_1', invoiceUrl: 'https://x/inv' }) + assert.equal(again.created, false) + assert.equal(tickets().length, 1) +}) + +test('ticket_geschlossen_aktiviert', async () => { + const doc = await sf.getFeatureDoc('c1', 'p1', 'news') + assert.deepEqual(await sf.activateClosedTickets({ customerId: 'c1' }), []) + assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, 'setup') + aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' }) + assert.deepEqual(await sf.activateClosedTickets({ customerId: 'c2' }), []) + const now = new Date('2026-10-01T08:00:00Z') + const done = await sf.activateClosedTickets({ now }) + assert.equal(done.length, 1) + assert.equal(done[0].state, 'active') + assert.equal(done[0].enabled, true) + assert.equal(done[0].activatedAt, now.toISOString()) + assert.equal((await sf.findActiveBySiteKey(doc.siteKey)).$id, doc.$id) +}) + +test('fehlgeschlagene_zahlung_setzt_off', async () => { + await sf.markOrdered({ customerId: 'c1', projectId: 'p2', key: 'news', sessionId: 'cs_2', consent }) + const doc = await sf.markCheckoutFailed('cs_2') + assert.equal(doc.state, 'off') + assert.equal(doc.enabled, false) + assert.equal(await sf.markCheckoutFailed('cs_unbekannt'), null) +}) + +test('fremdes_projekt_403', async () => { + await assert.rejects(() => sf.assertOwnProject('c1', 'p3'), { status: 403 }) + await assert.rejects(() => sf.assertOwnProject('c1', 'gibts_nicht'), { status: 403 }) + assert.equal((await sf.assertOwnProject('c1', 'p1')).$id, 'p1') +}) + +test('sitekey_format', () => { + const keys = new Set() + for (let i = 0; i < 100; i += 1) { + const k = sf.newSiteKey() + assert.match(k, /^[a-z2-7]{16}$/) + keys.add(k) + } + assert.equal(keys.size, 100) +}) + +test('admin_aktiviert_ohne_zahlung', async () => { + const before = tickets().length + const doc = await sf.adminActivate('c1', 'p2', 'news', { by: 'kenso' }) + assert.equal(doc.state, 'active') + assert.equal(doc.enabled, true) + assert.equal(doc.unlockedByPurchase, false) + assert.equal(tickets().length, before) + const off = await sf.disable(doc.$id, { by: 'kenso' }) + assert.equal(off.state, 'disabled') + assert.equal(off.enabled, false) +}) + +test('lange_projekt_id_bekommt_gueltige_dokument_id', async () => { + const doc = await sf.adminActivate('c1', 'p_' + 'x'.repeat(40), 'news', {}) + assert.ok(doc.$id.length <= 36) +})