232 lines
9.0 KiB
JavaScript
232 lines
9.0 KiB
JavaScript
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)
|
||
}
|