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:
1 parent
7fcb268644
commit
9dd0244537
5 files changed
+678
-20
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user