Files
Webklar-Kundenbereich/server/services/ticketAdmin.js
T

232 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<div data-webklar-news="${siteKey}"></div> + 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)
}