import { randomUUID } from 'node:crypto' import { config, WOMS_DATABASE_ID } from '../config.js' function buildQueries(queries = []) { return queries.map((q) => { if (typeof q === 'string') return q return JSON.stringify(q) }) } function adminHeaders() { return { 'Content-Type': 'application/json', 'X-Appwrite-Project': config.appwrite.projectId, 'X-Appwrite-Key': config.appwrite.apiKey, } } function formatRequestBody(body, method, { raw = false } = {}) { if (!body || method === 'GET' || method === 'DELETE') return body if (raw || body.data !== undefined) return body const { documentId, ...fields } = body const payload = { data: fields } if (documentId) payload.documentId = documentId return payload } /** raw: true schickt den Body unverpackt (sonst wird er zu { data, documentId }; Pfade unter /users sind immer roh). */ async function adminFetch(path, { method = 'GET', body, queries = [], raw = false } = {}) { if (!config.appwrite.apiKey) { const error = new Error('APPWRITE_API_KEY fehlt in .env') error.status = 500 throw error } const url = new URL(`${config.appwrite.endpoint}${path}`) for (const q of buildQueries(queries)) { url.searchParams.append('queries[]', q) } const useRawBody = raw || path.startsWith('/users') const requestBody = formatRequestBody(body, method, { raw: useRawBody }) const response = await fetch(url.toString(), { method, headers: adminHeaders(), body: requestBody ? JSON.stringify(requestBody) : undefined, }) const text = await response.text() let data = null if (text) { try { data = JSON.parse(text) } catch { data = { message: text } } } if (!response.ok) { const error = new Error(data?.message || `Appwrite ${response.status}`) error.status = response.status >= 500 ? 500 : response.status error.code = data?.code error.type = data?.type throw error } return data } /** Roher Server-Key-Aufruf (Body unverpackt), z. B. fuer Collection- und Bucket-Rechte (staffAccessAdmin.js). */ export async function appwriteAdminRequest(path, options = {}) { return adminFetch(path, { ...options, raw: true }) } /** Appwrite Query-Helper (kompatibel zu bisherigen Aufrufen) */ export const Query = { equal: (attribute, value) => ({ method: 'equal', attribute, values: Array.isArray(value) ? value : [value], }), limit: (n) => ({ method: 'limit', values: [n] }), greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }), lessThan: (attribute, value) => ({ method: 'lessThan', attribute, values: [value] }), greaterThanEqual: (attribute, value) => ({ method: 'greaterThanEqual', attribute, values: [value] }), lessThanEqual: (attribute, value) => ({ method: 'lessThanEqual', attribute, values: [value] }), /** Beide Grenzen inklusiv */ between: (attribute, start, end) => ({ method: 'between', attribute, values: [start, end] }), /** Seite nach dem Dokument mit dieser ID (zusammen mit orderAsc/orderDesc und limit) */ cursorAfter: (documentId) => ({ method: 'cursorAfter', values: [documentId] }), orderDesc: (attribute) => ({ method: 'orderDesc', attribute }), orderAsc: (attribute) => ({ method: 'orderAsc', attribute }), } export const ID = { unique: () => randomUUID(), } function collectionPath(collectionId) { return `/databases/${config.appwrite.databaseId}/collections/${collectionId}/documents` } export async function getUserById(userId) { return adminFetch(`/users/${userId}`) } export async function createAppwriteUser({ email, password, name }) { return adminFetch('/users', { method: 'POST', body: { userId: ID.unique(), email: email.trim(), password, name: name?.trim() || email.trim(), }, }) } export async function updateAppwriteUserEmail(userId, email) { return adminFetch(`/users/${userId}/email`, { method: 'PATCH', body: { email: email.trim() }, }) } export async function updateAppwriteUserPassword(userId, password) { return adminFetch(`/users/${userId}/password`, { method: 'PATCH', body: { password }, }) } export async function updateAppwriteUserName(userId, name) { return adminFetch(`/users/${userId}/name`, { method: 'PATCH', body: { name: name.trim() }, }) } export async function updateAppwriteUserLabels(userId, labels) { return adminFetch(`/users/${userId}/labels`, { method: 'PUT', body: { labels }, }) } export async function updateAppwriteUserMfa(userId, mfa) { return adminFetch(`/users/${userId}/mfa`, { method: 'PATCH', body: { mfa: Boolean(mfa) } }) } export async function updateAppwriteUserEmailVerification(userId, emailVerification) { return adminFetch(`/users/${userId}/verification`, { method: 'PATCH', body: { emailVerification: Boolean(emailVerification) }, }) } export async function deleteAppwriteUser(userId) { return adminFetch(`/users/${userId}`, { method: 'DELETE' }) } export async function deleteUserSession(userId, sessionId) { return adminFetch(`/users/${userId}/sessions/${sessionId}`, { method: 'DELETE' }) } export async function listDocuments(collectionId, queries = []) { const result = await adminFetch(collectionPath(collectionId), { queries }) return result.documents } /** * Ein Aufruf mit allen Seiten: sortiert nach $id, Seite fuer Seite per cursorAfter, bis eine Seite kuerzer als * pageSize ist. queries enthalten nur Filter, nie limit/order/cursor (die setzt die Funktion selbst). Mehr als max * Treffer ist ein Fehler (Schutz vor unbeabsichtigtem Vollabzug). */ export async function listAllDocuments(collectionId, queries = [], { pageSize = 500, max = 20000 } = {}) { if (!Number.isInteger(pageSize) || pageSize < 1) throw new Error(`listAllDocuments: pageSize muss eine ganze Zahl ab 1 sein, nicht ${pageSize}`) const all = [] let cursor = null for (;;) { const page = await listDocuments(collectionId, [ ...queries, Query.limit(pageSize), Query.orderAsc('$id'), ...(cursor ? [Query.cursorAfter(cursor)] : []), ]) all.push(...page) if (all.length > max) { const error = new Error(`listAllDocuments: mehr als ${max} Dokumente in ${collectionId}`) error.status = 500 error.type = 'list_limit_exceeded' throw error } if (page.length < pageSize) return all cursor = page[page.length - 1].$id } } export async function getCustomerByAppwriteUserId(appwriteUserId) { const docs = await listDocuments(config.collections.customers, [ Query.equal('appwriteUserId', appwriteUserId), Query.limit(1), ]) return docs[0] || null } export async function getCustomerByEmail(email) { if (!email) return null const docs = await listDocuments(config.collections.customers, [ Query.equal('email', email.trim()), Query.limit(1), ]) return docs[0] || null } export async function getEmployeeByUserId(userId) { if (!userId) return null const docs = await listDocuments(config.collections.employees, [ Query.equal('userId', userId), Query.limit(1), ]) return docs[0] || null } export async function getEmployeeByEmail(email) { if (!email) return null const docs = await listDocuments(config.collections.employees, [ Query.equal('email', email.trim().toLowerCase()), Query.limit(1), ]) return docs[0] || null } export async function getPortalAccessByCustomerId(customerId) { const docs = await listDocuments(config.collections.customerPortalAccess, [ Query.equal('customerId', customerId), Query.limit(1), ]) return docs[0] || null } export async function updateDocument(collectionId, documentId, data) { return adminFetch(`${collectionPath(collectionId)}/${documentId}`, { method: 'PATCH', body: data, }) } export async function getDocument(collectionId, documentId) { return adminFetch(`${collectionPath(collectionId)}/${documentId}`) } export async function createDocument(collectionId, data, documentId = ID.unique()) { return adminFetch(collectionPath(collectionId), { method: 'POST', body: { ...data, documentId }, }) } /** * Mehrere Dokumente in einer Anfrage (Appwrite 1.8.1 Bulk-Create, hoechstens 100): alles oder nichts. Ist eine * $id schon vergeben, antwortet Appwrite 409 document_already_exists und schreibt nichts (err.status === 409). * Jedes Dokument bringt seine $id mit: [{ $id, ...felder }]. Liefert die angelegten Dokumente. */ export async function createDocuments(collectionId, documents) { if (documents.length === 0) return [] const result = await adminFetch(collectionPath(collectionId), { method: 'POST', body: { documents }, raw: true }) return result.documents } export async function deleteDocument(collectionId, documentId) { return adminFetch(`${collectionPath(collectionId)}/${documentId}`, { method: 'DELETE', }) } const PRESERVE_IF_EMPTY = new Set(['customerId', 'ticketId']) function buildProjectPayload(existing, data) { const now = new Date().toISOString() const payload = { updatedAt: now } for (const [key, value] of Object.entries(data)) { if (value === undefined) continue if (PRESERVE_IF_EMPTY.has(key) && (value === '' || value === null)) { if (existing?.[key]) continue } payload[key] = value } return payload } export async function upsertWebsiteProjectByRepo(repoFullName, data) { const existing = await listDocuments(config.collections.websiteProjects, [ Query.equal('repoFullName', repoFullName), Query.limit(1), ]) const payload = buildProjectPayload(existing[0], data) if (existing[0]) { return updateDocument( config.collections.websiteProjects, existing[0].$id, payload ) } 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(), }, }) } export async function verifyDatabaseAccess() { if (!config.appwrite.apiKey) { return { ok: false, reason: 'APPWRITE_API_KEY fehlt' } } try { await listDocuments(config.collections.customers, [Query.limit(1)]) return { ok: true } } catch (err) { return { ok: false, reason: err.message, code: err.code, type: err.type, status: err.status, } } } export function createAdminClient() { return { usesNativeFetch: true, databaseId: WOMS_DATABASE_ID } } export function createUserClient() { return { usesNativeFetch: true } }