This commit is contained in:
michael committed 2026-09-29 13:26:18 +02:00
1 parent c89d4825e2
commit 62794f72f1
8 files changed
+402 -78

No files matched your search

+55 -12
View File
@@ -343,12 +343,24 @@ function sanitizeZammadArticleHtml(value: string) {
return `${tags.open}${tag.replace(/^<span\b[^>]*>/i, "").replace(/<\/span>$/i, "")}${tags.close}`;
});
const allowedArticleTags = new Set(["br", "div", "p", "span", "b", "strong", "i", "em", "u", "s", "strike", "ul", "ol", "li", "blockquote", "pre", "code", "h1", "h2", "h3"]);
const sanitized = normalizedFormatting
.replace(/<\s*(\/?)\s*(br|div|p|span|b|strong|i|em|u|s|strike|ul|ol|li|blockquote|pre|code|h1|h2|h3)\b[^>]*>/gi, (_tag, closing: string, rawName: string) => {
.replace(/<[^>]*>/g, (tag) => {
const match = /^<\s*(\/?)\s*([a-z][a-z0-9]*)\b[^>]*\/?\s*>$/i.exec(tag);
if (!match) {
return "";
}
const [, closing, rawName] = match;
const name = rawName.toLowerCase();
if (!allowedArticleTags.has(name)) {
return "";
}
return name === "br" ? "<br>" : `<${closing ? "/" : ""}${name}>`;
})
.replace(/<[^>]*>/g, "")
.replace(new RegExp(`${imagePlaceholderPrefix}(\\d+)_`, "g"), (_placeholder, index: string) => images[Number(index)] ?? "");
if (!htmlToPlainText(sanitized) && images.length === 0) {
@@ -790,7 +802,13 @@ async function getZammadCredentialsForUser(userId: string) {
throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden");
}
const apiToken = decryptSecret(userResult.rows[0]?.zammad_api_token);
let apiToken: string | null = null;
try {
apiToken = decryptSecret(userResult.rows[0]?.zammad_api_token);
} catch {
throw badRequest("Der gespeicherte Zammad-Token kann nicht entschlüsselt werden. Bitte den persönlichen Zammad-Token im Profil neu speichern.");
}
if (!apiToken) {
throw badRequest("Zammad-Token muss zuerst im Profil hinterlegt werden");
@@ -874,6 +892,7 @@ async function zammadTimeUnitForUser(userId: string, durationSeconds: number) {
type ZammadSessionArticlePayload = {
ticket_id: number;
type: "note";
sender: "Agent";
internal: true;
subject: string;
body: string;
@@ -898,6 +917,7 @@ function zammadSessionArticlePayload(input: {
return {
ticket_id: ticketId,
type: "note",
sender: "Agent",
internal: true,
subject: "TicketTracker Session",
body: input.body,
@@ -912,7 +932,7 @@ function zammadSessionArticleBodyForDelivery(body: string, contentType: "text/pl
return body;
}
return `<div style="white-space: pre-wrap;">${body}</div>`;
return /<\s*[a-z][\s/>]/i.test(body) ? body : `<div>${body}</div>`;
}
async function postZammadSessionArticleForUser(userId: string, payload: ZammadSessionArticlePayload) {
@@ -1077,6 +1097,13 @@ async function postImmediateZammadSessionArticle(input: {
timeUnit: input.timeUnit,
articleTypeId: input.articleTypeId
});
logDebug("zammad-session-article-post", {
sessionId: input.sessionId,
contentType: input.articleContentType,
sourceBodyLength: input.articleBody.length,
deliveryBodyLength: payload.body.length,
deliveryBodyHasHtml: payload.body.includes("<")
});
const zammadResponse = await postZammadSessionArticleForUser(input.userId, payload);
const articleId = zammadArticleId(zammadResponse);
@@ -1278,6 +1305,10 @@ async function sendZammadSessionArticleForSession(userId: string, sessionId: num
throw badRequest("Ohne Tätigkeit kann keine Zammad-Notiz gesendet werden");
}
if (!session.activity_html) {
throw badRequest("Diese Session hat keinen HTML-Notizinhalt. Bitte Session bearbeiten und speichern, dann erneut an Zammad übertragen.");
}
if (session.source_article_id !== null) {
throw badRequest("Importierte Zammad-Mails können nicht erneut als Zammad-Notiz gesendet werden");
}
@@ -1292,8 +1323,8 @@ async function sendZammadSessionArticleForSession(userId: string, sessionId: num
}
const timeUnit = await zammadTimeUnitForUser(userId, Number(session.duration_seconds));
const articleBody = session.activity_html ?? session.activity;
const articleContentType = session.activity_html ? "text/html" as const : "text/plain" as const;
const articleBody = session.activity_html;
const articleContentType = "text/html" as const;
const articleId = await postImmediateZammadSessionArticle({
userId,
sessionId: session.session_id,
@@ -1610,6 +1641,15 @@ async function createSessionForUser(input: SessionCreationInput) {
throw Object.assign(new Error("Monat ist abgeschlossen"), { status: 409 });
}
if (createZammadArticle && !zammadArticleHtml) {
logDebug("zammad-session-article-missing-html", {
userId,
ticketNumber,
activityLength: activity.length
});
throw badRequest("HTML-Inhalt der Zammad-Notiz fehlt. Bitte Seite neu laden und erneut speichern.");
}
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
const existingLocalTicket = useZammadLookup ? await ticketExists(ticketNumber) : false;
const zammadTicket = providedZammadTicket !== undefined
@@ -1800,9 +1840,9 @@ async function createSessionForUser(input: SessionCreationInput) {
);
const session = sessionResult.rows[0];
const zammadArticleBody = zammadArticleHtml ?? activity;
const zammadArticleContentType = zammadArticleHtml ? "text/html" as const : "text/plain" as const;
const shouldCreateZammadArticle = createZammadArticle && (activity.trim().length > 0 || Boolean(zammadArticleHtml));
const zammadArticleBody = zammadArticleHtml ?? "";
const zammadArticleContentType = "text/html" as const;
const shouldCreateZammadArticle = createZammadArticle && Boolean(zammadArticleHtml);
const zammadArticle: {
requested: boolean;
status: "not_requested" | "sent" | "failed";
@@ -5411,12 +5451,15 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
s.activity,
s.activity_html AS zammad_article_body,
s.work_type,
s.project_teamspace_entry_id::text AS project_teamspace_entry_id,
COALESCE(NULLIF(pte.teamspace_description, ''), pte.teamspace_project) AS project_teamspace_project,
s.started_at,
s.rounded_minutes,
s.recurring_billing_id::text AS recurring_billing_id
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN organizations so ON so.zammad_id = s.organization_id
LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.user_id = $3
@@ -5504,7 +5547,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio
COALESCE(so.name, ot.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name,
s.activity,
s.work_type,
COALESCE(pte.teamspace_project, p.teamspace_project) AS project_teamspace_project,
COALESCE(NULLIF(pte.teamspace_description, ''), pte.teamspace_project, p.teamspace_project) AS project_teamspace_project,
s.started_at,
(s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day,
s.rounded_minutes,
@@ -6046,7 +6089,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
) AS zammad_article_type_id,
project_teamspace_entry_id::text AS project_teamspace_entry_id,
(
SELECT teamspace_project
SELECT COALESCE(NULLIF(teamspace_description, ''), teamspace_project)
FROM project_teamspace_entries
WHERE id = sessions.project_teamspace_entry_id
) AS project_teamspace_project
@@ -6208,7 +6251,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
za.zammad_article_id::text AS zammad_article_id,
za.zammad_article_type_id,
s.project_teamspace_entry_id::text AS project_teamspace_entry_id,
pte.teamspace_project AS project_teamspace_project
COALESCE(NULLIF(pte.teamspace_description, ''), pte.teamspace_project) AS project_teamspace_project
FROM sessions s
LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id
LEFT JOIN LATERAL (