Files
Webklar-Kundenbereich/server/services/appwriteAdmin.js
T
ClaudeandClaude Opus 5.5 e43cdb667b Projekt-Ablauf im Ticketsystem: Anlegen, Archivieren, Import repariert
- Neues Projekt ohne Subdomain: subdomain null statt '' (Unique-Index
  subdomain_unique, jedes weitere scheiterte mit "Document with the
  requested ID already exists"); ebenso Typ "Internes Projekt"
- Anlegen unter derselben Repo-Sperre wie der Webhook repository/created;
  upsertWebsiteProjectByRepo macht aus einem 409 ein Update
- Gitea-Beschreibung = Anzeigename; Art (kind) beim Anlegen aus dem Ticket
- Reaktivieren setzt status zurueck (vorher blieb 'archived' stehen)
- Import: archivierte Repos als archiviert, Archiv-Stand aus Gitea wird
  gespiegelt, ein fehlerhaftes Repo bricht den Lauf nicht mehr ab
- Abgleich ueberschreibt Projektnamen nicht mehr
- CORS erlaubt PUT (Preview-Variablen speichern)
- Vorlage zieht beim Zuweisen nie in die Org um
- Erinnerungs-Lauf ueberspringt archivierte Projekte
- kaputte Umlaute (U+FFFD) in drei Fehlermeldungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:22:53 +00:00

356 lines
11 KiB
JavaScript

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 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()
try {
return await 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(),
},
})
} catch (err) {
// Ein anderer Schreiber ohne gemeinsame Sperre hat das Dokument zwischen Suche und Anlage
// angelegt: der Unique-Index auf repoFullName meldet 409. Dann das vorhandene Dokument
// aktualisieren, statt den Fehler bis in die Oberflaeche zu reichen.
if (err.status !== 409) throw err
const raced = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
if (!raced[0]) throw err
return updateDocument(config.collections.websiteProjects, raced[0].$id, buildProjectPayload(raced[0], data))
}
}
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 }
}