From fc0dfd05567b106b820aae2fe4796b2d0f2d93d0 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 6 Oct 2026 08:57:37 +0200 Subject: [PATCH] Update --- backend/src/index.ts | 94 +++++++++++++++++++++------ frontend/src/App.tsx | 2 +- frontend/src/views/ZammadSyncPage.tsx | 12 +++- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 0536ce0..4d8f721 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4230,6 +4230,16 @@ function addSyncChange(changes: ZammadSyncChange[], field: string, from: unknown } } +const informationalZammadSyncChangeFields = new Set([ + "Fremdes Ticket", + "Fremder Zammad-Owner", + "Hinweis" +]); + +function zammadSyncResultChanges(changes: ZammadSyncChange[]) { + return changes.some((change) => !informationalZammadSyncChangeFields.has(change.field)) ? changes : []; +} + function zammadSyncSnapshot(zammadTicket: ZammadTicketImport) { return { ticketId: zammadTicket.zammadTicketId, @@ -5161,7 +5171,9 @@ async function planExistingZammadTicketSync( } if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) { - return changes.length > 0 + const resultChanges = zammadSyncResultChanges(changes); + + return resultChanges.length > 0 ? { ticketNumber: localTicket.ticket_number, action: "update", @@ -5172,7 +5184,7 @@ async function planExistingZammadTicketSync( : "Zammad-Metadaten werden aktualisiert.", local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), - changes + changes: resultChanges } : null; } @@ -5208,17 +5220,19 @@ async function planExistingZammadTicketSync( changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" }); } + const resultChanges = zammadSyncResultChanges(changes); + return { ticketNumber: localTicket.ticket_number, - action: changes.length > 0 ? "update" : "skip", + action: resultChanges.length > 0 ? "update" : "skip", reason: zammadTicket.ticketNumber !== localTicket.ticket_number ? mergedTargetTicket ? "Zammad-Merge wird lokal nachvollzogen; vorhandenes Ziel-Ticket wird verwendet." : "Zammad-Merge wird lokal nachvollzogen; Ziel-Ticket wird synchronisiert und die Quelle umgehängt." - : changes.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.", + : resultChanges.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.", local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), - changes + changes: resultChanges }; } @@ -5263,13 +5277,15 @@ async function planExistingZammadTicketSync( } + const resultChanges = zammadSyncResultChanges(changes); + return { ticketNumber: localTicket.ticket_number, - action: changes.length > 0 ? "update" : "skip", - reason: changes.length > 0 ? "Ticket wird aktualisiert; Projektdaten bleiben wegen offener TS/TT-Prüfung lokal geschützt." : "Keine Änderung nach aktueller Auswahl.", + action: resultChanges.length > 0 ? "update" : "skip", + reason: resultChanges.length > 0 ? "Ticket wird aktualisiert; Projektdaten bleiben wegen offener TS/TT-Prüfung lokal geschützt." : "Keine Änderung nach aktueller Auswahl.", local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), - changes + changes: resultChanges }; } @@ -5277,7 +5293,7 @@ async function planExistingZammadTicketSync( return null; } - if (!project) { + if (!project && fields.type && localTicket.work_type !== "consulting") { changes.push({ field: "Projekt", from: null, to: "wird angelegt" }); } @@ -5327,17 +5343,19 @@ async function planExistingZammadTicketSync( } } + const resultChanges = zammadSyncResultChanges(changes); + return { ticketNumber: localTicket.ticket_number, - action: changes.length > 0 ? "update" : "skip", + action: resultChanges.length > 0 ? "update" : "skip", reason: zammadTicket.ticketNumber !== localTicket.ticket_number - ? mergedTargetTicket - ? "Zammad-Merge wird lokal nachvollzogen; vorhandenes Ziel-Ticket wird verwendet." - : "Zammad-Merge wird lokal nachvollzogen; Ziel-Ticket wird synchronisiert und die Quelle umgehängt." - : changes.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.", + ? mergedTargetTicket + ? "Zammad-Merge wird lokal nachvollzogen; vorhandenes Ziel-Ticket wird verwendet." + : "Zammad-Merge wird lokal nachvollzogen; Ziel-Ticket wird synchronisiert und die Quelle umgehängt." + : resultChanges.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.", local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), - changes + changes: resultChanges }; } @@ -6669,6 +6687,7 @@ async function applyZammadTicketSync( const organization = shouldResolveOrganization ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; const ownerMapping = await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId); const zammadOwnerUserId = ownerMapping.status === "ambiguous" ? localTicket.user_id : ownerMapping.userId; + const zammadStateChanged = numberOrNull(localTicket.zammad_state_id) !== zammadTicket.zammadStateId; if (shouldResolveOrganization && !organization) { return "organization-missing" as const; @@ -6754,6 +6773,12 @@ async function applyZammadTicketSync( ); } + if (zammadStateChanged) { + const completedAt = new Date(); + await restartProjectFollowupPeriodForTicket(client, localTicket.id, completedAt); + await completeAutomaticProjectFollowupsForTicketStatusChange(client, localTicket.id, completedAt); + } + if (fields.pendingTodo) { await ensureZammadPendingTodo(client, localTicket.id, userId, zammadTicket); } @@ -7778,6 +7803,35 @@ async function completeAutomaticProjectFollowupsForSession(client: QueryClient, ); } +async function completeAutomaticProjectFollowupsForTicketStatusChange(client: QueryClient, ticketId: string, completedAt: Date) { + await client.query( + ` + UPDATE ticket_todos tt + SET status = 'done', + completed_at = COALESCE(tt.completed_at, $2::timestamptz), + updated_at = now() + FROM project_followup_reminders pfr + WHERE pfr.todo_id = tt.id + AND pfr.ticket_id = $1 + AND tt.ticket_id = $1 + AND tt.status = 'open'; + `, + [ticketId, completedAt.toISOString()] + ); +} + +async function restartProjectFollowupPeriodForTicket(client: QueryClient, ticketId: string, restartedAt: Date) { + await client.query( + ` + UPDATE projects + SET updated_at = GREATEST(updated_at, $2::timestamptz) + WHERE ticket_id = $1 + AND closed_at IS NULL; + `, + [ticketId, restartedAt.toISOString()] + ); +} + async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); @@ -12442,12 +12496,12 @@ app.patch("/api/projects/:projectId/review", requireUser, async (req, res) => { const updateResult = await client.query<{ id: string; ticket_id: string; close_review_required: boolean }>( ` UPDATE projects - SET close_review_required = NOT $3::boolean, + SET close_review_required = NOT $2::boolean, updated_at = now() WHERE id = $1 RETURNING id::text, ticket_id::text, close_review_required; `, - [projectId, userId, acknowledged] + [projectId, acknowledged] ); return updateResult.rows[0] ?? null; @@ -12493,13 +12547,13 @@ app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { const updateResult = await client.query<{ id: string; ticket_id: string; closed_at: string | null }>( ` UPDATE projects - SET closed_at = CASE WHEN $3::boolean THEN now() ELSE NULL END, - close_review_required = CASE WHEN $3::boolean THEN false ELSE close_review_required END, + SET closed_at = CASE WHEN $2::boolean THEN now() ELSE NULL END, + close_review_required = CASE WHEN $2::boolean THEN false ELSE close_review_required END, updated_at = now() WHERE id = $1 RETURNING id::text, ticket_id::text, closed_at; `, - [projectId, userId, closed] + [projectId, closed] ); return updateResult.rows[0] ?? null; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 160a5f9..16d57e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1315,7 +1315,7 @@ export function App() { setOpenTickets((items) => items.filter((item) => item.key !== ticket.key)); - if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) { + if (route.page === "ticket" && route.ticketId === ticket.ticketId) { const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis"; routerNavigate(returnPath); } diff --git a/frontend/src/views/ZammadSyncPage.tsx b/frontend/src/views/ZammadSyncPage.tsx index a045dd7..7a8bba3 100644 --- a/frontend/src/views/ZammadSyncPage.tsx +++ b/frontend/src/views/ZammadSyncPage.tsx @@ -669,13 +669,21 @@ const preservedExistingPreviewChangeFields = new Set([ "TicketTracker-Zusammenführung" ]); +const informationalExistingPreviewChangeFields = new Set([ + "Fremdes Ticket", + "Fremder Zammad-Owner", + "Hinweis" +]); + function preserveExistingPreviewChanges(item: ZammadSyncItem, changes: ZammadSyncChange[], fields: ZammadSyncExistingFields) { const existingFields = new Set(changes.map((change) => change.field)); - return [ + const resultChanges = [ ...item.changes.filter((change) => preservedExistingPreviewChangeFields.has(change.field) && !existingFields.has(change.field) && changeAllowedByFields(item, change, fields)), ...changes ]; + + return resultChanges.some((change) => !informationalExistingPreviewChangeFields.has(change.field)) ? resultChanges : []; } function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyncExistingFields) { @@ -737,7 +745,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn const consultingProjectCanBeTouched = !mergeProtected && zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting"); if (consultingProjectCanBeTouched) { - if (fields.type && !project) { + if (fields.type && localWorkType !== "consulting" && !project) { changes.push({ field: "Projekt", from: null, to: "wird angelegt" }); }