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 }