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"
+ ? 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."}
+