diff --git a/.env_dev b/.env_dev index 18d13c0..d757eb7 100644 --- a/.env_dev +++ b/.env_dev @@ -1,5 +1,5 @@ PP_PASSWORD='$ouzs4D$@RAi9' -DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.3:5432/tickettracker_dev +DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.4:5432/tickettracker_dev APP_PORT=3911 TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest COOKIE_SECURE=false diff --git a/backend/src/index.ts b/backend/src/index.ts index 624ccc3..0536ce0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1938,9 +1938,9 @@ async function createSessionForUser(input: SessionCreationInput) { await importZammadTicketForUser(client, zammadTicket, userId); } - const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null; project_id: string | null }>( + const existingTicketResult = await client.query<{ id: string; user_id: string | null; organization_id: string | null; customer_name: string | null; work_type: string | null; project_id: string | null }>( ` - SELECT t.id, t.organization_id, t.customer_name, t.work_type, p.id AS project_id + SELECT t.id, t.user_id::text AS user_id, t.organization_id, t.customer_name, t.work_type, p.id AS project_id FROM tickets t LEFT JOIN projects p ON p.ticket_id = t.id WHERE t.ticket_number = $1 @@ -1963,24 +1963,30 @@ async function createSessionForUser(input: SessionCreationInput) { throw badRequest("organizationId must reference an existing organization"); } + const ownerMapping = zammadTicket ? await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId) : null; + const ownerUserId = zammadTicket + ? ownerMapping?.status === "ambiguous" ? userId : ownerMapping?.userId ?? null + : existingTicket?.user_id ?? userId; const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null; zammad_ticket_id: string | null }>( ` - INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type) - VALUES ($1, $2, $3, $4) + INSERT INTO tickets (user_id, ticket_number, organization_id, customer_name, work_type) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (ticket_number) DO UPDATE SET - organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), - customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), + user_id = COALESCE(tickets.user_id, EXCLUDED.user_id), + organization_id = CASE WHEN tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(tickets.organization_id, EXCLUDED.organization_id) ELSE tickets.organization_id END, + customer_name = CASE WHEN tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(tickets.customer_name, EXCLUDED.customer_name) ELSE tickets.customer_name END, work_type = CASE + WHEN tickets.user_id IS NOT NULL AND tickets.user_id <> EXCLUDED.user_id THEN tickets.work_type WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' WHEN EXISTS ( - SELECT 1 FROM projects p WHERE p.ticket_id = tickets.id AND p.user_id = $5 + SELECT 1 FROM projects p WHERE p.ticket_id = tickets.id AND p.user_id = $6 ) THEN 'consulting' ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) END RETURNING id, ticket_number, organization_id, customer_name, work_type, zammad_ticket_id::text AS zammad_ticket_id; `, - [effectiveTicketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId] + [ownerUserId, effectiveTicketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId] ); const ticket = ticketResult.rows[0]; @@ -1993,16 +1999,13 @@ async function createSessionForUser(input: SessionCreationInput) { let projectTeamspaceEntryId: number | null = null; if (workType === "consulting") { - const projectOwnerUserId = await projectOwnerUserIdForZammadOwner( - client, - zammadTicket?.zammadOwnerId ?? null, - userId - ); + const projectOwnerUserId = await ticketOwnerUserId(client, ticket.id, ownerUserId) ?? ownerUserId; await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, { teamspaceProject: zammadTicket?.teamspaceProject ?? null, teamspaceProjects: zammadTicket?.teamspaceProjects ?? [], hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null, - closeReviewRequired: zammadTicket?.zammadStateId === 4 + closeReviewRequired: zammadTicket?.zammadStateId === 4, + zammadOwnerId: zammadTicket?.zammadOwnerId ?? null }); projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( client, @@ -2484,12 +2487,17 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId: `, [zammadUserId] ); - const user = userResult.rows[0] ?? null; - if (!user) { + if (userResult.rows.length === 0) { return { created: false, skipped: true, reason: "unknown-user" }; } + if (userResult.rows.length > 1) { + return { created: false, skipped: true, reason: "ambiguous-user" }; + } + + const user = userResult.rows[0]; + if (!user.enabled) { return { created: false, skipped: true, reason: "disabled" }; } @@ -2632,9 +2640,14 @@ async function fetchZammadConsultingTicketsForOwner(userId: string): Promise left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true })); } -async function projectOwnerUserIdForZammadOwner(client: QueryClient, zammadOwnerId: number | null, fallbackUserId: string) { +type ZammadOwnerUserMapping = { + userId: string | null; + status: "none" | "missing" | "ambiguous" | "mapped"; +}; + +async function zammadOwnerUserMapping(client: QueryClient, zammadOwnerId: number | null): Promise { if (!zammadOwnerId) { - return fallbackUserId; + return { userId: null, status: "none" }; } const result = await client.query<{ id: string }>( @@ -2642,14 +2655,162 @@ async function projectOwnerUserIdForZammadOwner(client: QueryClient, zammadOwner SELECT id::text AS id FROM users WHERE zammad_user_id = $1 + AND role = 'user' AND active ORDER BY id ASC - LIMIT 1; `, [zammadOwnerId] ); - return result.rows[0]?.id ?? fallbackUserId; + if (result.rows.length === 0) { + return { userId: null, status: "missing" }; + } + + if (result.rows.length > 1) { + return { userId: null, status: "ambiguous" }; + } + + return { userId: result.rows[0].id, status: "mapped" }; +} + +async function projectOwnerUserIdForZammadOwner(client: QueryClient, zammadOwnerId: number | null, fallbackUserId: string) { + const mapping = await zammadOwnerUserMapping(client, zammadOwnerId); + return mapping.userId ?? fallbackUserId; +} + +async function ticketOwnerUserId(client: QueryClient, ticketId: string | number, fallbackUserId: string | null = null) { + const result = await client.query<{ + user_id: string | null; + zammad_owner_id: string | null; + project_user_id: string | null; + session_user_id: string | null; + }>( + ` + SELECT + t.user_id::text AS user_id, + t.zammad_owner_id::text AS zammad_owner_id, + ( + SELECT p.user_id::text + FROM projects p + WHERE p.ticket_id = t.id + ORDER BY p.id ASC + LIMIT 1 + ) AS project_user_id, + ( + SELECT s.user_id::text + FROM sessions s + WHERE s.ticket_id = t.id + ORDER BY s.id ASC + LIMIT 1 + ) AS session_user_id + FROM tickets t + WHERE t.id = $1 + LIMIT 1; + `, + [ticketId] + ); + const row = result.rows[0]; + + if (!row) { + return fallbackUserId; + } + + const zammadOwnerId = numberOrNull(row.zammad_owner_id); + + if (zammadOwnerId) { + const mapping = await zammadOwnerUserMapping(client, zammadOwnerId); + return mapping.userId; + } + + return row.user_id || row.project_user_id || row.session_user_id || fallbackUserId; +} + +async function userCanViewTicket(client: QueryClient, ticketId: string | number, userId: string) { + const result = await client.query<{ allowed: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM tickets t + WHERE t.id = $1 + AND ( + t.user_id = $2 + OR EXISTS ( + SELECT 1 + FROM users viewer + WHERE viewer.id = $2 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) + OR EXISTS (SELECT 1 FROM projects p WHERE p.ticket_id = t.id AND p.user_id = $2) + OR EXISTS (SELECT 1 FROM sessions s WHERE s.ticket_id = t.id AND s.user_id = $2) + OR EXISTS (SELECT 1 FROM ticket_todos tt WHERE tt.ticket_id = t.id AND tt.user_id = $2) + ) + ) AS allowed; + `, + [ticketId, userId] + ); + + return Boolean(result.rows[0]?.allowed); +} + +async function userCanManageTicket(client: QueryClient, ticketId: string | number, userId: string) { + return (await ticketOwnerUserId(client, ticketId)) === userId; +} + +async function userHasSessionsForTicket(client: QueryClient, ticketId: string | number, userId: string) { + const result = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM sessions + WHERE ticket_id = $1 + AND user_id = $2 + ) AS exists; + `, + [ticketId, userId] + ); + + return Boolean(result.rows[0]?.exists); +} + +async function zammadUserIdForUser(client: QueryClient, userId: string) { + const result = await client.query<{ zammad_user_id: string | null }>( + ` + SELECT zammad_user_id::text AS zammad_user_id + FROM users + WHERE id = $1 + AND role = 'user' + AND active + LIMIT 1; + `, + [userId] + ); + + return numberOrNull(result.rows[0]?.zammad_user_id ?? null); +} + +async function assertUniqueZammadUserIdAvailable(client: QueryClient, zammadUserId: number | null, currentUserId: string) { + if (!zammadUserId) { + return; + } + + const result = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM users + WHERE role = 'user' + AND active + AND zammad_user_id = $1 + AND id <> $2 + LIMIT 1; + `, + [zammadUserId, currentUserId] + ); + + if (result.rowCount && result.rowCount > 0) { + throw Object.assign(new Error("Diese Zammad User-ID ist bereits einem anderen aktiven Benutzer zugeordnet."), { status: 409 }); + } } async function getOrganizationByZammadId( @@ -2675,9 +2836,12 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm throw badRequest("Zammad Organisation ist lokal nicht synchronisiert"); } + const ownerMapping = await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId); + const ownerUserId = ownerMapping.status === "ambiguous" ? fallbackUserId : ownerMapping.userId; const result = await client.query<{ id: string }>( ` INSERT INTO tickets ( + user_id, ticket_number, title, organization_id, @@ -2692,9 +2856,14 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm zammad_merged_from_ticket_number, zammad_preferences ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb) ON CONFLICT (ticket_number) DO UPDATE SET - zammad_ticket_id = COALESCE(tickets.zammad_ticket_id, EXCLUDED.zammad_ticket_id), + user_id = EXCLUDED.user_id, + title = CASE WHEN EXCLUDED.user_id IS NULL OR tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(EXCLUDED.title, tickets.title) ELSE tickets.title END, + organization_id = CASE WHEN EXCLUDED.user_id IS NULL OR tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN EXCLUDED.organization_id ELSE tickets.organization_id END, + customer_name = CASE WHEN EXCLUDED.user_id IS NULL OR tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN EXCLUDED.customer_name ELSE tickets.customer_name END, + work_type = CASE WHEN EXCLUDED.user_id IS NULL OR tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN EXCLUDED.work_type ELSE tickets.work_type END, + 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, @@ -2705,6 +2874,7 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm RETURNING id::text; `, [ + ownerUserId, zammadTicket.ticketNumber, zammadTicket.title, organization.id, @@ -2722,13 +2892,14 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm ); if (zammadTicket.workType === "consulting") { - const ownerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, fallbackUserId); + const effectiveOwnerUserId = await ticketOwnerUserId(client, result.rows[0].id, ownerUserId); - await ensureProjectForTicket(client, result.rows[0].id, ownerUserId, { + await ensureProjectForTicket(client, result.rows[0].id, effectiveOwnerUserId, { teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, - closeReviewRequired: zammadTicket.zammadStateId === 4 + closeReviewRequired: zammadTicket.zammadStateId === 4, + zammadOwnerId: zammadTicket.zammadOwnerId }); } @@ -3322,19 +3493,21 @@ async function ensureRecurringSessionsForUserPeriod(userId: string, startIso: st const endedAt = new Date(startedAt.getTime() + slot.duration_minutes * 60_000); const ticketResult = await client.query<{ id: string }>( ` - INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type) - VALUES ($1, $2, $3, $4) + INSERT INTO tickets (user_id, ticket_number, organization_id, customer_name, work_type) + VALUES ($5, $1, $2, $3, $4) ON CONFLICT (ticket_number) DO UPDATE SET - organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), - customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), + user_id = COALESCE(tickets.user_id, EXCLUDED.user_id), + organization_id = CASE WHEN tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(tickets.organization_id, EXCLUDED.organization_id) ELSE tickets.organization_id END, + customer_name = CASE WHEN tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(tickets.customer_name, EXCLUDED.customer_name) ELSE tickets.customer_name END, work_type = CASE + WHEN tickets.user_id IS NOT NULL AND tickets.user_id <> EXCLUDED.user_id THEN tickets.work_type WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) END RETURNING id; `, - [rule.ticket_number, rule.organization_id, rule.organization_name, rule.work_type] + [rule.ticket_number, rule.organization_id, rule.organization_name, rule.work_type, userId] ); if (rule.work_type === "consulting") { @@ -3444,8 +3617,8 @@ async function cleanupTicketDayBillingIfEmpty( async function ensureProjectForTicket( client: QueryClient, ticketId: string, - userId: string, - defaults: { teamspaceProject?: string | null; teamspaceProjects?: string[]; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean } = {} + userId: string | null, + defaults: { teamspaceProject?: string | null; teamspaceProjects?: string[]; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean; zammadOwnerId?: number | null } = {} ) { const teamspaceProjects = defaults.teamspaceProjects?.length ? defaults.teamspaceProjects @@ -3453,17 +3626,32 @@ async function ensureProjectForTicket( ? [defaults.teamspaceProject] : []; const teamspaceProjectDefault = teamspaceProjects[0] ?? (Object.prototype.hasOwnProperty.call(defaults, "teamspaceProject") ? defaults.teamspaceProject ?? "" : null); - const result = await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number }>( + const existingResult = await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number }>( ` - INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals, close_review_required) - SELECT $2, t.id, COALESCE($3, t.title, ''), '', '', $4::boolean - FROM tickets t - WHERE t.id = $1 - ON CONFLICT (user_id, ticket_id) DO NOTHING - RETURNING id::text, teamspace_project, hour_budget_minutes, manual_consumption_minutes; + SELECT id::text, teamspace_project, hour_budget_minutes, manual_consumption_minutes + FROM projects + WHERE ticket_id = $1 + AND ( + ($2::bigint IS NOT NULL AND user_id = $2) + OR ($2::bigint IS NULL AND user_id IS NULL AND zammad_owner_id IS NOT DISTINCT FROM $3::bigint) + ) + LIMIT 1; `, - [ticketId, userId, teamspaceProjectDefault, Boolean(defaults.closeReviewRequired)] + [ticketId, userId, defaults.zammadOwnerId ?? null] ); + const result = existingResult.rows[0] + ? existingResult + : await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number }>( + ` + INSERT INTO projects (user_id, zammad_owner_id, ticket_id, teamspace_project, description, goals, close_review_required) + SELECT $2::bigint, $5::bigint, t.id, COALESCE($3::text, t.title, ''), '', '', $4::boolean + FROM tickets t + WHERE t.id = $1 + ON CONFLICT DO NOTHING + RETURNING id::text, teamspace_project, hour_budget_minutes, manual_consumption_minutes; + `, + [ticketId, userId, teamspaceProjectDefault, Boolean(defaults.closeReviewRequired), defaults.zammadOwnerId ?? null] + ); if ("rows" in result && result.rows[0]) { if (defaults.hourBudgetMinutes !== undefined && defaults.hourBudgetMinutes !== null) { @@ -3501,10 +3689,13 @@ async function ensureProjectForTicket( SELECT id::text AS id FROM projects WHERE ticket_id = $1 - AND user_id = $2 + AND ( + ($2::bigint IS NOT NULL AND user_id = $2) + OR ($2::bigint IS NULL AND user_id IS NULL AND zammad_owner_id IS NOT DISTINCT FROM $3::bigint) + ) LIMIT 1; `, - [ticketId, userId] + [ticketId, userId, defaults.zammadOwnerId ?? null] ); const projectId = projectResult.rows[0]?.id; @@ -3519,14 +3710,17 @@ async function ensureProjectForTicket( SET close_review_required = true, updated_at = now() WHERE ticket_id = $1 - AND user_id = $2; + AND ( + ($2::bigint IS NOT NULL AND user_id = $2) + OR ($2::bigint IS NULL AND user_id IS NULL AND zammad_owner_id IS NOT DISTINCT FROM $3::bigint) + ); `, - [ticketId, userId] + [ticketId, userId, defaults.zammadOwnerId ?? null] ); } } -async function getProjectRowForTicket(client: QueryClient, ticketId: string, projectOwnerUserId: string, viewerUserId: string) { +async function getProjectRowForTicket(client: QueryClient, ticketId: string, projectOwnerUserId: string | null, viewerUserId: string, zammadOwnerId: number | null = null) { const result = await client.query( ` SELECT @@ -3534,7 +3728,16 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro p.user_id::text AS user_id, owner.username AS owner_username, owner.display_name AS owner_display_name, - (p.user_id = $3::bigint) AS can_edit, + COALESCE(( + t.user_id = $3::bigint + OR EXISTS ( + SELECT 1 FROM users viewer + WHERE viewer.id = $3 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) + ), false) AS can_edit, p.ticket_id::text AS ticket_id, t.ticket_number, t.title, @@ -3576,7 +3779,7 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro p.created_at, p.updated_at FROM projects p - JOIN users owner ON owner.id = p.user_id + LEFT JOIN users owner ON owner.id = p.user_id JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( @@ -3654,9 +3857,12 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro AND tt.user_id = $3 ) todo_totals ON true WHERE p.ticket_id = $1 - AND p.user_id = $2; + AND ( + ($2::bigint IS NOT NULL AND p.user_id = $2) + OR ($2::bigint IS NULL AND p.user_id IS NULL AND p.zammad_owner_id IS NOT DISTINCT FROM $4::bigint) + ); `, - [ticketId, projectOwnerUserId, viewerUserId] + [ticketId, projectOwnerUserId, viewerUserId, zammadOwnerId] ); return result.rows[0] ?? null; @@ -3674,16 +3880,21 @@ async function resolveProjectTeamspaceEntryForSession( return null; } - const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadOwnerId, userId); + const ticketOwner = await ticketOwnerUserId(client, ticketId, null); + const projectOwnerUserId = ticketOwner ?? (zammadOwnerId ? null : await projectOwnerUserIdForZammadOwner(client, zammadOwnerId, userId)); const projectResult = await client.query<{ id: string }>( ` SELECT p.id::text AS id FROM projects p WHERE p.ticket_id = $1 - ORDER BY (p.user_id = $2::bigint) DESC, (p.user_id = $3::bigint) DESC, p.id ASC + ORDER BY + (p.user_id = $2::bigint) DESC, + (p.user_id IS NULL AND p.zammad_owner_id IS NOT DISTINCT FROM $4::bigint) DESC, + (p.user_id = $3::bigint) DESC, + p.id ASC LIMIT 1; `, - [ticketId, projectOwnerUserId, userId] + [ticketId, projectOwnerUserId, userId, zammadOwnerId] ); const projectId = projectResult.rows[0]?.id; @@ -3925,6 +4136,8 @@ function emptyZammadSyncResult(mode: "preview" | "applied" = "applied"): ZammadS type LocalZammadSyncTicket = { id: string; + user_id: string | null; + own_session_count?: number; ticket_number: string; title: string | null; organization_id: string | null; @@ -3943,7 +4156,7 @@ type LocalZammadSyncTicket = { type SyncProjectSnapshot = { id: string; - user_id: string; + user_id: string | null; teamspace_project: string | null; hour_budget_minutes: number | null; close_review_required: boolean; @@ -4085,6 +4298,8 @@ function localSyncSnapshot( ) { return { ticketId: localTicket.id, + userId: localTicket.user_id, + ownSessionCount: localTicket.own_session_count ?? 0, ticketNumber: localTicket.ticket_number, title: localTicket.title, organizationId: localTicket.organization_id, @@ -4121,6 +4336,8 @@ async function findLocalTicketForZammadTarget(client: QueryClient, zammadTicket: ` SELECT t.id::text AS id, + t.user_id::text AS user_id, + 0::int AS own_session_count, t.ticket_number, t.title, t.organization_id::text AS organization_id, @@ -4148,7 +4365,7 @@ async function findLocalTicketForZammadTarget(client: QueryClient, zammadTicket: return result.rows[0] ?? null; } -async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: string, ownerUserId: string) { +async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: string, ownerUserId: string | null, zammadOwnerId: number | null = null) { const result = await client.query( ` SELECT @@ -4191,15 +4408,90 @@ async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: s ) ordered ) entries ON true WHERE p.ticket_id = $1 - AND p.user_id = $2 + AND ( + ( + ($2::bigint IS NOT NULL AND p.user_id = $2) + OR ($2::bigint IS NULL AND p.user_id IS NULL AND p.zammad_owner_id IS NOT DISTINCT FROM $3::bigint) + ) + OR NOT EXISTS ( + SELECT 1 + FROM projects owner_project + WHERE owner_project.ticket_id = $1 + AND ( + ($2::bigint IS NOT NULL AND owner_project.user_id = $2) + OR ($2::bigint IS NULL AND owner_project.user_id IS NULL AND owner_project.zammad_owner_id IS NOT DISTINCT FROM $3::bigint) + ) + ) + ) + ORDER BY + ( + ($2::bigint IS NOT NULL AND p.user_id = $2) + OR ($2::bigint IS NULL AND p.user_id IS NULL AND p.zammad_owner_id IS NOT DISTINCT FROM $3::bigint) + ) DESC, + (p.user_id IS NULL AND p.zammad_owner_id IS NOT NULL) DESC, + p.id ASC LIMIT 1; `, - [ticketId, ownerUserId] + [ticketId, ownerUserId, zammadOwnerId] ); return result.rows[0] ?? null; } +async function adoptExistingProjectForTicketOwner(client: QueryClient, ticketId: string, ownerUserId: string | null, zammadOwnerId: number | null) { + const targetProject = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM projects + WHERE ticket_id = $1 + AND ( + ($2::bigint IS NOT NULL AND user_id = $2) + OR ($2::bigint IS NULL AND user_id IS NULL AND zammad_owner_id IS NOT DISTINCT FROM $3::bigint) + ) + FOR UPDATE; + `, + [ticketId, ownerUserId, zammadOwnerId] + ); + + if (targetProject.rows[0]) { + return targetProject.rows[0].id; + } + + const sourceProject = await client.query<{ id: string }>( + ` + SELECT p.id::text AS id + FROM projects p + WHERE p.ticket_id = $1 + ORDER BY + (p.user_id IS NULL AND p.zammad_owner_id IS NOT NULL) DESC, + p.id ASC + LIMIT 1 + FOR UPDATE; + `, + [ticketId] + ); + + const sourceProjectId = sourceProject.rows[0]?.id; + + if (!sourceProjectId) { + return null; + } + + const updated = await client.query<{ id: string }>( + ` + UPDATE projects + SET user_id = $1::bigint, + zammad_owner_id = $2::bigint, + updated_at = now() + WHERE id = $3 + RETURNING id::text AS id; + `, + [ownerUserId, zammadOwnerId, sourceProjectId] + ); + + return updated.rows[0]?.id ?? null; +} + async function ticketHasAnyProject(client: QueryClient, ticketId: string) { const result = await client.query<{ exists: boolean }>( ` @@ -4772,16 +5064,83 @@ async function planExistingZammadTicketSync( fields: ZammadSyncExistingFields ): Promise { const changes: ZammadSyncChange[] = []; + const foreignOwnerWithOwnSessions = Boolean(localTicket.user_id && localTicket.user_id !== userId && (localTicket.own_session_count ?? 0) > 0); + + if (foreignOwnerWithOwnSessions) { + changes.push({ + field: "Fremdes Ticket", + from: `TT-User ${localTicket.user_id}`, + to: `${localTicket.own_session_count ?? 0} eigene Session(s); Ticketdaten werden trotzdem mit Zammad abgeglichen` + }); + } + 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 ownerMapping = await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId); + const zammadOwnerUserId = ownerMapping.status === "ambiguous" ? localTicket.user_id : ownerMapping.userId; + const currentZammadUserId = await zammadUserIdForUser(client, userId); + const localZammadOwnerId = numberOrNull(localTicket.zammad_owner_id); const mergedTargetTicket = zammadTicket.ticketNumber !== localTicket.ticket_number ? await findLocalTicketForZammadTarget(client, zammadTicket, localTicket.id) : null; + if (localZammadOwnerId !== zammadTicket.zammadOwnerId) { + changes.push({ + field: "Owner", + from: localZammadOwnerId ? `Zammad-Owner ${localZammadOwnerId}` : "kein Zammad-Owner", + to: ownerMapping.userId + ? `Zammad-Owner ${zammadTicket.zammadOwnerId} (TT-User ${ownerMapping.userId})` + : ownerMapping.status === "missing" + ? `Zammad-Owner ${zammadTicket.zammadOwnerId} (nicht in TT angelegt)` + : `Zammad-Owner ${zammadTicket.zammadOwnerId}` + }); + } + + if (zammadTicket.zammadOwnerId && currentZammadUserId && zammadTicket.zammadOwnerId !== currentZammadUserId) { + const ownerLabel = ownerMapping.userId + ? `Owner ${zammadTicket.zammadOwnerId} (TT-User ${ownerMapping.userId})` + : ownerMapping.status === "missing" + ? `Owner ${zammadTicket.zammadOwnerId} (nicht in TT angelegt)` + : `Owner ${zammadTicket.zammadOwnerId}`; + changes.push({ + field: "Fremder Zammad-Owner", + from: `du: Zammad-User ${currentZammadUserId}`, + to: `${ownerLabel}; Aktualisierung betrifft das Ticket eines anderen Zammad-Nutzers` + }); + } + + if ((localTicket.user_id ?? null) !== (zammadOwnerUserId ?? null)) { + const ownershipMovesToCurrentUser = + zammadOwnerUserId === userId || + ( + zammadTicket.zammadOwnerId !== null && + currentZammadUserId !== null && + zammadTicket.zammadOwnerId === currentZammadUserId + ); + + changes.push({ + field: "Ticket-Zuordnung", + from: localTicket.user_id ? `TT-User ${localTicket.user_id}` : "kein TT-Hauptuser", + to: zammadOwnerUserId + ? `TT-User ${zammadOwnerUserId}` + : ownerMapping.status === "missing" + ? `Zammad-Owner ${zammadTicket.zammadOwnerId ?? "unbekannt"} ist nicht in TT angelegt` + : "kein eindeutiger TT-Hauptuser" + }); + + if (!ownershipMovesToCurrentUser) { + changes.push({ + field: "Hinweis", + from: null, + to: "Ticket-Zuordnung kommt aus Zammad. Bitte den betroffenen Kollegen informieren." + }); + } + } + if (zammadTicket.ticketNumber !== localTicket.ticket_number) { addSyncChange(changes, "Ticketnummer", localTicket.ticket_number, zammadTicket.ticketNumber); changes.push({ @@ -4870,8 +5229,8 @@ async function planExistingZammadTicketSync( let mergeReviewProjects: SyncProjectSnapshot[] = []; if (shouldManageConsultingProject || (isProjectDataProtected && localTicket.work_type === "consulting")) { - projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); - project = await getProjectSnapshotForTicketOwner(client, localTicket.id, projectOwnerUserId); + projectOwnerUserId = ownerMapping.status === "ambiguous" ? localTicket.user_id ?? userId : ownerMapping.userId; + project = await getProjectSnapshotForTicketOwner(client, localTicket.id, projectOwnerUserId, zammadTicket.zammadOwnerId); } if (isProjectDataProtected && localTicket.work_type === "consulting") { @@ -5248,12 +5607,11 @@ async function fetchZammadMailSessionArticleCandidatesForUser(input: { }) { const cutoff = zammadMailSyncCutoff(input.daysBack); const cutoffIso = cutoff.toISOString(); - const basePath = `/api/v1/ticket_articles?sort_by=id&order_by=desc&type_id=1&created_by_id=${input.zammadUserId}&per_page=${input.perPage}`; + const basePath = `/api/v1/ticket_articles?sort_by=created_at&order_by=desc&per_page=${input.perPage}`; async function fetchPage(page: number) { const payload = await fetchZammadJson(input.baseUrl, input.apiToken, `${basePath}&page=${page}`); - return zammadArticleCandidates(payload) - .filter((article) => numberOrNull(article.created_by_id) === input.zammadUserId && numberOrNull(article.type_id) === 1); + return zammadArticleCandidates(payload); } function pageOldestDate(articles: Record[]) { @@ -5275,6 +5633,8 @@ async function fetchZammadMailSessionArticleCandidatesForUser(input: { if ( !articleId + || numberOrNull(article.created_by_id) !== input.zammadUserId + || numberOrNull(article.type_id) !== 1 || numberOrNull(article.sender_id) !== 1 || (!input.atossEmailSyncEnabled && zammadArticleTargetsAtossSupport(article)) || Boolean(input.selectedArticleIds && !input.selectedArticleIds.has(articleId)) @@ -5411,6 +5771,49 @@ function zammadMailSessionArticleFromByTicketRaw( }; } +async function linkExistingZammadMailSessionForArticle( + client: QueryClient, + userId: string, + ticketNumber: string, + article: ZammadMailSessionArticle +) { + const endedAt = new Date(article.createdAt.getTime() + article.durationSeconds * 1000); + const sessionResult = await client.query<{ id: string }>( + ` + SELECT s.id::text AS id + FROM sessions s + JOIN tickets t ON t.id = s.ticket_id + WHERE s.user_id = $1 + AND t.ticket_number = $2 + AND s.started_at = $3::timestamptz + AND s.ended_at = $4::timestamptz + AND s.duration_seconds = $5 + AND s.activity = $6 + ORDER BY s.created_at DESC, s.id DESC + LIMIT 1; + `, + [userId, ticketNumber, article.createdAt.toISOString(), endedAt.toISOString(), article.durationSeconds, article.activity] + ); + const sessionId = sessionResult.rows[0]?.id; + + if (!sessionId) { + return false; + } + + const eventResult = await client.query<{ session_id: string | null }>( + ` + INSERT INTO zammad_email_session_events (user_id, zammad_article_id, session_id) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, zammad_article_id) WHERE zammad_article_id IS NOT NULL + DO UPDATE SET session_id = COALESCE(zammad_email_session_events.session_id, EXCLUDED.session_id) + RETURNING session_id::text AS session_id; + `, + [userId, article.articleId, sessionId] + ); + + return Boolean(eventResult.rows[0]?.session_id); +} + async function runTicketZammadMailSessionImport( userId: string, ticketId: number, @@ -5499,15 +5902,21 @@ async function runTicketZammadMailSessionImport( continue; } - if (dryRun) { - result.items.push(ticketZammadMailSessionItem(article)); - continue; - } + if (dryRun) { + result.items.push(ticketZammadMailSessionItem(article)); + continue; + } - try { - const startedAt = article.createdAt; - const endedAt = new Date(startedAt.getTime() + article.durationSeconds * 1000); - const created = await createSessionForUser({ + if (await withTransaction((client) => linkExistingZammadMailSessionForArticle(client, userId, ticket.ticket_number, article))) { + result.created += 1; + result.items.push(ticketZammadMailSessionItem(article)); + continue; + } + + try { + const startedAt = article.createdAt; + const endedAt = new Date(startedAt.getTime() + article.durationSeconds * 1000); + const created = await createSessionForUser({ userId, ticketNumber: ticket.ticket_number, activity: article.activity, @@ -5553,6 +5962,7 @@ async function zammadMailArticleAlreadyImported(client: QueryClient, userId: str FROM zammad_email_session_events WHERE user_id = $1 AND zammad_article_id = $2 + AND session_id IS NOT NULL ) AS exists; `, [userId, articleId] @@ -5571,6 +5981,7 @@ async function importedZammadMailArticleIds(client: QueryClient, userId: string, SELECT zammad_article_id::text AS zammad_article_id FROM zammad_email_session_events WHERE user_id = $1 + AND session_id IS NOT NULL AND zammad_article_id = ANY($2::bigint[]); `, [userId, articleIds] @@ -5722,21 +6133,32 @@ async function updateDefaultProjectTeamspaceEntry( ); } +async function zammadSyncPhase(phase: string, run: () => Promise) { + try { + return await run(); + } catch (error) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + throw new Error(`${phase}: ${message}`); + } +} + async function applyZammadConsultingProjectSync( client: QueryClient, ticketId: string, - projectOwnerUserId: string, + projectOwnerUserId: string | null, zammadTicket: ZammadTicketImport, fields: ZammadSyncExistingFields ) { - await ensureProjectForTicket(client, ticketId, projectOwnerUserId, { + await zammadSyncPhase("Projekt-Owner umstellen", () => adoptExistingProjectForTicketOwner(client, ticketId, projectOwnerUserId, zammadTicket.zammadOwnerId)); + await zammadSyncPhase("Projekt sicherstellen", () => ensureProjectForTicket(client, ticketId, projectOwnerUserId, { teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, - closeReviewRequired: zammadTicket.zammadStateId === 4 - }); + closeReviewRequired: zammadTicket.zammadStateId === 4, + zammadOwnerId: zammadTicket.zammadOwnerId + })); - const project = await getProjectSnapshotForTicketOwner(client, ticketId, projectOwnerUserId); + const project = await zammadSyncPhase("Projekt laden", () => getProjectSnapshotForTicketOwner(client, ticketId, projectOwnerUserId, zammadTicket.zammadOwnerId)); if (!project) { return; @@ -5745,12 +6167,12 @@ async function applyZammadConsultingProjectSync( const syncBudget = fields.budget && (!project.default_budget_sync_disabled || fields.overrideBudget); if (fields.projectName) { - await syncProjectTeamspaceEntriesFromZammad(client, project.id, zammadTeamspaceProjectList(zammadTicket), zammadTicket.teamspaceBudgetMinutes, syncBudget); + await zammadSyncPhase("Teamspace-Projekte synchronisieren", () => syncProjectTeamspaceEntriesFromZammad(client, project.id, zammadTeamspaceProjectList(zammadTicket), zammadTicket.teamspaceBudgetMinutes, syncBudget)); } else if (syncBudget) { - await updateDefaultProjectTeamspaceEntry(client, project.id, { + await zammadSyncPhase("Teamspace-Budget synchronisieren", () => updateDefaultProjectTeamspaceEntry(client, project.id, { budgetMinutes: zammadTicket.teamspaceBudgetMinutes - }); - await client.query( + })); + await zammadSyncPhase("Projekt-Budget synchronisieren", () => client.query( ` UPDATE projects SET hour_budget_minutes = $1, @@ -5758,11 +6180,11 @@ async function applyZammadConsultingProjectSync( WHERE id = $2; `, [zammadTicket.teamspaceBudgetMinutes, project.id] - ); + )); } if (zammadTicket.zammadStateId === 4) { - await client.query( + await zammadSyncPhase("Projekt-Prüfung setzen", () => client.query( ` UPDATE projects SET close_review_required = true, @@ -5770,7 +6192,7 @@ async function applyZammadConsultingProjectSync( WHERE id = $1; `, [project.id] - ); + )); } } @@ -6094,6 +6516,8 @@ async function localZammadSyncTicketById(client: QueryClient, ticketId: string) ` SELECT t.id::text AS id, + t.user_id::text AS user_id, + COALESCE(own_sessions.own_session_count, 0)::int AS own_session_count, t.ticket_number, t.title, t.organization_id::text AS organization_id, @@ -6119,16 +6543,19 @@ async function localZammadSyncTicketById(client: QueryClient, ticketId: string) return result.rows[0] ?? null; } -async function importLocalTargetForZammadMerge(client: QueryClient, zammadTicket: ZammadTicketImport) { +async function importLocalTargetForZammadMerge(client: QueryClient, zammadTicket: ZammadTicketImport, userId: string) { const organization = await getOrganizationByZammadId(client, zammadTicket.organizationId); if (!organization) { return null; } + const ownerMapping = await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId); + const ownerUserId = ownerMapping.status === "ambiguous" ? userId : ownerMapping.userId; const result = await client.query<{ id: string }>( ` INSERT INTO tickets ( + user_id, ticket_number, title, organization_id, @@ -6143,11 +6570,13 @@ async function importLocalTargetForZammadMerge(client: QueryClient, zammadTicket zammad_merged_from_ticket_number, zammad_preferences ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::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), + user_id = EXCLUDED.user_id, + title = CASE WHEN EXCLUDED.user_id IS NULL OR tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(EXCLUDED.title, tickets.title) ELSE tickets.title END, + organization_id = CASE WHEN EXCLUDED.user_id IS NULL OR tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(tickets.organization_id, EXCLUDED.organization_id) ELSE tickets.organization_id END, + customer_name = CASE WHEN EXCLUDED.user_id IS NULL OR tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(tickets.customer_name, EXCLUDED.customer_name) ELSE tickets.customer_name END, + work_type = CASE WHEN EXCLUDED.user_id IS NULL OR tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN EXCLUDED.work_type ELSE tickets.work_type END, zammad_ticket_id = EXCLUDED.zammad_ticket_id, zammad_group_id = EXCLUDED.zammad_group_id, zammad_priority_id = EXCLUDED.zammad_priority_id, @@ -6159,6 +6588,7 @@ async function importLocalTargetForZammadMerge(client: QueryClient, zammadTicket RETURNING id::text; `, [ + ownerUserId, zammadTicket.ticketNumber, zammadTicket.title, organization.id, @@ -6179,6 +6609,10 @@ async function importLocalTargetForZammadMerge(client: QueryClient, zammadTicket } async function resolveLocalTicketForZammadSync(client: QueryClient, localTicket: LocalZammadSyncTicket, zammadTicket: ZammadTicketImport, userId: string) { + if (localTicket.user_id && localTicket.user_id !== userId && !await userHasSessionsForTicket(client, localTicket.id, userId)) { + return "not-owner" as const; + } + if (localTicket.ticket_number === zammadTicket.ticketNumber && localTicket.zammad_ticket_id === zammadTicket.zammadTicketId) { return localTicket; } @@ -6186,16 +6620,24 @@ async function resolveLocalTicketForZammadSync(client: QueryClient, localTicket: const targetTicket = await findLocalTicketForZammadTarget(client, zammadTicket, localTicket.id); if (targetTicket) { + if (targetTicket.user_id && targetTicket.user_id !== userId && !await userHasSessionsForTicket(client, targetTicket.id, userId)) { + return "not-owner" as const; + } + 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); + const importedTargetTicket = await importLocalTargetForZammadMerge(client, zammadTicket, userId); if (!importedTargetTicket) { return "organization-missing" as const; } + if (importedTargetTicket.user_id && importedTargetTicket.user_id !== userId && !await userHasSessionsForTicket(client, importedTargetTicket.id, userId)) { + return "not-owner" as const; + } + return mergeLocalTicketIntoTarget(client, localTicket.id, localTicket.ticket_number, localTicket.work_type, importedTargetTicket, zammadTicket); } @@ -6216,11 +6658,17 @@ async function applyZammadTicketSync( return "organization-missing" as const; } + if (resolvedLocalTicket === "not-owner") { + return "not-owner" 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; + const ownerMapping = await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId); + const zammadOwnerUserId = ownerMapping.status === "ambiguous" ? localTicket.user_id : ownerMapping.userId; if (shouldResolveOrganization && !organization) { return "organization-missing" as const; @@ -6230,6 +6678,7 @@ async function applyZammadTicketSync( ` UPDATE tickets SET ticket_number = $15, + user_id = $16, 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, @@ -6240,7 +6689,17 @@ async function applyZammadTicketSync( zammad_state_id = $12, zammad_owner_id = $13, zammad_preferences = $14::jsonb - WHERE id = $1; + WHERE id = $1 + AND ( + user_id = $17 + OR user_id IS NULL + OR EXISTS ( + SELECT 1 + FROM sessions own_sessions + WHERE own_sessions.ticket_id = tickets.id + AND own_sessions.user_id = $17 + ) + ); `, [ localTicket.id, @@ -6257,19 +6716,41 @@ async function applyZammadTicketSync( zammadTicket.zammadStateId, zammadTicket.zammadOwnerId, JSON.stringify(zammadTicket.preferences), - zammadTicket.ticketNumber + zammadTicket.ticketNumber, + zammadOwnerUserId, + userId ] ); + if (zammadOwnerUserId && localTicket.user_id && zammadOwnerUserId !== localTicket.user_id) { + await client.query( + ` + UPDATE projects p + SET user_id = $2, + updated_at = now() + WHERE p.ticket_id = $1 + AND p.user_id = $3 + AND NOT EXISTS ( + SELECT 1 + FROM projects existing + WHERE existing.ticket_id = p.ticket_id + AND existing.user_id = $2 + ); + `, + [localTicket.id, zammadOwnerUserId, localTicket.user_id] + ); + } + if (!metadataOnly && fields.organization && organization) { await client.query( ` UPDATE sessions SET organization_id = $1, customer_name = $2 - WHERE ticket_id = $3; + WHERE ticket_id = $3 + AND user_id = $4; `, - [organization.id, organization.name, localTicket.id] + [organization.id, organization.name, localTicket.id, userId] ); } @@ -6298,7 +6779,7 @@ async function applyZammadTicketSync( } if (fields.type && zammadTicket.workType === "consulting") { - const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); + const projectOwnerUserId = ownerMapping.status === "ambiguous" ? localTicket.user_id ?? userId : ownerMapping.userId; await applyZammadConsultingProjectSync(client, localTicket.id, projectOwnerUserId, zammadTicket, fields); await client.query( @@ -6315,7 +6796,7 @@ async function applyZammadTicketSync( } if (zammadTicket.workType === "consulting" && localTicket.work_type === "consulting") { - const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); + const projectOwnerUserId = ownerMapping.status === "ambiguous" ? localTicket.user_id ?? userId : ownerMapping.userId; await applyZammadConsultingProjectSync(client, localTicket.id, projectOwnerUserId, zammadTicket, fields); await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id); @@ -7299,6 +7780,11 @@ async function completeAutomaticProjectFollowupsForSession(client: QueryClient, async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); + + if (!await userCanViewTicket({ query }, ticketId, userId)) { + return null; + } + await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId); const periodClosureJoin = @@ -7470,6 +7956,11 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon if (filters.month) { await ensureRecurringSessionsForUserPeriod(userId, filters.month.startIso, filters.month.endIso); } + + if (!await userCanViewTicket({ query }, ticketId, userId)) { + return null; + } + await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId); const ticketResult = await query( @@ -7482,8 +7973,16 @@ 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_ticket_id::text AS zammad_ticket_id, + t.zammad_group_id::text AS zammad_group_id, 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_owner_id::text AS zammad_owner_id, + zammad_owner_user.id::text AS zammad_owner_user_id, + zammad_owner_user.display_name AS zammad_owner_display_name, + zammad_owner_user.username AS zammad_owner_username, + (viewer.zammad_user_id IS NOT NULL AND t.zammad_owner_id IS NOT NULL AND viewer.zammad_user_id IS DISTINCT FROM t.zammad_owner_id) AS is_foreign_zammad_owner, + (t.zammad_ticket_id IS NULL AND (t.user_id = $2::bigint OR t.user_id IS NULL)) AS can_edit_ticket_data, NULL::timestamptz AS closed_at, NULL::timestamptz AS period_closed_at, COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count, @@ -7496,7 +7995,9 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing FROM tickets t + JOIN users viewer ON viewer.id = $2 LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN users zammad_owner_user ON zammad_owner_user.role = 'user' AND zammad_owner_user.active AND zammad_owner_user.zammad_user_id = t.zammad_owner_id LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count, @@ -7637,7 +8138,19 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon p.user_id::text AS user_id, owner.username AS owner_username, owner.display_name AS owner_display_name, - (p.user_id = $2::bigint) AS can_edit, + p.zammad_owner_id::text AS zammad_owner_id, + t.zammad_ticket_id::text AS zammad_ticket_id, + t.zammad_owner_id::text AS ticket_zammad_owner_id, + COALESCE(( + t.user_id = $2::bigint + OR EXISTS ( + SELECT 1 FROM users viewer + WHERE viewer.id = $2 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) + ), false) AS can_edit, p.ticket_id::text AS ticket_id, t.ticket_number, t.title, @@ -7672,7 +8185,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon p.created_at, p.updated_at FROM projects p - JOIN users owner ON owner.id = p.user_id + LEFT JOIN users owner ON owner.id = p.user_id JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( @@ -7776,8 +8289,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon tt.sort_order ASC, tt.id ASC; `, - [ticketId, userId] - ) + [ticketId, userId] + ) ]); const sessions = sessionsResult.rows; @@ -8273,8 +8786,13 @@ app.patch("/api/auth/me", async (req, res) => { } } + if (updatesZammadToken) { + await assertUniqueZammadUserIdAvailable({ query }, zammadUserId, user.id); + } + try { - const result = await query( + const updatedUser = await withTransaction(async (client) => { + const result = await client.query( ` UPDATE users SET username = $1, @@ -8320,14 +8838,46 @@ app.patch("/api/auth/me", async (req, res) => { projectFollowupSecondWeeks, projectFollowupCompleteOnSession ] - ); + ); - if (result.rowCount === 0) { + if (updatesZammadToken && zammadUserId) { + await client.query( + ` + UPDATE tickets + SET user_id = $2 + WHERE zammad_owner_id = $1 + AND (user_id IS NULL OR user_id = $2); + `, + [zammadUserId, user.id] + ); + await client.query( + ` + UPDATE projects p + SET user_id = $2, + updated_at = now() + WHERE p.zammad_owner_id = $1 + AND (p.user_id IS NULL OR p.user_id = $2) + AND NOT EXISTS ( + SELECT 1 + FROM projects existing + WHERE existing.ticket_id = p.ticket_id + AND existing.user_id = $2 + AND existing.id <> p.id + ); + `, + [zammadUserId, user.id] + ); + } + + return result; + }); + + if (updatedUser.rowCount === 0) { res.status(404).json({ error: "User not found" }); return; } - res.json({ user: result.rows[0] }); + res.json({ user: updatedUser.rows[0] }); } catch (error: any) { if (error?.code === "23505") { res.status(409).json({ error: "Benutzername ist bereits vergeben" }); @@ -9261,19 +9811,21 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => const targetTicketNumber = payload.ticketNumber ?? `Fix#${billingId}`; const targetTicketResult = await client.query<{ id: string }>( ` - INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type) - VALUES ($1, $2, $3, $4) + INSERT INTO tickets (user_id, ticket_number, organization_id, customer_name, work_type) + VALUES ($5, $1, $2, $3, $4) ON CONFLICT (ticket_number) DO UPDATE SET - organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), - customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), + user_id = COALESCE(tickets.user_id, EXCLUDED.user_id), + organization_id = CASE WHEN tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(tickets.organization_id, EXCLUDED.organization_id) ELSE tickets.organization_id END, + customer_name = CASE WHEN tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(tickets.customer_name, EXCLUDED.customer_name) ELSE tickets.customer_name END, work_type = CASE + WHEN tickets.user_id IS NOT NULL AND tickets.user_id <> EXCLUDED.user_id THEN tickets.work_type WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) END RETURNING id; `, - [targetTicketNumber, organization.id, organization.name, payload.workType] + [targetTicketNumber, organization.id, organization.name, payload.workType, userId] ); if (payload.workType === "consulting") { @@ -9771,7 +10323,16 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { COALESCE(project_entries.teamspace_entries, '[]'::json) AS project_teamspace_entries, p.user_id::text AS project_owner_user_id, pu.display_name AS project_owner_display_name, - (p.user_id = $2::bigint) AS can_edit_project, + COALESCE(( + t.user_id = $2::bigint + OR EXISTS ( + SELECT 1 FROM users viewer + WHERE viewer.id = $2 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) + ), false) AS can_edit_project, EXISTS ( SELECT 1 FROM recurring_billings rb @@ -9784,7 +10345,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { SELECT projects.* FROM projects WHERE projects.ticket_id = t.id - ORDER BY (projects.user_id = $2::bigint) DESC, projects.id ASC + ORDER BY (projects.user_id = t.user_id) DESC, (projects.user_id = $2::bigint) DESC, projects.id ASC LIMIT 1 ) p ON true LEFT JOIN users pu ON pu.id = p.user_id @@ -9978,6 +10539,8 @@ async function runExistingZammadSync( ` SELECT t.id::text AS id, + t.user_id::text AS user_id, + COALESCE(own_sessions.own_session_count, 0)::int AS own_session_count, t.ticket_number, t.title, t.organization_id::text AS organization_id, @@ -9994,15 +10557,27 @@ async function runExistingZammadSync( t.zammad_preferences FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id - WHERE t.ticket_number = ANY($1::text[]) + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS own_session_count + FROM sessions s + WHERE s.ticket_id = t.id + AND s.user_id = $1 + ) own_sessions ON true + WHERE t.ticket_number = ANY($2::text[]) + AND ( + t.user_id = $1 + OR own_sessions.own_session_count > 0 + ) ORDER BY t.ticket_number ASC; `, - [Array.from(selectedTicketNumbers)] + [userId, Array.from(selectedTicketNumbers)] ) : await query( ` SELECT t.id::text AS id, + t.user_id::text AS user_id, + COALESCE(own_sessions.own_session_count, 0)::int AS own_session_count, t.ticket_number, t.title, t.organization_id::text AS organization_id, @@ -10019,9 +10594,20 @@ async function runExistingZammadSync( t.zammad_preferences FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS own_session_count + FROM sessions s + WHERE s.ticket_id = t.id + AND s.user_id = $1 + ) own_sessions ON true WHERE t.ticket_number LIKE 'Ticket#%' + AND ( + t.user_id = $1 + OR own_sessions.own_session_count > 0 + ) ORDER BY t.ticket_number ASC; - ` + `, + [userId] ); for (const localTicket of localTickets.rows) { @@ -10059,11 +10645,13 @@ async function runExistingZammadSync( if (!dryRun) { const status = await withTransaction(async (client) => applyZammadTicketSync(client, localTicket, zammadTicket, userId, fields)); - if (status === "organization-missing") { + if (status === "organization-missing" || status === "not-owner") { const item = { ...plan, action: "skip" as const, - reason: "Zammad-Organisation ist lokal nicht synchronisiert.", + reason: status === "organization-missing" + ? "Zammad-Organisation ist lokal nicht synchronisiert." + : "Ticket gehört einem anderen Benutzer und wurde nicht überschrieben.", changes: [] }; updateZammadSyncResultFromItem(result, item, localTicket.work_type); @@ -10174,6 +10762,7 @@ async function runExistingZammadSyncFromPreview( ` SELECT t.id::text AS id, + t.user_id::text AS user_id, t.ticket_number, t.title, t.organization_id::text AS organization_id, @@ -10190,10 +10779,20 @@ async function runExistingZammadSyncFromPreview( t.zammad_preferences FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id - WHERE t.ticket_number = ANY($1::text[]) + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS own_session_count + FROM sessions s + WHERE s.ticket_id = t.id + AND s.user_id = $1 + ) own_sessions ON true + WHERE t.ticket_number = ANY($2::text[]) + AND ( + t.user_id = $1 + OR own_sessions.own_session_count > 0 + ) ORDER BY t.ticket_number ASC; `, - [Array.from(cachedTickets.keys())] + [userId, Array.from(cachedTickets.keys())] ); for (const localTicket of localTickets.rows) { @@ -10219,11 +10818,13 @@ async function runExistingZammadSyncFromPreview( const status = await withTransaction(async (client) => applyZammadTicketSync(client, localTicket, zammadTicket, userId, fields)); - if (status === "organization-missing") { + if (status === "organization-missing" || status === "not-owner") { updateZammadSyncResultFromItem(result, { ...plan, action: "skip", - reason: "Zammad-Organisation ist lokal nicht synchronisiert.", + reason: status === "organization-missing" + ? "Zammad-Organisation ist lokal nicht synchronisiert." + : "Ticket gehört einem anderen Benutzer und wurde nicht überschrieben.", changes: [] }, localTicket.work_type); continue; @@ -10306,6 +10907,7 @@ async function localTicketForZammadMailSync(zammadTicket: ZammadTicketImport) { ` SELECT t.id::text AS id, + t.user_id::text AS user_id, t.ticket_number, t.title, t.organization_id::text AS organization_id, @@ -10434,6 +11036,15 @@ async function runZammadMailSessionImport(userId: string, daysBack: number, perP if (!dryRun) { const startedAt = article.createdAt; const endedAt = new Date(startedAt.getTime() + article.durationSeconds * 1000); + const linkedExisting = await withTransaction((client) => + linkExistingZammadMailSessionForArticle(client, userId, zammadTicket.ticketNumber, article) + ); + + if (linkedExisting) { + updateZammadSyncResultFromItem(result, plan, localTicket?.work_type ?? zammadTicket.workType); + continue; + } + const created = await createSessionForUser({ userId, ticketNumber: zammadTicket.ticketNumber, @@ -10449,7 +11060,7 @@ async function runZammadMailSessionImport(userId: string, daysBack: number, perP startedAt, endedAt, durationSeconds: article.durationSeconds, - zammadTicket: localTicket ? null : zammadTicket, + zammadTicket, sourceEvent: { zammadArticleId: article.articleId, deliveryId: null @@ -10529,6 +11140,15 @@ async function runZammadMailSessionImportFromPreview(userId: string, previewResu const startedAt = article.createdAt; const endedAt = new Date(startedAt.getTime() + article.durationSeconds * 1000); + const linkedExisting = await withTransaction((client) => + linkExistingZammadMailSessionForArticle(client, userId, zammadTicket.ticketNumber, article) + ); + + if (linkedExisting) { + updateZammadSyncResultFromItem(result, plan, localTicket?.work_type ?? zammadTicket.workType); + continue; + } + const created = await createSessionForUser({ userId, ticketNumber: zammadTicket.ticketNumber, @@ -10544,7 +11164,7 @@ async function runZammadMailSessionImportFromPreview(userId: string, previewResu startedAt, endedAt, durationSeconds: article.durationSeconds, - zammadTicket: localTicket ? null : zammadTicket, + zammadTicket, sourceEvent: { zammadArticleId: article.articleId, deliveryId: null @@ -10754,9 +11374,43 @@ app.get("/api/projects", requireUser, async (req, res) => { const userId = currentUser(req).id; const includeClosed = req.query.includeClosed === "true"; const hiddenOnly = req.query.hidden === "true"; + const canonicalProjectWhere = ` + p.id = ( + SELECT canonical_project.id + FROM projects canonical_project + JOIN tickets canonical_ticket ON canonical_ticket.id = canonical_project.ticket_id + WHERE canonical_project.ticket_id = p.ticket_id + ORDER BY + ( + canonical_ticket.zammad_owner_id IS NOT NULL + AND canonical_project.user_id IS NULL + AND canonical_project.zammad_owner_id IS NOT DISTINCT FROM canonical_ticket.zammad_owner_id + ) DESC, + ( + canonical_ticket.user_id IS NOT NULL + AND canonical_project.user_id = canonical_ticket.user_id + ) DESC, + (canonical_project.user_id IS NULL) DESC, + canonical_project.id ASC + LIMIT 1 + ) + `; const hiddenProjectWhere = ` ( - p.user_id = $1 + EXISTS ( + SELECT 1 + FROM tickets visible_ticket + LEFT JOIN users viewer ON viewer.id = $1 + WHERE visible_ticket.id = p.ticket_id + AND ( + visible_ticket.user_id = $1 + OR ( + viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = visible_ticket.zammad_owner_id + ) + ) + ) OR EXISTS ( SELECT 1 FROM sessions viewer_sessions @@ -10770,6 +11424,7 @@ app.get("/api/projects", requireUser, async (req, res) => { SELECT COUNT(*) AS count FROM projects p WHERE ${hiddenProjectWhere} + AND ${canonicalProjectWhere} AND EXISTS ( SELECT 1 FROM hidden_user_projects hup @@ -10783,7 +11438,8 @@ app.get("/api/projects", requireUser, async (req, res) => { ` SELECT COUNT(*) AS count FROM projects p - WHERE ${hiddenProjectWhere}; + WHERE ${hiddenProjectWhere} + AND ${canonicalProjectWhere}; `, [userId] ); @@ -10794,7 +11450,16 @@ app.get("/api/projects", requireUser, async (req, res) => { p.user_id::text AS user_id, owner.username AS owner_username, owner.display_name AS owner_display_name, - (p.user_id = $1::bigint) AS can_edit, + COALESCE(( + t.user_id = $1::bigint + OR EXISTS ( + SELECT 1 FROM users viewer + WHERE viewer.id = $1 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) + ), false) AS can_edit, p.ticket_id::text AS ticket_id, t.ticket_number, t.title, @@ -10842,7 +11507,7 @@ app.get("/api/projects", requireUser, async (req, res) => { COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos, COALESCE(todo_totals.done_todos, 0)::int AS done_todos FROM projects p - JOIN users owner ON owner.id = p.user_id + LEFT JOIN users owner ON owner.id = p.user_id JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( @@ -10920,14 +11585,23 @@ app.get("/api/projects", requireUser, async (req, res) => { AND tt.user_id = $1 ) todo_totals ON true WHERE ( - p.user_id = $1 + t.user_id = $1 + OR EXISTS ( + SELECT 1 + FROM users viewer + WHERE viewer.id = $1 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) OR EXISTS ( SELECT 1 FROM sessions viewer_sessions WHERE viewer_sessions.ticket_id = p.ticket_id AND viewer_sessions.user_id = $1 - ) + ) ) + AND ${canonicalProjectWhere} AND ( ( $3::boolean @@ -10974,7 +11648,16 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { p.user_id::text AS user_id, owner.username AS owner_username, owner.display_name AS owner_display_name, - (p.user_id = $2::bigint) AS can_edit, + COALESCE(( + t.user_id = $2::bigint + OR EXISTS ( + SELECT 1 FROM users viewer + WHERE viewer.id = $2 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) + ), false) AS can_edit, p.ticket_id::text AS ticket_id, t.ticket_number, t.title, @@ -11010,7 +11693,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { p.created_at, p.updated_at FROM projects p - JOIN users owner ON owner.id = p.user_id + LEFT JOIN users owner ON owner.id = p.user_id JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( @@ -11071,7 +11754,15 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { ) unassigned_sessions ON true WHERE p.id = $1 AND ( - p.user_id = $2 + t.user_id = $2 + OR EXISTS ( + SELECT 1 + FROM users viewer + WHERE viewer.id = $2 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) OR EXISTS ( SELECT 1 FROM sessions viewer_sessions @@ -11168,13 +11859,26 @@ app.post("/api/projects", requireUser, async (req, res) => { const existingTicket = await client.query<{ id: string; + user_id: string | null; title: string | null; organization_id: string | null; customer_name: string | null; + ticket_number: string; + work_type: "support" | "consulting" | null; zammad_owner_id: string | null; + zammad_ticket_id: string | null; }>( ` - SELECT id, title, organization_id::text AS organization_id, customer_name, zammad_owner_id::text AS zammad_owner_id + SELECT + id, + user_id::text AS user_id, + ticket_number, + title, + organization_id::text AS organization_id, + customer_name, + work_type, + zammad_owner_id::text AS zammad_owner_id, + zammad_ticket_id::text AS zammad_ticket_id FROM tickets WHERE ticket_number = $1 FOR UPDATE; @@ -11182,6 +11886,22 @@ app.post("/api/projects", requireUser, async (req, res) => { [effectiveTicketNumber] ); + if (existingTicket.rows[0] && !await userCanManageTicket(client, existingTicket.rows[0].id, userId)) { + return "not-owner" as const; + } + + if ( + existingTicket.rows[0]?.zammad_ticket_id + && ( + effectiveTicketNumber !== existingTicket.rows[0].ticket_number + || (title ?? null) !== (existingTicket.rows[0].title ?? null) + || (requestOrganizationId !== null && String(requestOrganizationId) !== existingTicket.rows[0].organization_id) + || existingTicket.rows[0].work_type !== "consulting" + ) + ) { + return "zammad-managed-ticket" as const; + } + const organizationId = existingTicket.rows[0]?.organization_id ? Number(existingTicket.rows[0].organization_id) : requestOrganizationId; if (!organizationId) { @@ -11196,22 +11916,24 @@ app.post("/api/projects", requireUser, async (req, res) => { const ticketResult = await client.query<{ id: string }>( ` - INSERT INTO tickets (ticket_number, title, organization_id, customer_name, work_type) - VALUES ($1, $2, $3, $4, 'consulting') + INSERT INTO tickets (user_id, ticket_number, title, organization_id, customer_name, work_type) + VALUES ($5, $1, $2, $3, $4, 'consulting') ON CONFLICT (ticket_number) DO UPDATE SET - title = COALESCE(EXCLUDED.title, tickets.title), - organization_id = EXCLUDED.organization_id, - customer_name = EXCLUDED.customer_name, - work_type = 'consulting' + user_id = COALESCE(tickets.user_id, EXCLUDED.user_id), + title = CASE WHEN tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN COALESCE(EXCLUDED.title, tickets.title) ELSE tickets.title END, + organization_id = CASE WHEN tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN EXCLUDED.organization_id ELSE tickets.organization_id END, + customer_name = CASE WHEN tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN EXCLUDED.customer_name ELSE tickets.customer_name END, + work_type = CASE WHEN tickets.user_id IS NULL OR tickets.user_id = EXCLUDED.user_id THEN 'consulting' ELSE tickets.work_type END RETURNING id; `, - [effectiveTicketNumber, title, organization.id, organization.name] + [effectiveTicketNumber, title, organization.id, organization.name, userId] ); const ticketId = ticketResult.rows[0].id; + const projectOwnerMapping = zammadTicket ? await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId) : null; const projectOwnerUserId = zammadTicket - ? await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId) + ? projectOwnerMapping?.status === "ambiguous" ? userId : projectOwnerMapping?.userId ?? null : userId; await client.query( @@ -11232,10 +11954,11 @@ app.post("/api/projects", requireUser, async (req, res) => { teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, - closeReviewRequired: zammadTicket.zammadStateId === 4 + closeReviewRequired: zammadTicket.zammadStateId === 4, + zammadOwnerId: zammadTicket.zammadOwnerId }); - return getProjectRowForTicket(client, ticketId, projectOwnerUserId, userId); + return getProjectRowForTicket(client, ticketId, projectOwnerUserId, userId, zammadTicket.zammadOwnerId); } const projectResult = await client.query( @@ -11358,6 +12081,16 @@ app.post("/api/projects", requireUser, async (req, res) => { return; } + if (created === "not-owner") { + res.status(403).json({ error: "Nur der Hauptuser kann Projektdetails ändern" }); + return; + } + + if (created === "zammad-managed-ticket") { + res.status(409).json({ error: "Ticketnummer, Titel, Art und Organisation kommen aus Zammad und können in TT nicht geändert werden." }); + return; + } + res.status(201).json({ project: created }); } catch (error: any) { if (error?.code === "23505") { @@ -11391,12 +12124,44 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { try { const updated = await withTransaction(async (client) => { - const projectResult = await client.query<{ id: string; ticket_id: string }>( + const projectResult = await client.query<{ + id: string; + ticket_id: string; + ticket_number: string; + title: string | null; + organization_id: string | null; + work_type: "support" | "consulting" | null; + zammad_ticket_id: string | null; + }>( ` - SELECT id, ticket_id - FROM projects - WHERE id = $1 - AND user_id = $2 + SELECT + p.id, + p.ticket_id, + t.ticket_number, + t.title, + t.organization_id::text AS organization_id, + t.work_type, + t.zammad_ticket_id::text AS zammad_ticket_id + FROM projects p + JOIN tickets t ON t.id = p.ticket_id + WHERE p.id = $1 + AND ( + t.user_id = $2 + OR EXISTS ( + SELECT 1 + FROM users viewer + WHERE viewer.id = $2 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $2 + ) + ) FOR UPDATE; `, [projectId, userId] @@ -11406,6 +12171,25 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { return null; } + if (!await userCanManageTicket(client, projectResult.rows[0].ticket_id, userId)) { + return "not-owner" as const; + } + + const projectTicket = projectResult.rows[0]; + const zammadManagedTicket = Boolean(projectTicket.zammad_ticket_id); + + if ( + zammadManagedTicket + && ( + ticketNumber !== projectTicket.ticket_number + || (title ?? null) !== (projectTicket.title ?? null) + || String(organizationId) !== projectTicket.organization_id + || projectTicket.work_type !== "consulting" + ) + ) { + return "zammad-managed-ticket" as const; + } + const currentTeamspaceEntries = await client.query<{ id: string; hour_budget_minutes: number | null }>( ` SELECT id::text AS id, hour_budget_minutes @@ -11424,30 +12208,32 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { return "organization-not-found" as const; } - await client.query( - ` - UPDATE tickets - SET ticket_number = $1, - title = $2, - organization_id = $3, - customer_name = $4, - work_type = 'consulting' - WHERE id = $5; - `, - [ticketNumber, title, organization.id, organization.name, projectResult.rows[0].ticket_id] - ); + if (!zammadManagedTicket) { + await client.query( + ` + UPDATE tickets + SET ticket_number = $1, + title = $2, + organization_id = $3, + customer_name = $4, + work_type = 'consulting' + WHERE id = $5; + `, + [ticketNumber, title, organization.id, organization.name, projectResult.rows[0].ticket_id] + ); - await client.query( - ` - UPDATE sessions - SET organization_id = $1, - customer_name = $2, - work_type = CASE WHEN recurring_billing_id IS NULL THEN 'consulting' ELSE work_type END - WHERE ticket_id = $3 - AND user_id = $4; - `, - [organization.id, organization.name, projectResult.rows[0].ticket_id, userId] - ); + await client.query( + ` + UPDATE sessions + SET organization_id = $1, + customer_name = $2, + work_type = CASE WHEN recurring_billing_id IS NULL THEN 'consulting' ELSE work_type END + WHERE ticket_id = $3 + AND user_id = $4; + `, + [organization.id, organization.name, projectResult.rows[0].ticket_id, userId] + ); + } const result = await client.query( ` @@ -11603,6 +12389,16 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { return; } + if (updated === "not-owner") { + res.status(403).json({ error: "Nur der Hauptuser kann Projektdetails ändern" }); + return; + } + + if (updated === "zammad-managed-ticket") { + res.status(409).json({ error: "Ticketnummer, Titel, Art und Organisation kommen aus Zammad und können in TT nicht geändert werden." }); + return; + } + if (!updated) { res.status(404).json({ error: "Projekt nicht gefunden" }); return; @@ -11624,24 +12420,50 @@ app.patch("/api/projects/:projectId/review", requireUser, async (req, res) => { 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] - ); + const result = await withTransaction(async (client) => { + const projectResult = await client.query<{ id: string; ticket_id: string }>( + ` + SELECT id::text, ticket_id::text + FROM projects + WHERE id = $1 + FOR UPDATE; + `, + [projectId] + ); - if (result.rowCount === 0) { + if (projectResult.rowCount === 0) { + return null; + } + + if (!await userCanManageTicket(client, projectResult.rows[0].ticket_id, userId)) { + return "not-owner" as const; + } + + const updateResult = await client.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 + RETURNING id::text, ticket_id::text, close_review_required; + `, + [projectId, userId, acknowledged] + ); + + return updateResult.rows[0] ?? null; + }); + + if (!result) { res.status(404).json({ error: "Projekt nicht gefunden" }); return; } - res.json({ project: result.rows[0] }); + if (result === "not-owner") { + res.status(403).json({ error: "Nur der Hauptuser kann Projektdetails ändern" }); + return; + } + + res.json({ project: result }); }); app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { @@ -11649,25 +12471,51 @@ app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { const userId = currentUser(req).id; const closed = optionalBoolean(req.body.closed, false); - const result = await query<{ id: string; ticket_id: string; closed_at: string | null }>( - ` - UPDATE projects - SET closed_at = CASE WHEN $3::boolean THEN now() ELSE NULL END, - close_review_required = CASE WHEN $3::boolean THEN false ELSE close_review_required END, - updated_at = now() - WHERE id = $1 - AND user_id = $2 - RETURNING id::text, ticket_id::text, closed_at; - `, - [projectId, userId, closed] - ); + const result = await withTransaction(async (client) => { + const projectResult = await client.query<{ id: string; ticket_id: string }>( + ` + SELECT id::text, ticket_id::text + FROM projects + WHERE id = $1 + FOR UPDATE; + `, + [projectId] + ); - if (result.rowCount === 0) { + if (projectResult.rowCount === 0) { + return null; + } + + if (!await userCanManageTicket(client, projectResult.rows[0].ticket_id, userId)) { + return "not-owner" as const; + } + + const updateResult = await client.query<{ id: string; ticket_id: string; closed_at: string | null }>( + ` + UPDATE projects + SET closed_at = CASE WHEN $3::boolean THEN now() ELSE NULL END, + close_review_required = CASE WHEN $3::boolean THEN false ELSE close_review_required END, + updated_at = now() + WHERE id = $1 + RETURNING id::text, ticket_id::text, closed_at; + `, + [projectId, userId, closed] + ); + + return updateResult.rows[0] ?? null; + }); + + if (!result) { res.status(404).json({ error: "Projekt nicht gefunden" }); return; } - res.json({ project: result.rows[0] }); + if (result === "not-owner") { + res.status(403).json({ error: "Nur der Hauptuser kann Projekte schließen" }); + return; + } + + res.json({ project: result }); }); app.post("/api/projects/:projectId/hide", requireUser, async (req, res) => { @@ -11752,6 +12600,36 @@ app.delete("/api/projects/:projectId", requireUser, async (req, res) => { return null; } + if (!await userCanManageTicket(client, projectResult.rows[0].ticket_id, userId)) { + return "not-owner" as const; + } + + const otherUserSessionResult = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM sessions + WHERE ticket_id = $1 + AND user_id <> $2 + ) AS exists; + `, + [projectResult.rows[0].ticket_id, userId] + ); + + if (otherUserSessionResult.rows[0]?.exists) { + await client.query( + ` + INSERT INTO hidden_user_projects (user_id, project_id) + VALUES ($1, $2) + ON CONFLICT (user_id, project_id) + DO UPDATE SET hidden_at = now(); + `, + [userId, projectId] + ); + + return { ...projectResult.rows[0], hidden: true }; + } + await deleteProjectDataForTicket(client, projectResult.rows[0].ticket_id, userId); await cleanupEmptyTickets(client, [projectResult.rows[0].ticket_id]); @@ -11763,6 +12641,11 @@ app.delete("/api/projects/:projectId", requireUser, async (req, res) => { return; } + if (result === "not-owner") { + res.status(403).json({ error: "Nur der Hauptuser kann Projekte löschen" }); + return; + } + res.json({ deleted: result }); }); @@ -11825,9 +12708,7 @@ app.post("/api/tickets/:ticketId/todos", requireUser, async (req, res) => { throw badRequest("endsOn must be after startsOn"); } - const ticketResult = await query<{ id: string }>("SELECT id FROM tickets WHERE id = $1;", [ticketId]); - - if (ticketResult.rowCount === 0) { + if (!await userCanViewTicket({ query }, ticketId, userId)) { res.status(404).json({ error: "Ticket nicht gefunden" }); return; } @@ -12026,6 +12907,28 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { return "organization-not-found" as const; } + if (!await userCanManageTicket(client, ticketId, userId)) { + return "not-owner" as const; + } + + const currentTicketResult = await client.query<{ zammad_ticket_id: string | null }>( + ` + SELECT zammad_ticket_id::text AS zammad_ticket_id + FROM tickets + WHERE id = $1 + FOR UPDATE; + `, + [ticketId] + ); + + if (currentTicketResult.rowCount === 0) { + return null; + } + + if (currentTicketResult.rows[0].zammad_ticket_id) { + return "zammad-managed-ticket" as const; + } + const projectsResult = await client.query<{ id: string; user_id: string }>( ` SELECT id::text AS id, user_id::text AS user_id @@ -12078,9 +12981,10 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { SET organization_id = $1, customer_name = $2, work_type = CASE WHEN recurring_billing_id IS NULL THEN $3 ELSE work_type END - WHERE ticket_id = $4; + WHERE ticket_id = $4 + AND user_id = $5; `, - [organization.id, organization.name, workType, ticketId] + [organization.id, organization.name, workType, ticketId, userId] ); return ticketResult.rows[0]; @@ -12101,6 +13005,16 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { return; } + if (result === "not-owner") { + res.status(403).json({ error: "Nur der Hauptuser kann Ticketdetails ändern" }); + return; + } + + if (result === "zammad-managed-ticket") { + res.status(409).json({ error: "Ticketnummer, Titel, Art und Organisation kommen aus Zammad und können in TT nicht geändert werden." }); + return; + } + if (!result) { res.status(404).json({ error: "Ticket not found" }); return; @@ -12395,142 +13309,6 @@ app.use("/api/months", (_req, res) => { res.status(410).json({ error: "Legacy month API removed. Use /api/periods/months instead." }); }); -app.get("/api/months/:month/overview", requireUser, async (req, res) => { - const month = parseMonth(String(req.params.month)); - - const [ticketsResult, openResult, monthClosureResult] = await Promise.all([ - query( - ` - SELECT - t.id, - t.ticket_number, - t.customer_name, - t.work_type, - COUNT(s.id)::int AS session_count, - COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes, - COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable')::int AS open_count, - COUNT(*) FILTER (WHERE s.billing_status = 'billable')::int AS billable_count, - COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count, - COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count, - tm.closed_at IS NOT NULL AS closed, - tm.closed_at - FROM tickets t - JOIN sessions s ON s.ticket_id = t.id - LEFT JOIN ticket_month_closures tm ON tm.ticket_id = t.id AND tm.month = $1::date - WHERE s.started_at >= $2::timestamptz AND s.started_at < $3::timestamptz - GROUP BY t.id, t.ticket_number, t.customer_name, t.work_type, tm.closed_at - ORDER BY t.ticket_number ASC; - `, - [month.start, month.startIso, month.endIso] - ), - query( - ` - SELECT - s.id, - s.ticket_id, - t.ticket_number, - s.customer_name, - s.activity, - s.work_type, - s.started_at, - s.rounded_minutes - FROM sessions s - JOIN tickets t ON t.id = s.ticket_id - WHERE s.started_at >= $1::timestamptz - AND s.started_at < $2::timestamptz - AND (s.billing_status IS NULL OR s.billing_status = 'billable') - ORDER BY s.started_at ASC; - `, - [month.startIso, month.endIso] - ), - query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date;", [month.start]) - ]); - - const tickets = ticketsResult.rows; - const totalSessions = tickets.reduce((sum: number, ticket: any) => sum + ticket.session_count, 0); - const totalMinutes = tickets.reduce((sum: number, ticket: any) => sum + ticket.total_minutes, 0); - const openSessions = openResult.rows; - const allTicketsClosed = tickets.length > 0 && tickets.every((ticket: any) => ticket.closed); - - res.json({ - month: month.label, - monthClosed: monthClosureResult.rows.length > 0, - monthClosedAt: monthClosureResult.rows[0]?.closed_at ?? null, - canCloseMonth: tickets.length > 0 && allTicketsClosed && openSessions.length === 0, - totals: { - tickets: tickets.length, - sessions: totalSessions, - minutes: totalMinutes, - openSessions: openSessions.length - }, - tickets, - openSessions - }); -}); - -app.get("/api/months/:month/tickets/:ticketId", requireUser, async (req, res) => { - const month = parseMonth(String(req.params.month)); - const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); - - const ticketResult = await query( - ` - SELECT - t.id, - t.ticket_number, - t.customer_name, - t.work_type, - tm.closed_at, - mc.closed_at AS month_closed_at - FROM tickets t - LEFT JOIN ticket_month_closures tm ON tm.ticket_id = t.id AND tm.month = $1::date - LEFT JOIN month_closures mc ON mc.month = $1::date - WHERE t.id = $2; - `, - [month.start, ticketId] - ); - - if (ticketResult.rowCount === 0) { - res.status(404).json({ error: "Ticket not found" }); - return; - } - - const sessionsResult = await query( - ` - SELECT - id, - customer_name, - activity, - work_type, - started_at, - ended_at, - duration_seconds, - rounded_minutes, - billing_status, - billing_updated_at, - created_at - FROM sessions - WHERE ticket_id = $1 - AND started_at >= $2::timestamptz - AND started_at < $3::timestamptz - ORDER BY started_at ASC; - `, - [ticketId, month.startIso, month.endIso] - ); - - const sessions = sessionsResult.rows; - const openCount = sessions.filter((session: any) => session.billing_status === null || session.billing_status === "billable").length; - - res.json({ - month: month.label, - ticket: ticketResult.rows[0], - monthClosed: Boolean(ticketResult.rows[0].month_closed_at), - monthClosedAt: ticketResult.rows[0].month_closed_at ?? null, - sessions, - canClose: sessions.length > 0 && openCount === 0, - openCount - }); -}); - app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => { const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); const billingStatus = parseBillingStatus(req.body.billingStatus); @@ -12688,7 +13466,8 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { ); if (Number(projectCountResult.rows[0]?.count ?? 0) === 0) { - await ensureProjectForTicket(client, current.ticket_id, userId); + const ownerUserId = await ticketOwnerUserId(client, current.ticket_id, userId) ?? userId; + await ensureProjectForTicket(client, current.ticket_id, ownerUserId); } projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( @@ -12736,7 +13515,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { ] ); - if (effectiveWorkType === "consulting") { + if (effectiveWorkType === "consulting" && await userCanManageTicket(client, current.ticket_id, userId)) { await client.query( ` UPDATE tickets @@ -12762,14 +13541,16 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { if (Number(remainingConsultingResult.rows[0]?.count ?? 0) === 0) { await deleteProjectDataForTicket(client, current.ticket_id, userId); - await client.query( - ` - UPDATE tickets - SET work_type = 'support' - WHERE id = $1; - `, - [current.ticket_id] - ); + if (await userCanManageTicket(client, current.ticket_id, userId)) { + await client.query( + ` + UPDATE tickets + SET work_type = 'support' + WHERE id = $1; + `, + [current.ticket_id] + ); + } } } @@ -12928,142 +13709,6 @@ app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => { res.json({ deleted }); }); -app.post("/api/months/:month/tickets/:ticketId/close", requireUser, async (req, res) => { - const month = parseMonth(String(req.params.month)); - const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); - - const statusResult = await query( - ` - SELECT - COUNT(*)::int AS session_count, - COUNT(*) FILTER (WHERE billing_status IS NULL OR billing_status = 'billable')::int AS open_count - FROM sessions - WHERE ticket_id = $1 - AND started_at >= $2::timestamptz - AND started_at < $3::timestamptz; - `, - [ticketId, month.startIso, month.endIso] - ); - - const status = statusResult.rows[0] as { session_count: number; open_count: number }; - - if (status.session_count === 0) { - res.status(404).json({ error: "No sessions found for this ticket and month" }); - return; - } - - if (status.open_count > 0) { - res.status(409).json({ error: "Ticket has open sessions", openCount: status.open_count }); - return; - } - - const closureResult = await query( - ` - INSERT INTO ticket_month_closures (ticket_id, month) - VALUES ($1, $2::date) - ON CONFLICT (ticket_id, month) - DO UPDATE SET closed_at = now() - RETURNING *; - `, - [ticketId, month.start] - ); - - res.json({ closure: closureResult.rows[0] }); -}); - -app.post("/api/months/:month/tickets/:ticketId/reopen", requireUser, async (req, res) => { - const month = parseMonth(String(req.params.month)); - const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); - - const result = await withTransaction(async (client) => { - const ticketClosureResult = await client.query( - ` - DELETE FROM ticket_month_closures - WHERE ticket_id = $1 AND month = $2::date - RETURNING *; - `, - [ticketId, month.start] - ); - - const monthClosureResult = await client.query( - ` - DELETE FROM month_closures - WHERE month = $1::date - RETURNING *; - `, - [month.start] - ); - - return { - ticketReopened: ticketClosureResult.rows.length > 0, - monthReopened: monthClosureResult.rows.length > 0 - }; - }); - - res.json(result); -}); - -app.post("/api/months/:month/close", requireUser, async (req, res) => { - const month = parseMonth(String(req.params.month)); - - const blockersResult = await query( - ` - SELECT - t.id AS ticket_id, - t.ticket_number, - COUNT(s.id)::int AS session_count, - COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable')::int AS open_count, - tm.closed_at IS NOT NULL AS ticket_closed - FROM tickets t - JOIN sessions s ON s.ticket_id = t.id - LEFT JOIN ticket_month_closures tm ON tm.ticket_id = t.id AND tm.month = $1::date - WHERE s.started_at >= $2::timestamptz AND s.started_at < $3::timestamptz - GROUP BY t.id, t.ticket_number, tm.closed_at - HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable') > 0 OR tm.closed_at IS NULL - ORDER BY t.ticket_number ASC; - `, - [month.start, month.startIso, month.endIso] - ); - - if (blockersResult.rows.length > 0) { - res.status(409).json({ - error: "Month has unfinished tickets", - blockers: blockersResult.rows - }); - return; - } - - const result = await query( - ` - INSERT INTO month_closures (month) - VALUES ($1::date) - ON CONFLICT (month) - DO UPDATE SET closed_at = now() - RETURNING *; - `, - [month.start] - ); - - res.json({ closure: result.rows[0] }); -}); - -app.post("/api/months/:month/reopen", requireUser, async (req, res) => { - const month = parseMonth(String(req.params.month)); - - const result = await query( - ` - DELETE FROM month_closures - WHERE month = $1::date - RETURNING *; - `, - [month.start] - ); - - res.json({ - reopened: result.rows.length > 0 - }); -}); - if (serveFrontend) { app.use(express.static(frontendDist)); app.get(/.*/, (_req, res) => { diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index a89c7b1..33789f4 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -206,6 +206,7 @@ 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 user_id BIGINT REFERENCES users(id) ON DELETE SET NULL;"); 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;"); @@ -225,6 +226,55 @@ export async function migrate() { END IF; END $$; `); + await query(` + WITH unique_zammad_users AS ( + SELECT zammad_user_id, MIN(id) AS user_id + FROM users + WHERE role = 'user' + AND active + AND zammad_user_id IS NOT NULL + GROUP BY zammad_user_id + HAVING COUNT(*) = 1 + ), + zammad_ticket_owners AS ( + SELECT t.id, uzu.user_id + FROM tickets t + LEFT JOIN unique_zammad_users uzu ON uzu.zammad_user_id = t.zammad_owner_id + WHERE t.zammad_owner_id IS NOT NULL + ) + UPDATE tickets t + SET user_id = zto.user_id + FROM zammad_ticket_owners zto + WHERE t.id = zto.id + AND t.user_id IS DISTINCT FROM zto.user_id; + `); + await query(` + UPDATE tickets t + SET user_id = owner.user_id + FROM ( + SELECT ticket_id, MIN(user_id) AS user_id + FROM projects + GROUP BY ticket_id + ) owner + WHERE t.user_id IS NULL + AND t.zammad_owner_id IS NULL + AND t.id = owner.ticket_id; + `); + await query(` + UPDATE tickets t + SET user_id = owner.user_id + FROM ( + SELECT ticket_id, MIN(user_id) AS user_id + FROM sessions + WHERE user_id IS NOT NULL + GROUP BY ticket_id + ) owner + WHERE t.user_id IS NULL + AND t.zammad_owner_id IS NULL + AND t.id = owner.ticket_id; + `); + await query("CREATE INDEX IF NOT EXISTS idx_tickets_user ON tickets(user_id);"); + await query("CREATE INDEX IF NOT EXISTS idx_tickets_zammad_owner ON tickets(zammad_owner_id);"); await query(` CREATE TABLE IF NOT EXISTS sessions ( @@ -708,6 +758,43 @@ export async function migrate() { await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;"); await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS close_review_required BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;"); + await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;"); + await query("ALTER TABLE projects ALTER COLUMN user_id DROP NOT NULL;"); + await query(` + UPDATE projects p + SET zammad_owner_id = t.zammad_owner_id + FROM tickets t + WHERE p.ticket_id = t.id + AND p.zammad_owner_id IS NULL + AND t.zammad_owner_id IS NOT NULL; + `); + await query(` + UPDATE projects p + SET user_id = t.user_id, + zammad_owner_id = t.zammad_owner_id + FROM tickets t + WHERE p.ticket_id = t.id + AND t.zammad_owner_id IS NOT NULL + AND ( + p.user_id IS DISTINCT FROM t.user_id + OR p.zammad_owner_id IS DISTINCT FROM t.zammad_owner_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM projects existing + WHERE existing.ticket_id = p.ticket_id + AND existing.id <> p.id + AND ( + (t.user_id IS NOT NULL AND existing.user_id = t.user_id) + OR ( + t.user_id IS NULL + AND existing.user_id IS NULL + AND existing.zammad_owner_id IS NOT DISTINCT FROM t.zammad_owner_id + ) + ) + ); + `); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_zammad_owner_ticket ON projects(zammad_owner_id, ticket_id) WHERE user_id IS NULL AND zammad_owner_id IS NOT NULL;"); await query(` DO $$ BEGIN @@ -799,13 +886,26 @@ export async function migrate() { `); await query(` - INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals) - SELECT DISTINCT s.user_id, s.ticket_id, COALESCE(t.title, ''), '', '' + INSERT INTO projects (user_id, zammad_owner_id, ticket_id, teamspace_project, description, goals) + SELECT DISTINCT + CASE + WHEN t.zammad_owner_id IS NOT NULL THEN zammad_owner_user.id + ELSE s.user_id + END, + t.zammad_owner_id, + s.ticket_id, + COALESCE(t.title, ''), + '', + '' FROM sessions s JOIN tickets t ON t.id = s.ticket_id + LEFT JOIN users zammad_owner_user + ON zammad_owner_user.role = 'user' + AND zammad_owner_user.active + AND zammad_owner_user.zammad_user_id = t.zammad_owner_id WHERE s.work_type = 'consulting' AND s.user_id IS NOT NULL - ON CONFLICT (user_id, ticket_id) DO NOTHING; + ON CONFLICT DO NOTHING; `); await query(` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f21111d..160a5f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1531,7 +1531,7 @@ export function App() { - v1.0.10-rc1 + v1.0.11-rc1 diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b91e4de..3d34a1b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -140,6 +140,11 @@ export type TicketMeta = { zammad_priority_id?: string | null; zammad_state_id?: string | null; zammad_owner_id?: string | null; + zammad_owner_user_id?: string | null; + zammad_owner_display_name?: string | null; + zammad_owner_username?: string | null; + is_foreign_zammad_owner?: boolean; + can_edit_ticket_data?: boolean; project_id?: string | null; is_project?: boolean; project_name?: string | null; @@ -184,6 +189,9 @@ export type ProjectSummary = { work_type: WorkType | null; zammad_priority_id?: string | null; zammad_state_id?: string | null; + zammad_ticket_id?: string | null; + zammad_owner_id?: string | null; + ticket_zammad_owner_id?: string | null; zammad_pending_time?: string | null; zammad_merged_from_ticket_id?: string | null; zammad_merged_from_ticket_number?: string | null; @@ -376,8 +384,16 @@ export type TicketPeriod = { organization_name: string | null; customer_name: string | null; work_type: WorkType | null; + zammad_ticket_id?: string | null; + zammad_group_id?: string | null; zammad_priority_id?: string | null; zammad_state_id?: string | null; + zammad_owner_id?: string | null; + zammad_owner_user_id?: string | null; + zammad_owner_display_name?: string | null; + zammad_owner_username?: string | null; + is_foreign_zammad_owner?: boolean; + can_edit_ticket_data?: boolean; closed_at: string | null; requires_closure: boolean; recurring_session_count: number; diff --git a/frontend/src/views/ProjectsPage.tsx b/frontend/src/views/ProjectsPage.tsx index ce37931..94815e3 100644 --- a/frontend/src/views/ProjectsPage.tsx +++ b/frontend/src/views/ProjectsPage.tsx @@ -1664,7 +1664,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly); const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly); const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1; - const canEditProject = project.can_edit !== false; + const canEditProject = project.can_edit === true; return (
@@ -1797,7 +1797,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { variant="outline" size="sm" className="h-7 shrink-0 px-2 text-xs" - disabled={savingProjectStatusId === project.id || project.can_edit === false} + disabled={savingProjectStatusId === project.id || !canEditProject} onClick={() => void acknowledgeProjectReviewCheck(project)} > diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index f29b051..78f7258 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -912,8 +912,17 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS const projectBudgetMinutes = project?.hour_budget_minutes ?? null; const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null; const projectRemainingMinutes = projectBudgetMinutes && projectBudgetMinutes > 0 ? projectBudgetMinutes - projectConsumptionMinutes : null; - const canEditProject = project?.can_edit !== false; - const canEditTicketData = !project || canEditProject; + const canEditProject = project?.can_edit === true; + const ticketManagedByZammad = Boolean(ticket?.zammad_ticket_id); + const canEditTicketData = ticket?.can_edit_ticket_data === true; + const zammadOwnerLabel = ticket?.zammad_owner_display_name + || ticket?.zammad_owner_username + || (ticket?.zammad_owner_id ? `Zammad-Owner ${ticket.zammad_owner_id}` : null); + const projectOwnerLabel = project?.owner_display_name + || project?.owner_username + || (project?.zammad_owner_id ? `Zammad-Owner ${project.zammad_owner_id}` : null) + || zammadOwnerLabel; + const currentZammadUserLabel = currentUser.zammad_user_id ? `Zammad-User ${currentUser.zammad_user_id}` : "dein Zammad-User"; const isProject = Boolean(project); const isGlobalTicket = periodType === "all"; const isPeriodClosed = Boolean(data?.closed); @@ -1045,6 +1054,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS const periodTeamspaceMinutes = (data?.dayBillings ?? []).reduce((sum, billing) => sum + Number(billing.billed_minutes ?? 0), 0); const ticketDataDirty = Boolean( ticket && + canEditTicketData && (editTicketNumber !== ticket.ticket_number || editTicketTitle !== (ticket.title ?? "") || editOrganizationId !== (ticket.organization_id ?? "") || @@ -1284,8 +1294,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS } if (!canEditTicketData) { - toast.error("Projekt ist geteilt", { - description: "Nur der Zammad-Owner kann Ticket- und Projektdaten ändern." + toast.error(ticketManagedByZammad ? "Ticketdaten kommen aus Zammad" : "Ticket gehört einem anderen Owner", { + description: ticketManagedByZammad + ? "Nummer, Art, Titel und Organisation werden durch den Zammad-Sync verwaltet." + : "Nur der Owner kann Ticketdaten ändern." }); return; } @@ -1334,7 +1346,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS return; } - if (project.can_edit === false) { + if (!canEditProject) { toast.error("Projekt ist geteilt", { description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten." }); @@ -1352,7 +1364,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS return; } - if (project.can_edit === false) { + if (!canEditProject) { toast.error("Projekt ist geteilt", { description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten." }); @@ -1407,7 +1419,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS return; } - if (project.can_edit === false) { + if (!canEditProject) { toast.error("Projekt ist geteilt", { description: "Nur der Zammad-Owner kann den Projektstatus ändern." }); @@ -1433,7 +1445,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS return; } - if (project.can_edit === false) { + if (!canEditProject) { toast.error("Projekt ist geteilt", { description: "Nur der Zammad-Owner kann die Prüfung bestätigen." }); @@ -1811,6 +1823,26 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS ) : null} + {ticket?.is_foreign_zammad_owner ? ( + + + Anderer Zammad-Owner + + Dieses Ticket gehört in Zammad {zammadOwnerLabel ? `${zammadOwnerLabel}` : "einem anderen Owner"}; du bist {currentZammadUserLabel}. Du kannst deine Sessions sehen, Projekt- und Ticketdaten sind für dich geschützt. + + + ) : null} + + {ticketManagedByZammad ? ( + + + Ticketdaten werden von Zammad verwaltet + + Nummer, Art, Titel und Organisation wurden durch den Zammad-Sync übernommen und können in TicketTracker nicht manuell geändert werden. + + + ) : null} +
@@ -1820,7 +1852,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS {canEditTicketData ? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen." - : "Dieses Projekt ist geteilt. Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."} + : ticketManagedByZammad + ? "Nummer, Art, Titel und Organisation kommen aus Zammad und sind lokal gesperrt." + : "Nur der Owner kann Ticketdaten ändern."}
@@ -1891,8 +1925,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS {(project.unassigned_teamspace_session_count ?? 0) > 0 ? {project.unassigned_teamspace_session_count} ohne TS : null} - {!canEditProject && project.owner_display_name - ? `Eigentümer: ${project.owner_display_name}` + {!canEditProject && projectOwnerLabel + ? `Eigentümer: ${projectOwnerLabel}` : showBudgetedProjectEntries ? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge` : budgetedProjectEntries[0] ? teamspaceEntryDisplayLabel(budgetedProjectEntries[0]) : project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"} @@ -1944,7 +1978,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS variant="outline" size="sm" className="shrink-0" - disabled={savingProjectStatus || project.can_edit === false} + disabled={savingProjectStatus || !canEditProject} onClick={() => void acknowledgeProjectReviewCheck()} > diff --git a/frontend/src/views/ZammadSyncPage.tsx b/frontend/src/views/ZammadSyncPage.tsx index db8eaf5..a045dd7 100644 --- a/frontend/src/views/ZammadSyncPage.tsx +++ b/frontend/src/views/ZammadSyncPage.tsx @@ -659,6 +659,25 @@ function addCachedChange(changes: ZammadSyncChange[], field: string, from: unkno } } +const preservedExistingPreviewChangeFields = new Set([ + "Fremdes Ticket", + "Fremder Zammad-Owner", + "Owner", + "Ticket-Zuordnung", + "Hinweis", + "Ticketnummer", + "TicketTracker-Zusammenführung" +]); + +function preserveExistingPreviewChanges(item: ZammadSyncItem, changes: ZammadSyncChange[], fields: ZammadSyncExistingFields) { + const existingFields = new Set(changes.map((change) => change.field)); + + return [ + ...item.changes.filter((change) => preservedExistingPreviewChangeFields.has(change.field) && !existingFields.has(change.field) && changeAllowedByFields(item, change, fields)), + ...changes + ]; +} + function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyncExistingFields) { if (!item.zammad) { return item.changes.filter((change) => changeAllowedByFields(item, change, fields)); @@ -756,7 +775,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn } } - return changes; + return preserveExistingPreviewChanges(item, changes, fields); } function rebuildZammadSyncResultFromItems(mode: ZammadSyncResult["mode"], items: ZammadSyncItem[], checked = items.length): ZammadSyncResult {