From 35cb578a9664da444ff32b560ba3c908ad02c65d Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 30 Sep 2026 12:43:01 +0200 Subject: [PATCH] Update --- backend/src/index.ts | 974 +++++++++++++++++++++++- backend/src/migrations.ts | 2 + frontend/src/api.ts | 7 + frontend/src/types.ts | 2 + frontend/src/views/DashboardPage.tsx | 18 +- frontend/src/views/ProjectsPage.tsx | 40 +- frontend/src/views/TicketDetailPage.tsx | 52 +- frontend/src/views/ZammadSyncPage.tsx | 42 +- 8 files changed, 1091 insertions(+), 46 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index ac7dec2..f7c1a74 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -533,6 +533,8 @@ type ProjectRow = { zammad_priority_id?: string | null; zammad_state_id?: string | null; zammad_pending_time?: string | null; + zammad_merged_from_ticket_id?: string | null; + zammad_merged_from_ticket_number?: string | null; teamspace_project: string | null; description: string; goals: string; @@ -578,6 +580,8 @@ type QueryClient = { type ZammadTicketImport = { zammadTicketId: number; ticketNumber: string; + mergedFromTicketId: number | null; + mergedFromTicketNumber: string | null; zammadGroupId: number | null; zammadPriorityId: number | null; zammadStateId: number | null; @@ -792,6 +796,11 @@ function zammadPreferenceValue(ticket: Record, key: string) { return preferences[key] ?? ticket[key]; } +function isMergedZammadTicket(rawTicket: Record) { + const state = zammadPreferenceValue(rawTicket, "state"); + return typeof state === "string" && state.trim().toLowerCase() === "merged"; +} + async function getZammadCredentialsForUser(userId: string) { const [savedBaseUrl, userResult] = await Promise.all([ getAppSetting(zammadBaseUrlSettingKey), @@ -1499,6 +1508,8 @@ function zammadTicketImportFromRaw(rawTicket: Record, fallbackT return { zammadTicketId, ticketNumber, + mergedFromTicketId: null, + mergedFromTicketNumber: null, zammadGroupId: groupId, zammadPriorityId: numberOrNull(rawTicket.priority_id), zammadStateId: zammadStateIdOrNull(rawTicket.state_id), @@ -1514,6 +1525,153 @@ function zammadTicketImportFromRaw(rawTicket: Record, fallbackT }; } +function zammadTicketImportRejectionReason(rawTicket: Record, fallbackTicketNumber: string | null = null) { + const zammadTicketId = numberOrNull(rawTicket.id); + const zammadNumber = rawTicket.number; + const ticketNumber = + fallbackTicketNumber ?? + (typeof zammadNumber === "string" || typeof zammadNumber === "number" ? `Ticket#${String(zammadNumber).trim()}` : null); + const organizationId = numberOrNull(rawTicket.organization_id); + const groupId = numberOrNull(rawTicket.group_id); + const workType = zammadWorkType(groupId); + + if (!zammadTicketId) { + return `Zammad-Antwort enthält keine gültige Ticket-ID (id=${JSON.stringify(rawTicket.id)})`; + } + + if (!ticketNumber) { + return `Zammad-Antwort enthält keine gültige Ticketnummer (number=${JSON.stringify(zammadNumber)})`; + } + + if (!organizationId) { + return `Zammad-Antwort enthält keine gültige Organisation (organization_id=${JSON.stringify(rawTicket.organization_id)})`; + } + + if (!ticketNumberMatchesZammadNumber(ticketNumber, zammadNumber)) { + return `Ticketnummer passt nicht zur Zammad-Nummer (ticketNumber=${JSON.stringify(ticketNumber)}, number=${JSON.stringify(zammadNumber)})`; + } + + if (!workType) { + return `Zammad-Gruppe wird nicht synchronisiert (group_id=${JSON.stringify(rawTicket.group_id)}; unterstützt: ${[zammadConsultingGroupId, ...zammadSupportGroupIds].join(", ")})`; + } + + return "unbekannter Importfilter"; +} + +function isZammadMergedTicketSync(zammadTicket: ZammadTicketImport) { + return Boolean(zammadTicket.mergedFromTicketId); +} + +function isLocalMergedTicket(localTicket: LocalZammadSyncTicket) { + return Boolean(localTicket.zammad_merged_from_ticket_id); +} + +function zammadHistoryEntries(payload: unknown): Record[] { + const entries: Record[] = []; + const seen = new Set(); + + const visit = (value: unknown) => { + if (!value || typeof value !== "object" || seen.has(value)) { + return; + } + + seen.add(value); + + if (Array.isArray(value)) { + for (const item of value) { + visit(item); + } + return; + } + + if (!isPlainObject(value)) { + return; + } + + if (typeof value.type === "string" || typeof value.history_type === "string" || typeof value.historyType === "string") { + entries.push(value); + } + + for (const child of Object.values(value)) { + visit(child); + } + }; + + visit(payload); + return entries; +} + +function zammadMergedTargetTicketIdFromHistory(payload: unknown, sourceTicketId: number) { + const candidate = zammadMergedTargetCandidatesFromHistory(payload, sourceTicketId) + .find((entry) => entry.idFrom === sourceTicketId && entry.idTo); + + return candidate?.idTo ?? null; +} + +function zammadMergedTargetCandidatesFromHistory(payload: unknown, sourceTicketId: number) { + const directHistory = isPlainObject(payload) && Array.isArray(payload.history) + ? payload.history.filter(isPlainObject) + : Array.isArray(payload) ? payload.filter(isPlainObject) : null; + const entries = directHistory ?? zammadHistoryEntries(payload); + + return entries + .filter((entry) => (optionalString(entry.type) ?? optionalString(entry.history_type) ?? optionalString(entry.historyType)) === "merged_into") + .map((entry) => ({ + idFrom: numberOrNull(entry.id_from ?? entry.idFrom), + idTo: numberOrNull(entry.id_to ?? entry.idTo), + objectId: numberOrNull(entry.o_id ?? entry.oId), + matchesSource: numberOrNull(entry.id_from ?? entry.idFrom) === sourceTicketId + })); +} + +function zammadHistoryDebugSummary(payload: unknown, sourceTicketId: number) { + const directHistory = isPlainObject(payload) && Array.isArray(payload.history) + ? payload.history.filter(isPlainObject) + : Array.isArray(payload) ? payload.filter(isPlainObject) : null; + const entries = directHistory ?? zammadHistoryEntries(payload); + const mergedInto = zammadMergedTargetCandidatesFromHistory(payload, sourceTicketId).slice(0, 8); + + return `entries=${entries.length}, merged_into=${JSON.stringify(mergedInto)}`; +} + +async function fetchZammadTicketDetail(baseUrl: string, apiToken: string, ticketId: number, expand = true) { + const detail = await fetchZammadJson(baseUrl, apiToken, `/api/v1/tickets/${ticketId}${expand ? "?expand=true" : ""}`); + return isPlainObject(detail) ? detail : null; +} + +async function fetchZammadMergedTargetTicket(baseUrl: string, apiToken: string, rawTicket: Record, sourceTicketIdOverride: unknown = null) { + const sourceTicketId = numberOrNull(sourceTicketIdOverride) ?? numberOrNull(rawTicket.id); + + if (!sourceTicketId || !isMergedZammadTicket(rawTicket)) { + return null; + } + + const history = await fetchZammadJson(baseUrl, apiToken, `/api/v1/ticket_history/${sourceTicketId}`); + const targetTicketId = zammadMergedTargetTicketIdFromHistory(history, sourceTicketId); + + if (!targetTicketId || targetTicketId === sourceTicketId) { + throw new Error(`Zammad-Merge-Ziel-ID für Ticket-ID ${sourceTicketId} wurde in ticket_history nicht gefunden (${zammadHistoryDebugSummary(history, sourceTicketId)})`); + } + + const targetTicket = await fetchZammadTicketDetail(baseUrl, apiToken, targetTicketId, false); + const sourceOrganizationId = numberOrNull(rawTicket.organization_id); + const normalizedTargetTicket = targetTicket && !numberOrNull(targetTicket.organization_id) && sourceOrganizationId + ? { ...targetTicket, organization_id: sourceOrganizationId } + : targetTicket; + const zammadTicket = normalizedTargetTicket ? zammadTicketImportFromRaw(normalizedTargetTicket) : null; + + if (!zammadTicket) { + const reason = normalizedTargetTicket ? zammadTicketImportRejectionReason(normalizedTargetTicket) : "Zammad-Antwort war leer oder kein JSON-Objekt"; + throw new Error(`Zammad-Merge-Ziel ${targetTicketId} konnte geladen, aber nicht als unterstütztes Ticket importiert werden: ${reason}`); + } + + return { + ...zammadTicket, + mergedFromTicketId: sourceTicketId, + mergedFromTicketNumber: typeof rawTicket.number === "string" || typeof rawTicket.number === "number" ? `Ticket#${String(rawTicket.number).trim()}` : null + }; +} + async function fetchZammadTicketByNumber(userId: string, ticketNumber: string): Promise { const { baseUrl, apiToken } = await getZammadCredentialsForUser(userId); const zammadNumber = normalizedTicketNumberDigits(ticketNumber) ?? ticketNumber.replace(/^Ticket#/i, "").trim(); @@ -1553,20 +1711,34 @@ async function fetchZammadTicketByNumber(userId: string, ticketNumber: string): const zammadTicketId = numberOrNull(candidate?.id); if (zammadTicketId) { - const detail = await fetchZammadJson(baseUrl, apiToken, `/api/v1/tickets/${zammadTicketId}`); - const rawTicket = isPlainObject(detail) ? detail : candidate; + const rawTicket = await fetchZammadTicketDetail(baseUrl, apiToken, zammadTicketId) ?? candidate; if (!rawTicket) { return null; } + const mergedTargetTicket = await fetchZammadMergedTargetTicket(baseUrl, apiToken, rawTicket); + + if (mergedTargetTicket) { + return mergedTargetTicket; + } + + if (isMergedZammadTicket(rawTicket)) { + throw new Error(`Zammad-Merge-Ziel für ${ticketNumber} konnte nicht ermittelt werden`); + } + return zammadTicketImportFromRaw(rawTicket, ticketNumber); } for (const candidateId of zammadTicketCandidateIds(payload).slice(0, 25)) { - const detail = await fetchZammadJson(baseUrl, apiToken, `/api/v1/tickets/${candidateId}`); - const rawTicket = isPlainObject(detail) ? detail : null; - const zammadTicket = rawTicket ? zammadTicketImportFromRaw(rawTicket, ticketNumber) : null; + const rawTicket = await fetchZammadTicketDetail(baseUrl, apiToken, candidateId); + const mergedTargetTicket = rawTicket ? await fetchZammadMergedTargetTicket(baseUrl, apiToken, rawTicket) : null; + + if (rawTicket && isMergedZammadTicket(rawTicket) && !mergedTargetTicket) { + throw new Error(`Zammad-Merge-Ziel für ${ticketNumber} konnte nicht ermittelt werden`); + } + + const zammadTicket = mergedTargetTicket ?? (rawTicket ? zammadTicketImportFromRaw(rawTicket, ticketNumber) : null); if (zammadTicket) { return zammadTicket; @@ -1577,6 +1749,27 @@ async function fetchZammadTicketByNumber(userId: string, ticketNumber: string): return null; } +async function fetchZammadTicketByIdForUser(userId: string, zammadTicketId: number) { + const { baseUrl, apiToken } = await getZammadCredentialsForUser(userId); + const rawTicket = await fetchZammadTicketDetail(baseUrl, apiToken, zammadTicketId); + + if (!rawTicket) { + return null; + } + + const mergedTargetTicket = await fetchZammadMergedTargetTicket(baseUrl, apiToken, rawTicket, zammadTicketId); + + if (mergedTargetTicket) { + return mergedTargetTicket; + } + + if (isMergedZammadTicket(rawTicket)) { + throw new Error(`Zammad-Merge-Ziel für Ticket-ID ${zammadTicketId} konnte nicht ermittelt werden`); + } + + return zammadTicketImportFromRaw(rawTicket); +} + type SessionCreationInput = { userId: string; ticketNumber: string; @@ -2402,9 +2595,9 @@ async function fetchZammadConsultingTicketsForOwner(userId: string): Promise> 'pending_time' AS zammad_pending_time, + t.zammad_merged_from_ticket_id::text AS zammad_merged_from_ticket_id, + t.zammad_merged_from_ticket_number, p.teamspace_project, p.description, p.goals, @@ -3705,6 +3900,8 @@ type LocalZammadSyncTicket = { zammad_priority_id: number | null; zammad_state_id: number | null; zammad_owner_id: number | null; + zammad_merged_from_ticket_id: string | null; + zammad_merged_from_ticket_number: string | null; zammad_preferences: Record | null; }; @@ -3788,6 +3985,8 @@ function zammadSyncSnapshot(zammadTicket: ZammadTicketImport) { return { ticketId: zammadTicket.zammadTicketId, ticketNumber: zammadTicket.ticketNumber, + mergedFromTicketId: zammadTicket.mergedFromTicketId, + mergedFromTicketNumber: zammadTicket.mergedFromTicketNumber, groupId: zammadTicket.zammadGroupId, priorityId: zammadTicket.zammadPriorityId, stateId: zammadTicket.zammadStateId, @@ -3824,6 +4023,8 @@ function zammadTicketFromSyncSnapshot(snapshot: Record | null): return { zammadTicketId, ticketNumber, + mergedFromTicketId: numberOrNull(snapshot.mergedFromTicketId), + mergedFromTicketNumber: typeof snapshot.mergedFromTicketNumber === "string" && snapshot.mergedFromTicketNumber.trim() ? snapshot.mergedFromTicketNumber.trim() : null, zammadGroupId, zammadPriorityId, zammadStateId, @@ -3858,6 +4059,8 @@ function localSyncSnapshot( zammadPriorityId: localTicket.zammad_priority_id, zammadStateId: localTicket.zammad_state_id, zammadOwnerId: localTicket.zammad_owner_id, + zammadMergedFromTicketId: localTicket.zammad_merged_from_ticket_id, + zammadMergedFromTicketNumber: localTicket.zammad_merged_from_ticket_number, zammadPendingTodoExists: options.zammadPendingTodoExists ?? null, project: project ? { @@ -3877,6 +4080,38 @@ function localSyncSnapshot( }; } +async function findLocalTicketForZammadTarget(client: QueryClient, zammadTicket: ZammadTicketImport, excludeTicketId: string | null = null) { + const result = await client.query( + ` + SELECT + t.id::text AS id, + t.ticket_number, + t.title, + t.organization_id::text AS organization_id, + o.name AS organization_name, + t.customer_name, + t.work_type, + t.zammad_ticket_id, + t.zammad_group_id, + t.zammad_priority_id, + t.zammad_state_id, + t.zammad_owner_id, + t.zammad_merged_from_ticket_id::text AS zammad_merged_from_ticket_id, + t.zammad_merged_from_ticket_number, + t.zammad_preferences + FROM tickets t + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + WHERE (t.ticket_number = $1 OR t.zammad_ticket_id = $2) + AND ($3::bigint IS NULL OR t.id <> $3::bigint) + ORDER BY CASE WHEN t.ticket_number = $1 THEN 0 ELSE 1 END, t.id ASC + LIMIT 1; + `, + [zammadTicket.ticketNumber, zammadTicket.zammadTicketId, excludeTicketId] + ); + + return result.rows[0] ?? null; +} + async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: string, ownerUserId: string) { const result = await client.query( ` @@ -3944,6 +4179,22 @@ async function ticketHasAnyProject(client: QueryClient, ticketId: string) { return Boolean(result.rows[0]?.exists); } +async function ticketHasOpenZammadReviewProject(client: QueryClient, ticketId: string) { + const result = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM projects + WHERE ticket_id = $1 + AND close_review_required + ) AS exists; + `, + [ticketId] + ); + + return Boolean(result.rows[0]?.exists); +} + function zammadTeamspaceProjectList(zammadTicket: ZammadTicketImport) { return zammadTicket.teamspaceProjects.length ? zammadTicket.teamspaceProjects : zammadTicket.teamspaceProject ? [zammadTicket.teamspaceProject] : []; } @@ -4008,6 +4259,94 @@ function addTeamspaceProjectSyncChanges(changes: ZammadSyncChange[], localProjec } } +function projectTeamspaceProjectsForMergeReview(project: SyncProjectSnapshot) { + return project.teamspace_entries?.length + ? project.teamspace_entries.map((entry) => entry.teamspace_project) + : project.teamspace_project ? [project.teamspace_project] : []; +} + +function mergeReviewMatchesZammad(project: SyncProjectSnapshot, zammadTicket: ZammadTicketImport) { + return !syncValueChanged("Teamspace-Projekte", projectTeamspaceProjectsForMergeReview(project), zammadTeamspaceProjectList(zammadTicket)); +} + +async function getProjectSnapshotsForTicket(client: QueryClient, ticketId: string, reviewOnly = false) { + const result = await client.query( + ` + SELECT + p.id::text AS id, + p.user_id::text AS user_id, + p.teamspace_project, + p.hour_budget_minutes, + p.close_review_required, + p.closed_at, + pte.teamspace_project AS default_teamspace_project, + pte.hour_budget_minutes AS default_hour_budget_minutes, + COALESCE(pte.budget_sync_disabled, false) AS default_budget_sync_disabled, + COALESCE(entries.teamspace_entries, '[]'::json) AS teamspace_entries + FROM projects p + LEFT JOIN LATERAL ( + SELECT teamspace_project, hour_budget_minutes, budget_sync_disabled + FROM project_teamspace_entries + WHERE project_id = p.id + AND is_default + ORDER BY id ASC + LIMIT 1 + ) pte ON true + LEFT JOIN LATERAL ( + SELECT json_agg( + json_build_object( + 'id', ordered.id::text, + 'teamspace_project', ordered.teamspace_project, + 'is_default', ordered.is_default, + 'hour_budget_minutes', ordered.hour_budget_minutes, + 'budget_sync_disabled', ordered.budget_sync_disabled, + 'display_order', ordered.display_order + ) + ) AS teamspace_entries + FROM ( + SELECT id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, display_order + FROM project_teamspace_entries + WHERE project_id = p.id + ORDER BY is_default DESC, display_order ASC, id ASC + ) ordered + ) entries ON true + WHERE p.ticket_id = $1 + AND ($2::boolean = false OR p.close_review_required) + ORDER BY p.id ASC; + `, + [ticketId, reviewOnly] + ); + + return result.rows; +} + +async function clearMergeReviewIfZammadMatches(client: QueryClient, ticketId: string, zammadTicket: ZammadTicketImport) { + if (zammadTicket.zammadStateId === 4) { + return 0; + } + + const projects = await getProjectSnapshotsForTicket(client, ticketId, true); + const projectIdsToClear = projects + .filter((project) => mergeReviewMatchesZammad(project, zammadTicket)) + .map((project) => project.id); + + if (projectIdsToClear.length === 0) { + return 0; + } + + const result = await client.query( + ` + UPDATE projects + SET close_review_required = false, + updated_at = now() + WHERE id = ANY($1::bigint[]); + `, + [projectIdsToClear] + ); + + return result.rowCount ?? 0; +} + function zammadPendingTodoPayload(zammadTicket: ZammadTicketImport) { if ((zammadTicket.zammadStateId !== 3 && zammadTicket.zammadStateId !== 7) || !zammadTicket.pendingTime) { return null; @@ -4339,6 +4678,25 @@ async function planExistingZammadTicketSync( const changes: ZammadSyncChange[] = []; const pendingTodo = zammadPendingTodoPayload(zammadTicket); const pendingTodoExists = pendingTodo ? await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket) : null; + const isMerge = isZammadMergedTicketSync(zammadTicket); + const isMergeProtected = isMerge || isLocalMergedTicket(localTicket); + const isOpenReviewProtected = zammadTicket.zammadStateId !== 4 && await ticketHasOpenZammadReviewProject(client, localTicket.id); + const isProjectDataProtected = isMergeProtected || isOpenReviewProtected; + const mergedTargetTicket = zammadTicket.ticketNumber !== localTicket.ticket_number + ? await findLocalTicketForZammadTarget(client, zammadTicket, localTicket.id) + : null; + + if (zammadTicket.ticketNumber !== localTicket.ticket_number) { + addSyncChange(changes, "Ticketnummer", localTicket.ticket_number, zammadTicket.ticketNumber); + changes.push({ + field: "TicketTracker-Zusammenführung", + from: localTicket.ticket_number, + to: mergedTargetTicket ? `${zammadTicket.ticketNumber} (existiert bereits)` : `${zammadTicket.ticketNumber} (wird synchronisiert)` + }); + if (await ticketHasAnyProject(client, localTicket.id)) { + changes.push({ field: "TS/TT-Prüfung", from: false, to: true }); + } + } addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId); addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId); @@ -4352,7 +4710,11 @@ async function planExistingZammadTicketSync( ? { ticketNumber: localTicket.ticket_number, action: "update", - reason: "Zammad-Metadaten werden aktualisiert.", + 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." + : "Zammad-Metadaten werden aktualisiert.", local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes @@ -4382,11 +4744,11 @@ async function planExistingZammadTicketSync( addSyncChange(changes, "Kunde", localTicket.customer_name ?? localTicket.organization_name, organization.name); } - if (fields.type) { + if (fields.type && !isProjectDataProtected) { addSyncChange(changes, "Typ", localTicket.work_type, zammadTicket.workType); } - if (fields.type && zammadTicket.workType === "support") { + if (!isProjectDataProtected && fields.type && zammadTicket.workType === "support") { if (await ticketHasAnyProject(client, localTicket.id)) { changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" }); } @@ -4394,7 +4756,11 @@ async function planExistingZammadTicketSync( return { ticketNumber: localTicket.ticket_number, action: changes.length > 0 ? "update" : "skip", - reason: changes.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.", + 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.", local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes @@ -4402,18 +4768,56 @@ async function planExistingZammadTicketSync( } const shouldManageConsultingProject = - zammadTicket.workType === "consulting" && ((fields.type && zammadTicket.workType === "consulting") || localTicket.work_type === "consulting"); + !isProjectDataProtected && zammadTicket.workType === "consulting" && ((fields.type && zammadTicket.workType === "consulting") || localTicket.work_type === "consulting"); let project: SyncProjectSnapshot | null = null; + let projectOwnerUserId: string | null = null; + let mergeReviewProjects: SyncProjectSnapshot[] = []; + + if (shouldManageConsultingProject || (isProjectDataProtected && localTicket.work_type === "consulting")) { + projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); + project = await getProjectSnapshotForTicketOwner(client, localTicket.id, projectOwnerUserId); + } + + if (isProjectDataProtected && localTicket.work_type === "consulting") { + mergeReviewProjects = await getProjectSnapshotsForTicket(client, localTicket.id, true); + project = project ?? mergeReviewProjects[0] ?? null; + } if (shouldManageConsultingProject) { - const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); - project = await getProjectSnapshotForTicketOwner(client, localTicket.id, projectOwnerUserId); const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); - const localTeamspaceProjects = project?.teamspace_entries?.length - ? project.teamspace_entries.map((entry) => entry.teamspace_project) - : project?.teamspace_project ? [project.teamspace_project] : []; + const localTeamspaceProjects = project ? projectTeamspaceProjectsForMergeReview(project) : []; const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget); + if (project?.close_review_required && zammadTicket.zammadStateId !== 4) { + if (mergeReviewMatchesZammad(project, zammadTicket)) { + changes.push({ field: "Merge-Prüfung", from: true, to: false }); + } else { + if (localTeamspaceProjects.length > 0 && zammadTeamspaceProjects.length === 0) { + changes.push({ + field: "Merge-Projektdaten", + from: normalizedTeamspaceProjectList(localTeamspaceProjects), + to: "in Zammad fehlen; lokale Projektdaten bleiben erhalten und muessen dort nachgetragen werden" + }); + } else if (syncValueChanged("Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects)) { + changes.push({ + field: "Merge-Projektdaten", + from: normalizedTeamspaceProjectList(localTeamspaceProjects), + to: normalizedTeamspaceProjectList(zammadTeamspaceProjects) + }); + } + + } + + 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.", + local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }), + zammad: zammadSyncSnapshot(zammadTicket), + changes + }; + } + if (zammadTicket.zammadStateId === 4 && project?.closed_at) { return null; } @@ -4441,10 +4845,41 @@ async function planExistingZammadTicketSync( } } + const mergeReviewProject = mergeReviewProjects[0] ?? (project?.close_review_required ? project : null); + + if (isProjectDataProtected && mergeReviewProject) { + const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); + const localTeamspaceProjects = projectTeamspaceProjectsForMergeReview(mergeReviewProject); + const mergeReviewOpen = mergeReviewProject.close_review_required && zammadTicket.zammadStateId !== 4; + + if (mergeReviewOpen && mergeReviewMatchesZammad(mergeReviewProject, zammadTicket)) { + changes.push({ field: "Merge-Prüfung", from: true, to: false }); + } else if (mergeReviewOpen) { + if (localTeamspaceProjects.length > 0 && zammadTeamspaceProjects.length === 0) { + changes.push({ + field: "Merge-Projektdaten", + from: normalizedTeamspaceProjectList(localTeamspaceProjects), + to: "in Zammad fehlen; lokale Projektdaten bleiben erhalten und muessen dort nachgetragen werden" + }); + } else if (syncValueChanged("Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects)) { + changes.push({ + field: "Merge-Projektdaten", + from: normalizedTeamspaceProjectList(localTeamspaceProjects), + to: normalizedTeamspaceProjectList(zammadTeamspaceProjects) + }); + } + + } + } + return { ticketNumber: localTicket.ticket_number, action: changes.length > 0 ? "update" : "skip", - reason: changes.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.", + 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.", local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes @@ -5243,13 +5678,449 @@ async function applyZammadConsultingProjectSync( } } +async function mergeProjectRowsIntoTicket(client: QueryClient, sourceTicketId: string, targetTicketId: string) { + const sourceProjects = await client.query<{ + id: string; + user_id: string; + teamspace_project: string | null; + description: string; + goals: string; + starts_on: string | null; + ends_on: string | null; + hour_budget_minutes: number | null; + manual_consumption_minutes: number; + closed_at: string | null; + close_review_required: boolean; + }>( + ` + SELECT + id::text AS id, + user_id::text AS user_id, + teamspace_project, + description, + goals, + starts_on::text AS starts_on, + ends_on::text AS ends_on, + hour_budget_minutes, + manual_consumption_minutes, + closed_at::text AS closed_at, + close_review_required + FROM projects + WHERE ticket_id = $1 + ORDER BY user_id ASC, id ASC + FOR UPDATE; + `, + [sourceTicketId] + ); + + for (const sourceProject of sourceProjects.rows) { + const targetProjectResult = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM projects + WHERE ticket_id = $1 + AND user_id = $2 + FOR UPDATE; + `, + [targetTicketId, sourceProject.user_id] + ); + const targetProject = targetProjectResult.rows[0]; + + if (!targetProject) { + await client.query( + ` + UPDATE projects + SET ticket_id = $1, + updated_at = now() + WHERE id = $2; + `, + [targetTicketId, sourceProject.id] + ); + continue; + } + + await client.query("UPDATE project_teamspace_entries SET is_default = false, updated_at = now() WHERE project_id = $1;", [targetProject.id]); + + await client.query( + ` + UPDATE project_teamspace_entries pte + SET project_id = $2, + display_order = pte.display_order, + updated_at = now() + WHERE pte.project_id = $1; + `, + [sourceProject.id, targetProject.id] + ); + + await client.query( + ` + INSERT INTO hidden_user_projects (user_id, project_id, hidden_at) + SELECT user_id, $2::bigint, hidden_at + FROM hidden_user_projects + WHERE project_id = $1 + ON CONFLICT (user_id, project_id) + DO UPDATE SET hidden_at = LEAST(hidden_user_projects.hidden_at, EXCLUDED.hidden_at); + `, + [sourceProject.id, targetProject.id] + ); + + await client.query( + ` + UPDATE projects target + SET teamspace_project = COALESCE(NULLIF($2, ''), target.teamspace_project), + description = CASE + WHEN $3 = '' THEN target.description + WHEN target.description = '' OR target.description = $3 THEN $3 + ELSE $3 || E'\n\n' || target.description + END, + goals = CASE + WHEN $4 = '' THEN target.goals + WHEN target.goals = '' OR target.goals = $4 THEN $4 + ELSE $4 || E'\n\n' || target.goals + END, + starts_on = CASE + WHEN target.starts_on IS NULL THEN $5::date + WHEN $5::date IS NULL THEN target.starts_on + ELSE LEAST(target.starts_on, $5::date) + END, + ends_on = CASE + WHEN target.ends_on IS NULL THEN $6::date + WHEN $6::date IS NULL THEN target.ends_on + ELSE GREATEST(target.ends_on, $6::date) + END, + hour_budget_minutes = COALESCE($7, target.hour_budget_minutes), + manual_consumption_minutes = target.manual_consumption_minutes + $8, + closed_at = CASE + WHEN target.closed_at IS NULL OR $9::timestamptz IS NULL THEN NULL + ELSE GREATEST(target.closed_at, $9::timestamptz) + END, + close_review_required = target.close_review_required OR $10, + updated_at = now() + WHERE target.id = $1; + `, + [ + targetProject.id, + sourceProject.teamspace_project, + sourceProject.description, + sourceProject.goals, + sourceProject.starts_on, + sourceProject.ends_on, + sourceProject.hour_budget_minutes, + sourceProject.manual_consumption_minutes, + sourceProject.closed_at, + sourceProject.close_review_required + ] + ); + + await client.query("DELETE FROM projects WHERE id = $1;", [sourceProject.id]); + } + + await client.query( + ` + WITH projects_without_default AS ( + SELECT p.id, MIN(pte.id) AS default_entry_id + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE p.ticket_id = $1 + GROUP BY p.id + HAVING NOT BOOL_OR(pte.is_default) + ) + UPDATE project_teamspace_entries pte + SET is_default = true, + updated_at = now() + FROM projects_without_default pwd + WHERE pte.id = pwd.default_entry_id; + `, + [targetTicketId] + ); +} + +async function mergeTicketDayBillingsIntoTarget(client: QueryClient, sourceTicketId: string, targetTicketId: string) { + await client.query( + ` + WITH moved AS ( + SELECT user_id, project_teamspace_entry_id, day, SUM(billed_minutes)::int AS billed_minutes + FROM ticket_day_billings + WHERE ticket_id = $1 + AND project_teamspace_entry_id IS NULL + GROUP BY user_id, project_teamspace_entry_id, day + ), + deleted AS ( + DELETE FROM ticket_day_billings + WHERE ticket_id = $1 + AND project_teamspace_entry_id IS NULL + ) + INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) + SELECT $2::bigint, user_id, project_teamspace_entry_id, day, billed_minutes + FROM moved + ON CONFLICT (ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL + DO UPDATE SET billed_minutes = ticket_day_billings.billed_minutes + EXCLUDED.billed_minutes, + updated_at = now(); + `, + [sourceTicketId, targetTicketId] + ); + + await client.query( + ` + WITH moved AS ( + SELECT user_id, project_teamspace_entry_id, day, SUM(billed_minutes)::int AS billed_minutes + FROM ticket_day_billings + WHERE ticket_id = $1 + AND project_teamspace_entry_id IS NOT NULL + GROUP BY user_id, project_teamspace_entry_id, day + ), + deleted AS ( + DELETE FROM ticket_day_billings + WHERE ticket_id = $1 + AND project_teamspace_entry_id IS NOT NULL + ) + INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) + SELECT $2::bigint, user_id, project_teamspace_entry_id, day, billed_minutes + FROM moved + ON CONFLICT (ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL + DO UPDATE SET billed_minutes = ticket_day_billings.billed_minutes + EXCLUDED.billed_minutes, + updated_at = now(); + `, + [sourceTicketId, targetTicketId] + ); +} + +async function mergeLocalTicketIntoTarget( + client: QueryClient, + sourceTicketId: string, + sourceTicketNumber: string, + sourceWorkType: "support" | "consulting" | null, + targetTicket: LocalZammadSyncTicket, + zammadTicket: ZammadTicketImport +) { + if (sourceTicketId === targetTicket.id) { + return targetTicket; + } + + await client.query("SELECT id FROM tickets WHERE id = ANY($1::bigint[]) FOR UPDATE;", [[sourceTicketId, targetTicket.id]]); + + await client.query( + ` + DELETE FROM active_timers source_timer + USING active_timers target_timer + WHERE source_timer.ticket_number = $1 + AND target_timer.ticket_number = $2 + AND source_timer.user_id = target_timer.user_id; + `, + [sourceTicketNumber, zammadTicket.ticketNumber] + ); + await client.query("UPDATE active_timers SET ticket_number = $2, updated_at = now() WHERE ticket_number = $1;", [sourceTicketNumber, zammadTicket.ticketNumber]); + await client.query("UPDATE recurring_billings SET ticket_number = $2, updated_at = now() WHERE ticket_number = $1;", [sourceTicketNumber, zammadTicket.ticketNumber]); + await client.query( + ` + UPDATE tickets + SET work_type = COALESCE($2, work_type), + zammad_merged_from_ticket_id = $3, + zammad_merged_from_ticket_number = $4 + WHERE id = $1; + `, + [targetTicket.id, sourceWorkType, zammadTicket.mergedFromTicketId, zammadTicket.mergedFromTicketNumber] + ); + + await mergeProjectRowsIntoTicket(client, sourceTicketId, targetTicket.id); + await mergeTicketDayBillingsIntoTarget(client, sourceTicketId, targetTicket.id); + + for (const { table, column } of [ + { table: "ticket_month_closures", column: "month" }, + { table: "ticket_day_closures", column: "day" } + ]) { + await client.query( + ` + WITH moved AS ( + SELECT user_id, ${column}, MAX(closed_at) AS closed_at + FROM ${table} + WHERE ticket_id = $1 + GROUP BY user_id, ${column} + ), + deleted AS ( + DELETE FROM ${table} + WHERE ticket_id = $1 + ) + INSERT INTO ${table} (ticket_id, user_id, ${column}, closed_at) + SELECT $2::bigint, user_id, ${column}, closed_at + FROM moved + ON CONFLICT (ticket_id, ${column}, user_id) + DO UPDATE SET closed_at = GREATEST(${table}.closed_at, EXCLUDED.closed_at); + `, + [sourceTicketId, targetTicket.id] + ); + } + + await client.query( + ` + WITH moved AS ( + SELECT user_id, day, MAX(acknowledged_at) AS acknowledged_at + FROM ticket_day_billing_acknowledgements + WHERE ticket_id = $1 + GROUP BY user_id, day + ), + deleted AS ( + DELETE FROM ticket_day_billing_acknowledgements + WHERE ticket_id = $1 + ) + INSERT INTO ticket_day_billing_acknowledgements (ticket_id, user_id, day, acknowledged_at) + SELECT $2::bigint, user_id, day, acknowledged_at + FROM moved + ON CONFLICT (ticket_id, user_id, day) + DO UPDATE SET acknowledged_at = GREATEST(ticket_day_billing_acknowledgements.acknowledged_at, EXCLUDED.acknowledged_at); + `, + [sourceTicketId, targetTicket.id] + ); + + await client.query("UPDATE ticket_todos SET ticket_id = $2, updated_at = now() WHERE ticket_id = $1;", [sourceTicketId, targetTicket.id]); + await client.query("UPDATE sessions SET ticket_id = $2 WHERE ticket_id = $1;", [sourceTicketId, targetTicket.id]); + await client.query("UPDATE projects SET close_review_required = true, updated_at = now() WHERE ticket_id = $1;", [targetTicket.id]); + await client.query( + ` + UPDATE zammad_session_article_queue + SET ticket_id = $2, + ticket_number = $3, + zammad_ticket_id = $4, + updated_at = now() + WHERE ticket_id = $1; + `, + [sourceTicketId, targetTicket.id, zammadTicket.ticketNumber, zammadTicket.zammadTicketId] + ); + + await client.query("DELETE FROM tickets WHERE id = $1;", [sourceTicketId]); + + return targetTicket; +} + +async function localZammadSyncTicketById(client: QueryClient, ticketId: string) { + const result = await client.query( + ` + SELECT + t.id::text AS id, + t.ticket_number, + t.title, + t.organization_id::text AS organization_id, + o.name AS organization_name, + t.customer_name, + t.work_type, + t.zammad_ticket_id, + t.zammad_group_id, + t.zammad_priority_id, + t.zammad_state_id, + t.zammad_owner_id, + t.zammad_merged_from_ticket_id::text AS zammad_merged_from_ticket_id, + t.zammad_merged_from_ticket_number, + t.zammad_preferences + FROM tickets t + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + WHERE t.id = $1 + LIMIT 1; + `, + [ticketId] + ); + + return result.rows[0] ?? null; +} + +async function importLocalTargetForZammadMerge(client: QueryClient, zammadTicket: ZammadTicketImport) { + const organization = await getOrganizationByZammadId(client, zammadTicket.organizationId); + + if (!organization) { + return null; + } + + const result = await client.query<{ id: string }>( + ` + INSERT INTO tickets ( + ticket_number, + title, + organization_id, + customer_name, + work_type, + zammad_ticket_id, + zammad_group_id, + zammad_priority_id, + zammad_state_id, + zammad_owner_id, + zammad_merged_from_ticket_id, + zammad_merged_from_ticket_number, + zammad_preferences + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb) + ON CONFLICT (ticket_number) DO UPDATE SET + title = COALESCE(EXCLUDED.title, tickets.title), + organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), + customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), + zammad_ticket_id = EXCLUDED.zammad_ticket_id, + zammad_group_id = EXCLUDED.zammad_group_id, + zammad_priority_id = EXCLUDED.zammad_priority_id, + zammad_state_id = EXCLUDED.zammad_state_id, + zammad_owner_id = EXCLUDED.zammad_owner_id, + zammad_merged_from_ticket_id = EXCLUDED.zammad_merged_from_ticket_id, + zammad_merged_from_ticket_number = EXCLUDED.zammad_merged_from_ticket_number, + zammad_preferences = EXCLUDED.zammad_preferences + RETURNING id::text; + `, + [ + zammadTicket.ticketNumber, + zammadTicket.title, + organization.id, + organization.name, + zammadTicket.workType, + zammadTicket.zammadTicketId, + zammadTicket.zammadGroupId, + zammadTicket.zammadPriorityId, + zammadTicket.zammadStateId, + zammadTicket.zammadOwnerId, + zammadTicket.mergedFromTicketId, + zammadTicket.mergedFromTicketNumber, + JSON.stringify(zammadTicket.preferences) + ] + ); + + return localZammadSyncTicketById(client, result.rows[0].id); +} + +async function resolveLocalTicketForZammadSync(client: QueryClient, localTicket: LocalZammadSyncTicket, zammadTicket: ZammadTicketImport, userId: string) { + if (localTicket.ticket_number === zammadTicket.ticketNumber && localTicket.zammad_ticket_id === zammadTicket.zammadTicketId) { + return localTicket; + } + + const targetTicket = await findLocalTicketForZammadTarget(client, zammadTicket, localTicket.id); + + if (targetTicket) { + return mergeLocalTicketIntoTarget(client, localTicket.id, localTicket.ticket_number, localTicket.work_type, targetTicket, zammadTicket); + } + + if (localTicket.ticket_number !== zammadTicket.ticketNumber) { + const importedTargetTicket = await importLocalTargetForZammadMerge(client, zammadTicket); + + if (!importedTargetTicket) { + return "organization-missing" as const; + } + + return mergeLocalTicketIntoTarget(client, localTicket.id, localTicket.ticket_number, localTicket.work_type, importedTargetTicket, zammadTicket); + } + + return localTicket; +} + async function applyZammadTicketSync( client: QueryClient, - localTicket: { id: string; ticket_number: string; work_type: "support" | "consulting" | null }, + localTicket: LocalZammadSyncTicket, zammadTicket: ZammadTicketImport, userId: string, fields: ZammadSyncExistingFields ) { + const isMerge = isZammadMergedTicketSync(zammadTicket); + const resolvedLocalTicket = await resolveLocalTicketForZammadSync(client, localTicket, zammadTicket, userId); + + if (resolvedLocalTicket === "organization-missing") { + return "organization-missing" as const; + } + + localTicket = resolvedLocalTicket; + const isMergeProtected = isMerge || isLocalMergedTicket(localTicket); const metadataOnly = zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4; const shouldResolveOrganization = !metadataOnly && (fields.organization || !localTicket.work_type); const organization = shouldResolveOrganization ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; @@ -5261,7 +6132,8 @@ async function applyZammadTicketSync( await client.query( ` UPDATE tickets - SET title = CASE WHEN $2::boolean THEN $3 ELSE title END, + SET ticket_number = $15, + title = CASE WHEN $2::boolean THEN $3 ELSE title END, organization_id = CASE WHEN $4::boolean THEN $5 ELSE organization_id END, customer_name = CASE WHEN $4::boolean THEN $6 ELSE customer_name END, work_type = CASE WHEN $7::boolean THEN $8 ELSE work_type END, @@ -5280,14 +6152,15 @@ async function applyZammadTicketSync( !metadataOnly && fields.organization, organization?.id ?? null, organization?.name ?? null, - !metadataOnly && fields.type, + !metadataOnly && fields.type && !isMergeProtected, zammadTicket.workType, zammadTicket.zammadTicketId, zammadTicket.zammadGroupId, zammadTicket.zammadPriorityId, zammadTicket.zammadStateId, zammadTicket.zammadOwnerId, - JSON.stringify(zammadTicket.preferences) + JSON.stringify(zammadTicket.preferences), + zammadTicket.ticketNumber ] ); @@ -5307,6 +6180,12 @@ async function applyZammadTicketSync( await ensureZammadPendingTodo(client, localTicket.id, userId, zammadTicket); } + if (isMergeProtected || (zammadTicket.zammadStateId !== 4 && await ticketHasOpenZammadReviewProject(client, localTicket.id))) { + await clearMergeReviewIfZammadMatches(client, localTicket.id, zammadTicket); + const reviewStillOpen = await ticketHasOpenZammadReviewProject(client, localTicket.id); + return reviewStillOpen ? "close-review" as const : "updated" as const; + } + if (!metadataOnly && fields.type && zammadTicket.workType === "support") { await deleteAllProjectDataForTicket(client, localTicket.id); await client.query( @@ -6321,9 +7200,11 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, - t.zammad_priority_id::text AS zammad_priority_id, - CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, - p.teamspace_project, + t.zammad_priority_id::text AS zammad_priority_id, + CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, + t.zammad_merged_from_ticket_id::text AS zammad_merged_from_ticket_id, + t.zammad_merged_from_ticket_number, + p.teamspace_project, p.description, p.goals, p.starts_on::text AS starts_on, @@ -8602,6 +9483,8 @@ async function runExistingZammadSync( t.zammad_priority_id, t.zammad_state_id, t.zammad_owner_id, + t.zammad_merged_from_ticket_id::text AS zammad_merged_from_ticket_id, + t.zammad_merged_from_ticket_number, t.zammad_preferences FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id @@ -8625,6 +9508,8 @@ async function runExistingZammadSync( t.zammad_priority_id, t.zammad_state_id, t.zammad_owner_id, + t.zammad_merged_from_ticket_id::text AS zammad_merged_from_ticket_id, + t.zammad_merged_from_ticket_number, t.zammad_preferences FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id @@ -8637,7 +9522,9 @@ async function runExistingZammadSync( result.checked += 1; try { - const zammadTicket = await fetchZammadTicketByNumber(userId, localTicket.ticket_number); + const zammadTicket = localTicket.zammad_ticket_id + ? await fetchZammadTicketByIdForUser(userId, localTicket.zammad_ticket_id) ?? await fetchZammadTicketByNumber(userId, localTicket.ticket_number) + : await fetchZammadTicketByNumber(userId, localTicket.ticket_number); if (!zammadTicket) { result.missingInZammad += 1; @@ -8792,6 +9679,8 @@ async function runExistingZammadSyncFromPreview( t.zammad_priority_id, t.zammad_state_id, t.zammad_owner_id, + t.zammad_merged_from_ticket_id::text AS zammad_merged_from_ticket_id, + t.zammad_merged_from_ticket_number, t.zammad_preferences FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id @@ -8922,6 +9811,8 @@ async function localTicketForZammadMailSync(zammadTicket: ZammadTicketImport) { t.zammad_priority_id, t.zammad_state_id, t.zammad_owner_id, + t.zammad_merged_from_ticket_id::text AS zammad_merged_from_ticket_id, + t.zammad_merged_from_ticket_number, t.zammad_preferences FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id @@ -9408,6 +10299,8 @@ app.get("/api/projects", requireUser, async (req, res) => { t.zammad_priority_id::text AS zammad_priority_id, CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, t.zammad_preferences ->> 'pending_time' AS zammad_pending_time, + t.zammad_merged_from_ticket_id::text AS zammad_merged_from_ticket_id, + t.zammad_merged_from_ticket_number, p.teamspace_project, p.description, p.goals, @@ -9586,6 +10479,8 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { t.zammad_priority_id::text AS zammad_priority_id, CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, t.zammad_preferences ->> 'pending_time' AS zammad_pending_time, + t.zammad_merged_from_ticket_id::text AS zammad_merged_from_ticket_id, + t.zammad_merged_from_ticket_number, p.teamspace_project, p.description, p.goals, @@ -10216,6 +11111,31 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { } }); +app.patch("/api/projects/:projectId/review", requireUser, async (req, res) => { + const projectId = parsePositiveInteger(req.params.projectId, "projectId"); + const userId = currentUser(req).id; + const acknowledged = optionalBoolean(req.body.acknowledged, true); + + const result = await query<{ id: string; ticket_id: string; close_review_required: boolean }>( + ` + UPDATE projects + SET close_review_required = NOT $3::boolean, + updated_at = now() + WHERE id = $1 + AND user_id = $2 + RETURNING id::text, ticket_id::text, close_review_required; + `, + [projectId, userId, acknowledged] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: "Projekt nicht gefunden" }); + return; + } + + res.json({ project: result.rows[0] }); +}); + app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { const projectId = parsePositiveInteger(req.params.projectId, "projectId"); const userId = currentUser(req).id; diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index c6f3fa1..836e75f 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -191,6 +191,8 @@ export async function migrate() { await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;"); await query("UPDATE tickets SET zammad_state_id = 2 WHERE zammad_state_id = 1;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_id BIGINT;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_number TEXT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;"); await query(` ALTER TABLE tickets diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 359a715..7c76007 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -672,6 +672,13 @@ export function updateProjectStatus(projectId: string, closed: boolean) { }); } +export function acknowledgeProjectReview(projectId: string) { + return request<{ project: { id: string; ticket_id: string; close_review_required: boolean } }>(`/api/projects/${projectId}/review`, { + method: "PATCH", + body: JSON.stringify({ acknowledged: true }) + }); +} + export function deleteProject(projectId: string) { return request<{ deleted: { id: string; ticket_id: string } }>(`/api/projects/${projectId}`, { method: "DELETE" diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 42ac01f..245d894 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -180,6 +180,8 @@ export type ProjectSummary = { zammad_priority_id?: string | null; zammad_state_id?: string | null; zammad_pending_time?: string | null; + zammad_merged_from_ticket_id?: string | null; + zammad_merged_from_ticket_number?: string | null; teamspace_project: string | null; description: string; goals: string; diff --git a/frontend/src/views/DashboardPage.tsx b/frontend/src/views/DashboardPage.tsx index 46265fa..3347627 100644 --- a/frontend/src/views/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage.tsx @@ -1859,14 +1859,16 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP empty="Keine Budget-Hinweise." items={budgetAttention.slice(0, 4).map((project) => { const percent = projectConsumptionPercent(project); - const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0; - const detail = project.close_review_required - ? "Abschluss prüfen" - : hasUnassignedSessions - ? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung` - : `${percent ?? 0}% Kontingent verbraucht`; - const note = project.close_review_required - ? "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären." + const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0; + const detail = project.close_review_required + ? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Abschluss prüfen" + : hasUnassignedSessions + ? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung` + : `${percent ?? 0}% Kontingent verbraucht`; + const note = project.close_review_required + ? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" + ? "Lokale Projektdaten aus dem alten Ticket prüfen und in Zammad nachtragen." + : "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären." : hasUnassignedSessions ? "Sessions einem Teamspace-Projekt zuordnen." : "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen."; diff --git a/frontend/src/views/ProjectsPage.tsx b/frontend/src/views/ProjectsPage.tsx index eb325c2..ce37931 100644 --- a/frontend/src/views/ProjectsPage.tsx +++ b/frontend/src/views/ProjectsPage.tsx @@ -15,7 +15,7 @@ import { HelpLink } from "@/components/HelpLink"; import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown"; import { OrganizationSelect } from "@/components/OrganizationSelect"; import { cn } from "@/lib/utils"; -import { addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api"; +import { acknowledgeProjectReview, addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api"; import { currentMonth, formatMinutes } from "../format"; import { projectHasRecurringBilling } from "../recurring-billings"; import { ticketPattern } from "../timers"; @@ -1359,6 +1359,21 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { } } + async function acknowledgeProjectReviewCheck(project: ProjectSummary) { + setSavingProjectStatusId(project.id); + try { + await acknowledgeProjectReview(project.id); + toast.success("Prüfung bestätigt"); + await load(); + } catch (error) { + toast.error("Prüfung konnte nicht bestätigt werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setSavingProjectStatusId(null); + } + } + async function saveProjectFavorite(project: ProjectSummary) { try { await addFavoriteTicket(project.ticket_number); @@ -1769,9 +1784,26 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {

) : null} {project.close_review_required ? ( -

- Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen. -

+
+ + {project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" + ? project.zammad_merged_from_ticket_id + ? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.` + : "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen." + : "Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."} + + +
) : null} {showBudgetedTeamspaceEntries ? (
diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index 4ed4c5a..1c1f477 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -15,6 +15,7 @@ import { OrganizationSelect } from "@/components/OrganizationSelect"; import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor"; import { TodoPanel } from "@/components/TodoPanel"; import { + acknowledgeProjectReview, createSession, deleteProject, deleteSession, @@ -1406,6 +1407,32 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS } } + async function acknowledgeProjectReviewCheck() { + if (!project) { + return; + } + + if (project.can_edit === false) { + toast.error("Projekt ist geteilt", { + description: "Nur der Zammad-Owner kann die Prüfung bestätigen." + }); + return; + } + + setSavingProjectStatus(true); + try { + await acknowledgeProjectReview(project.id); + toast.success("Prüfung bestätigt"); + await load(); + } catch (error) { + toast.error("Prüfung konnte nicht bestätigt werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setSavingProjectStatus(false); + } + } + async function submitTicketSession(event: FormEvent) { event.preventDefault(); @@ -1879,9 +1906,28 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS ) : null} {project.close_review_required ? ( -
Zeiten prüfen
-
- Zammad meldet dieses Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen. +
+
+
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Zeiten prüfen"}
+
+ {project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" + ? project.zammad_merged_from_ticket_id + ? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte, Budget und Zuordnungen bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.` + : "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte, Budget und Zuordnungen bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen." + : "Zammad meldet dieses Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."} +
+
+
) : null} diff --git a/frontend/src/views/ZammadSyncPage.tsx b/frontend/src/views/ZammadSyncPage.tsx index 3e385a4..db8eaf5 100644 --- a/frontend/src/views/ZammadSyncPage.tsx +++ b/frontend/src/views/ZammadSyncPage.tsx @@ -433,7 +433,7 @@ function formatChangeValue(field: string, value: unknown) { return formatMinutes(value); } - if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung") { + if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung" || field === "Merge-Projektdaten") { return formatTeamspaceProjectList(value) || "-"; } @@ -493,6 +493,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f return fields.projectName && consultingProjectCanBeTouched; } + if (change.field === "Merge-Projektdaten" || change.field === "Merge-Prüfung") { + return true; + } + if (change.field === "Teamspace-Budget") { return fields.budget && consultingProjectCanBeTouched; } @@ -578,6 +582,10 @@ function addCachedTeamspaceProjectChanges(changes: ZammadSyncChange[], localProj } } +function mergeReviewMatchesCachedZammad(localTeamspaceProjects: unknown, zammadTeamspaceProjects: unknown) { + return !cachedValueChanged("Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects); +} + function zammadPendingTodoPreview(zammad: Record) { const stateId = numericValue(cachedValue(zammad, "stateId")); const pendingTime = cachedValue(zammad, "pendingTime"); @@ -666,6 +674,11 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn const localWorkType = cachedValue(local, "workType"); const zammadWorkType = cachedValue(zammad, "workType"); const zammadStateId = numericValue(cachedValue(zammad, "stateId")); + const mergeProtected = Boolean( + cachedValue(local, "zammadMergedFromTicketId") + || cachedValue(zammad, "mergedFromTicketId") + || (cachedValue(project, "closeReviewRequired") === true && zammadStateId !== 4) + ); const pendingTodoExists = cachedValue(local, "zammadPendingTodoExists"); const localTeamspaceProjects = cachedStringList(project, "teamspaceProjects"); const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects"); @@ -694,15 +707,15 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn addCachedChange(changes, "Organisation", cachedValue(local, "organizationId"), cachedValue(zammad, "organizationId")); } - if (fields.type) { + if (fields.type && !mergeProtected) { addCachedChange(changes, "Typ", localWorkType, zammadWorkType); } - if (fields.type && zammadWorkType === "support" && project) { + if (!mergeProtected && fields.type && zammadWorkType === "support" && project) { changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" }); } - const consultingProjectCanBeTouched = zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting"); + const consultingProjectCanBeTouched = !mergeProtected && zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting"); if (consultingProjectCanBeTouched) { if (fields.type && !project) { @@ -722,6 +735,27 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn } } + if (mergeProtected && project && cachedValue(project, "closeReviewRequired") === true && zammadStateId !== 4) { + if (mergeReviewMatchesCachedZammad(localTeamspaceProjectList, zammadTeamspaceProjectList)) { + changes.push({ field: "Merge-Prüfung", from: true, to: false }); + } else { + if (localTeamspaceProjectList.length > 0 && zammadTeamspaceProjectList.length === 0) { + changes.push({ + field: "Merge-Projektdaten", + from: normalizedTeamspaceProjectList(localTeamspaceProjectList), + to: "in Zammad fehlen; lokale Projektdaten bleiben erhalten und muessen dort nachgetragen werden" + }); + } else if (cachedValueChanged("Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList)) { + changes.push({ + field: "Merge-Projektdaten", + from: normalizedTeamspaceProjectList(localTeamspaceProjectList), + to: normalizedTeamspaceProjectList(zammadTeamspaceProjectList) + }); + } + + } + } + return changes; }