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:
1 parent
16fbb03c13
commit
4b1df8fbfa
4 files changed
+416
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user