import { config } from '../config.js' import { berlinDateTime } from './berlinTime.js' import { createDocument, deleteDocument, getDocument, listDocuments, updateDocument, Query, ID, } from './appwriteAdmin.js' import { kuerze, LANGE_DETAILS, TICKET_DETAILS_MAX, TICKET_GEKUERZT, TICKET_TOPIC_MAX } from './featureRegistry.js' const WOID_BASE = 9999 async function generateNextWoid() { // Numerisches Maximum ueber alle Tickets - orderDesc('woid') sortiert Strings // und liefert bei gemischt 5-/6-stelligen WOIDs falsche Ergebnisse. const docs = await listDocuments(config.collections.workorders, [Query.limit(1000)]) const highest = docs.reduce((max, doc) => { const n = parseInt(doc.woid, 10) return Number.isNaN(n) ? max : Math.max(max, n) }, WOID_BASE) return String(highest + 1) } /** * Stellt sicher, dass ein Website-Projekt ein Ticket hat, damit Git-Pushes * als Worksheets (WSIDs) getrackt werden koennen. Legt bei Bedarf automatisch * ein 'Webpage'-Ticket fuer den Kunden des Projekts an und verknuepft es. * Projekte ohne Kunde bekommen kein automatisches Ticket. */ export async function ensureProjectTicket(project, { repoFullName } = {}) { if (!project) return null if (project.ticketId) return project.ticketId if (!project.customerId) return null const customer = await getDocument(config.collections.customers, project.customerId).catch(() => null) if (!customer) return null const woid = await generateNextWoid() const now = new Date().toISOString() const ticket = await createDocument( config.collections.workorders, { topic: `Website ${project.projectName || repoFullName || project.subdomain || ''}`.trim(), type: 'Webpage', status: 'Open', priority: 1, serviceType: 'Remote', systemType: 'n/a', customerId: project.customerId, customerName: customer.name || '', customerLocation: customer.location || '', woid, details: `Automatisch angelegt fuer das Repository ${repoFullName || project.repoFullName || ''} (erster Git-Push).`, createdAt: now, }, ID.unique() ) await updateDocument(config.collections.websiteProjects, project.$id, { ticketId: ticket.$id, updatedAt: now, }) console.log(`[ticketAdmin] Webpage-Ticket #${woid} fuer Projekt ${project.$id} angelegt`) return ticket.$id } /** * Schlusszeile der Ticket-Details im Abo-Modell (V7): sagt dem Team, wie die Einrichtung abgerechnet wird. Steht immer als letzte * Zeile, auch nach dem Kuerzen und im Notfallweg. */ export const ABRECHNUNGS_ZEILEN = { abo: 'Zeit im Arbeitsblatt erfassen und aufs Abo-Stundenkonto anrechnen (Einrichtung aus dem Abo, Vorschuss möglich).', projekt: 'Zeit im Arbeitsblatt erfassen, nicht aufs Abo anrechnen (im Projekt enthalten).', } /** * Einrichtungs-Ticket fuer eine gekaufte Funktion (z. B. News-Pinnwand, Terminplaner) einer Kundenwebsite. * Wird geschlossen, sobald die Funktion eingebaut ist; danach schaltet das Portal sie frei. * Einzige Stelle, die dieses Ticket anlegt. topic und details sind optional (ohne: die News-Festwerte); markPaid uebergibt * die Registertexte (ticketTexte). Beides wird auf TICKET_TOPIC_MAX / TICKET_DETAILS_MAX gekuerzt. * Wiederholung nur, wenn das Anlegen mit 400 scheitert, fallbackDetails uebergeben wurde und die Details lang waren * (LANGE_DETAILS): dann einmal mit fallbackDetails und derselben woid. Jeder andere Fehler (5xx, Netz: unklar, ob das Ticket * entstand) wird ohne Wiederholung weitergereicht, sonst gaebe es Doppel-Tickets; scheitert auch der Rueckfall, wird der erste * Fehler geworfen. * abrechnung ('' | 'abo' | 'projekt', Abo-Modell): setzt am Ticket einrichtungFuer (= featureKey) und einrichtungAbrechnung und haengt * ABRECHNUNGS_ZEILEN[abrechnung] als letzte Zeile an die Details (der Text wird dafuer auf TICKET_DETAILS_MAX minus Zeile gekuerzt). * Ohne abrechnung schreibt das Ticket weder die Felder noch die Zeile (alte Ansicht unveraendert). Ein anderer Wert ist ein * TypeError vor jedem Schreibzugriff. */ export async function createFeatureTicket({ customerId, project, featureKey, siteKey, topic = null, details = null, fallbackDetails = null, abrechnung = '', }) { if (abrechnung !== '' && !(typeof abrechnung === 'string' && Object.hasOwn(ABRECHNUNGS_ZEILEN, abrechnung))) { throw new TypeError(`createFeatureTicket: abrechnung muss '', 'abo' oder 'projekt' sein, nicht ${String(abrechnung)}`) } const zeile = abrechnung ? ABRECHNUNGS_ZEILEN[abrechnung] : '' // Details auf TICKET_DETAILS_MAX kuerzen; mit Abrechnung bleibt Platz fuer die Schlusszeile (und ihren Zeilenumbruch) const kuerzeDetails = (roh) => { const gekuerzt = kuerze(roh, zeile ? TICKET_DETAILS_MAX - zeile.length - 1 : TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) return { gekuerzt, text: zeile ? `${gekuerzt}\n${zeile}` : gekuerzt } } 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 = `
+ https://project.webklar.com/embed/news.js` const { gekuerzt, text } = kuerzeDetails( details || [ `Funktion: ${featureKey}`, `Projekt: ${projectName}`, `Domain: ${domain}`, `siteKey: ${siteKey}`, `Einbau: ${embed}`, 'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)', ].join('\n') ) const fields = { topic: kuerze(topic || `News-Pinnwand einbauen – ${projectName}`.trim(), TICKET_TOPIC_MAX, { endzeile: '…' }), type: 'Webpage', status: 'Open', priority: 1, serviceType: 'Remote', systemType: 'n/a', customerId, customerName: customer?.name || '', customerLocation: customer?.location || '', woid, createdAt: new Date().toISOString(), ...(abrechnung ? { einrichtungFuer: featureKey, einrichtungAbrechnung: abrechnung } : {}), } let ticket try { ticket = await createDocument(config.collections.workorders, { ...fields, details: text }, ID.unique()) } catch (firstError) { const hatRueckfall = typeof fallbackDetails === 'string' && fallbackDetails !== '' if (firstError?.status !== 400 || !hatRueckfall || gekuerzt.length <= LANGE_DETAILS) throw firstError console.warn(`[ticketAdmin] Funktions-Ticket #${woid} (${featureKey}) abgelehnt (400), zweiter Versuch mit Notfall-Details:`, firstError.message) try { ticket = await createDocument( config.collections.workorders, { ...fields, details: kuerzeDetails(fallbackDetails).text }, ID.unique() ) } catch (secondError) { console.error(`[ticketAdmin] Funktions-Ticket #${woid} (${featureKey}): auch der zweite Versuch scheiterte:`, secondError.message) throw firstError } } console.log(`[ticketAdmin] Funktions-Ticket #${woid} (${featureKey}) fuer Projekt ${project?.$id} angelegt`) return { ticketId: ticket.$id, woid } } /** * Legt ein Ticket fuer ein Projekt an (Zuordnen-Flow, erst beim Abschliessen). * Unbekannter Kunde => Fehler mit status 400. */ export async function createTicketForProject({ customerId, topic, type, requestedBy, startDate, details }, { now = new Date() } = {}) { const customer = customerId ? await getDocument(config.collections.customers, customerId).catch(() => null) : null if (!customer) { const err = new Error('Kunde nicht gefunden.') err.status = 400 throw err } // WOMS speichert Ticket-Daten als 'TT.MM.JJJJ'; leer = heute (Berlin). let date if (!startDate) { date = berlinDateTime(now).date } else { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(startDate)) const valid = m && (() => { const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])) return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3] })() if (!valid) { const err = new Error('Das Startdatum ist ungültig (erwartet: JJJJ-MM-TT).') err.status = 400 throw err } date = `${m[3]}.${m[2]}.${m[1]}` } const woid = await generateNextWoid() const ticket = await createDocument( config.collections.workorders, { topic, type, status: 'Open', priority: 1, serviceType: 'Remote', systemType: 'n/a', customerId, customerName: customer.name || '', customerLocation: customer.location || '', requestedBy: requestedBy || '', startDate: date, deadline: date, details: details || '', woid, createdAt: new Date().toISOString(), }, ID.unique() ) console.log(`[ticketAdmin] Ticket #${woid} fuer Kunde ${customerId} angelegt`) return { $id: ticket.$id, woid } } /** Loescht ein Ticket wieder (Rollback, wenn der Zuordnen-Flow scheitert). */ export async function deleteTicket(ticketId) { await deleteDocument(config.collections.workorders, ticketId) }