Funktionen: Zustände, Ticket beim Kauf, Freischaltung bei geschlossenem Ticket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 16:27:27 +00:00
1 parent 16fbb03c13
commit 4b1df8fbfa
4 files changed
+416

No files matched your search

+1
View File
@@ -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 || '',
}))
+264
View File
@@ -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
}
+39
View File
@@ -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 = `<div data-webklar-news="${siteKey}"></div> + 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 }
}
+112
View File
@@ -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)
})