Funktions-Ticket: Text aus dem Register, Laengendeckel, Notfall-Details und gezielte Wiederholung

markPaid holt Betreff und Details des Einrichtungs-Tickets aus dem Register (ticketTexte), createFeatureTicket
bleibt die einzige Stelle, die das Ticket anlegt. Betreff auf 128, Details auf 7000 Zeichen gekuerzt (surrogatsicher,
Schnitt an der Zeilengrenze, einheitliche Schlusszeile). Wirft die Details-Funktion oder liefert sie keinen Text,
gelten die Notfall-Details (Kopfzeilen und Verweis ins WOMS). Nach einem 400 bei langen Details gibt es genau einen
zweiten Versuch mit den Notfall-Details und derselben woid; 5xx, Netzfehler und kurze Details werden ohne
Wiederholung weitergereicht. Stub termine/ticketDetails.js bis Task D4.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 12:48:02 +00:00
1 parent 7fcb268644
commit 9dd0244537
5 files changed
+678 -20

No files matched your search

+103
View File
@@ -116,3 +116,106 @@ export function oeffentlicheAnsicht(key) {
requiresWartungsAbo: e.requiresWartungsAbo === true,
}
}
// ---------------------------------------------------------------------------------------------------------------
// Einrichtungs-Ticket aus dem Register (Task B8): Texte je Funktion, Laengendeckel und Notfall-Details.
// createFeatureTicket (ticketAdmin.js) ist die einzige Stelle, die das Ticket anlegt; markPaid holt die Texte hier.
// ---------------------------------------------------------------------------------------------------------------
/** Laengendeckel der Ticket-Felder (workorders.topic / workorders.details). */
export const TICKET_TOPIC_MAX = 128
export const TICKET_DETAILS_MAX = 7000
/** Ab dieser Laenge gelten Details als "lang": nur dann wiederholt createFeatureTicket nach einem 400 mit den Notfall-Details. */
export const LANGE_DETAILS = 1000
/** Einheitliche Schlusszeile eines gekuerzten Ticket-Textes. */
export const TICKET_GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen'
const istHighSurrogat = (code) => code >= 0xd800 && code <= 0xdbff
/**
* Text auf hoechstens max Zeichen (UTF-16-Einheiten, wie string.length) kuerzen, surrogatsicher: nie ein halbes Zeichenpaar.
* Passt der Text, bleibt er unveraendert (ohne endzeile). Sonst wird so viel Text behalten, dass mit der Endzeile genau max
* nicht ueberschritten wird: anZeile schneidet an der letzten Zeilengrenze (ohne Zeilengrenze im Platz: harter Schnitt) und
* setzt die Endzeile in eine eigene Zeile; ohne anZeile haengt die Endzeile direkt an den Schnitt.
*/
export function kuerze(text, max, { anZeile = false, endzeile = '' } = {}) {
const s = typeof text === 'string' ? text : String(text ?? '')
if (s.length <= max) return s
const trenner = anZeile && endzeile ? '\n' : ''
const platz = max - endzeile.length - trenner.length
const hartSchnitt = (n) => {
let teil = s.slice(0, Math.max(0, n))
if (teil && istHighSurrogat(teil.charCodeAt(teil.length - 1))) teil = teil.slice(0, -1)
return teil
}
if (platz <= 0) return hartSchnitt(max)
let teil
if (anZeile) {
const grenze = s.lastIndexOf('\n', platz)
teil = grenze > 0 ? s.slice(0, grenze) : hartSchnitt(platz)
} else {
teil = hartSchnitt(platz)
}
teil = teil.trimEnd()
return teil ? `${teil}${trenner}${endzeile}` : endzeile
}
/** Link ins WOMS-Kundenpanel "Funktionen" mit geoeffneter Einbauanleitung des Projekts (Ticket, T16, Anleitungs-Route). */
export function anleitungsLink(customerId, projectId) {
return `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId ?? '')}?tab=funktionen&anleitung=${encodeURIComponent(projectId ?? '')}`
}
const projektName = (project) => project?.projectName || project?.subdomain || ''
/**
* Notfall-Details des Tickets: Kopfzeilen (Funktion, Projekt, Domain, Kunde, siteKey) und ein Verweis auf die Anleitung im
* WOMS. Ohne Rechenarbeit und ohne Dienste, damit es auch dann klappt, wenn die Anleitung des Registers nicht entsteht.
* ctx = { feature, doc, project, customer } (customer best effort oder null).
*/
export function minimaleTicketDetails(ctx) {
const { feature, doc, project, customer } = ctx || {}
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
return [
`Funktion: ${feature?.key || doc?.featureKey || ''}`,
`Projekt: ${projektName(project)}`,
`Domain: ${domain}`,
`Kunde: ${customer?.name || doc?.customerId || ''}`,
`siteKey: ${doc?.siteKey || ''}`,
`Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`,
].join('\n')
}
/**
* Texte des Einrichtungs-Tickets einer Funktion aus dem Register: { topic, details, fallbackDetails }.
* topic auf TICKET_TOPIC_MAX, details auf TICKET_DETAILS_MAX gekuerzt (Schnitt an der letzten Zeilengrenze, eine
* einheitliche Schlusszeile). Wirft feature.ticket.details oder liefert es Leeres/Nicht-Text, gilt fallbackDetails
* (minimaleTicketDetails): bezahlt ist bezahlt, das Ticket entsteht immer.
*/
export async function ticketTexte(ctx) {
const { feature, project } = ctx || {}
const kuerzeDetails = (text) => kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT })
const fallbackDetails = kuerzeDetails(minimaleTicketDetails(ctx))
let topic = ''
try {
topic = feature.ticket.topic(project)
} catch (err) {
console.error(`[funktionen] Ticket-Betreff (${feature?.key}) nicht berechenbar:`, err.message)
}
if (typeof topic !== 'string' || !topic.trim()) topic = `${feature?.label?.name || 'Funktion'} einbauen – ${projektName(project)}`.trim()
let details = ''
try {
details = await feature.ticket.details(ctx)
} catch (err) {
console.error(`[funktionen] Ticket-Details (${feature?.key}) nicht berechenbar, Notfall-Details:`, err.message)
}
if (typeof details !== 'string' || !details.trim()) details = fallbackDetails
return {
topic: kuerze(topic.trim(), TICKET_TOPIC_MAX, { endzeile: '…' }),
details: kuerzeDetails(details),
fallbackDetails,
}
}
+5 -1
View File
@@ -4,7 +4,7 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro
import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { featureConfig, featureKeys } from './featureRegistry.js'
import { featureConfig, featureKeys, ticketTexte } from './featureRegistry.js'
import { zustimmungFuerSitzung } from './featureConsents.js'
import { createFeatureTicket } from './ticketAdmin.js'
@@ -293,11 +293,15 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
invoiceUrl: invoiceUrl || '',
})
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
const customer = await customerOf(doc.customerId).catch(() => null)
const feature = featureConfig(doc.featureKey)
const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {}
const { ticketId } = await createFeatureTicket({
customerId: doc.customerId,
project,
featureKey: doc.featureKey,
siteKey: doc.siteKey,
...texte,
})
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
return { doc: updated, created: true }
+10
View File
@@ -0,0 +1,10 @@
// STUB (Block B) – ersetzt Task D4
import { minimaleTicketDetails } from '../featureRegistry.js'
/**
* Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details).
* ctx = { feature, doc, project, customer }. Block D4 ersetzt den Inhalt durch einbauanleitung(...).kurz; Signatur und ctx bleiben.
*/
export async function termineTicketDetails(ctx) {
return `${minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.`
}
+54 -19
View File
@@ -9,6 +9,7 @@ import {
Query,
ID,
} from './appwriteAdmin.js'
import { kuerze, LANGE_DETAILS, TICKET_DETAILS_MAX, TICKET_GEKUERZT, TICKET_TOPIC_MAX } from './featureRegistry.js'
const WOID_BASE = 9999
@@ -69,30 +70,33 @@ export async function ensureProjectTicket(project, { repoFullName } = {}) {
}
/**
* Einrichtungs-Ticket fuer eine gekaufte Funktion (z. B. News-Pinnwand) einer Kundenwebsite.
* 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.
*/
export async function createFeatureTicket({ customerId, project, featureKey, siteKey, topic = null, details = null }) {
export async function createFeatureTicket({
customerId,
project,
featureKey,
siteKey,
topic = null,
details = null,
fallbackDetails = null,
}) {
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 ticket = await createDocument(
config.collections.workorders,
{
topic: topic || `News-Pinnwand einbauen – ${projectName}`.trim(),
type: 'Webpage',
status: 'Open',
priority: 1,
serviceType: 'Remote',
systemType: 'n/a',
customerId,
customerName: customer?.name || '',
customerLocation: customer?.location || '',
woid,
details: details || [
const text = kuerze(
details ||
[
`Funktion: ${featureKey}`,
`Projekt: ${projectName}`,
`Domain: ${domain}`,
@@ -100,10 +104,41 @@ export async function createFeatureTicket({ customerId, project, featureKey, sit
`Einbau: ${embed}`,
'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)',
].join('\n'),
createdAt: new Date().toISOString(),
},
ID.unique()
TICKET_DETAILS_MAX,
{ anZeile: true, endzeile: TICKET_GEKUERZT }
)
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(),
}
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 || text.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: kuerze(fallbackDetails, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) },
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 }
}