From 07ef8ef0252b5364fba0260ca1e325b4994d37bf Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 9 Sep 2026 12:55:56 +0200 Subject: [PATCH] Weiter --- .env_dev | 5 + backend/src/index.ts | 1009 +++++++++++++++++++++-- backend/src/migrations.ts | 59 ++ frontend/src/api.ts | 18 +- frontend/src/types.ts | 20 + frontend/src/views/ProjectsPage.tsx | 101 ++- frontend/src/views/TicketDetailPage.tsx | 197 ++++- frontend/src/views/TimerPage.tsx | 216 ++++- scripts/install-local-dependencies.sh | 88 ++ scripts/start-local.sh | 4 +- 10 files changed, 1624 insertions(+), 93 deletions(-) create mode 100644 .env_dev create mode 100755 scripts/install-local-dependencies.sh diff --git a/.env_dev b/.env_dev new file mode 100644 index 0000000..b632e20 --- /dev/null +++ b/.env_dev @@ -0,0 +1,5 @@ +DATABASE_URL=postgresql://ticket:ticket@10.10.10.21:5433/tickettracker_dev +APP_PORT=3910 +TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest +COOKIE_SECURE=false +DB_POOL_SIZE=10 diff --git a/backend/src/index.ts b/backend/src/index.ts index 846b111..7be002e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3,6 +3,7 @@ import cors from "cors"; import express from "express"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import type pg from "pg"; import { migrate } from "./migrations.js"; import { pool, query, withTransaction } from "./db.js"; import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js"; @@ -97,6 +98,14 @@ function parseOptionalBilledMinutes(value: unknown) { return minutes; } +function parseOptionalTeamspaceEntryId(value: unknown) { + if (value === undefined || value === null || value === "") { + return null; + } + + return parsePositiveInteger(value, "projectTeamspaceEntryId"); +} + function optionalTicketSessionMonth(value: unknown) { if (value === undefined || value === null || value === "" || value === "all") { return null; @@ -159,6 +168,10 @@ type OrganizationRow = { type ProjectRow = { id: string; + user_id?: string; + owner_username?: string | null; + owner_display_name?: string | null; + can_edit?: boolean; ticket_id: string; ticket_number: string; title: string | null; @@ -202,6 +215,25 @@ type ProjectWithEntries = ProjectRow & { const zammadBaseUrlSettingKey = "zammad.base_url"; const zammadApiKeySettingKey = "zammad.api_key"; +type QueryClient = { + query: (text: string, params?: unknown[]) => Promise<{ rows: T[]; rowCount: number | null }>; +}; + +type ZammadTicketImport = { + zammadTicketId: number; + ticketNumber: string; + zammadGroupId: number | null; + zammadPriorityId: number | null; + zammadStateId: number | null; + organizationId: number; + title: string | null; + zammadOwnerId: number | null; + workType: "support" | "consulting" | null; + preferences: Record; + teamspaceBudgetMinutes: number | null; + teamspaceProject: string | null; +}; + async function getAppSetting(key: string) { const result = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [key]); return result.rows[0]?.value ?? null; @@ -249,6 +281,199 @@ async function fetchZammadUserIdForToken(apiToken: string) { return zammadUserId; } +function isPlainObject(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function numberOrNull(value: unknown) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : null; +} + +function zammadWorkType(groupId: number | null) { + if (groupId === 2) { + return "consulting" as const; + } + + if (groupId === 3) { + return "support" as const; + } + + return null; +} + +function parseZammadBudgetMinutes(value: unknown) { + if (value === null || value === undefined || value === "") { + return null; + } + + const raw = typeof value === "string" ? value.trim().replace(",", ".") : value; + const hours = Number(raw); + + if (!Number.isFinite(hours) || hours < 0) { + return null; + } + + return Math.round(hours * 60); +} + +function zammadPreferenceValue(ticket: Record, key: string) { + const preferences = isPlainObject(ticket.preferences) ? ticket.preferences : {}; + return preferences[key] ?? ticket[key]; +} + +async function getZammadCredentialsForUser(userId: string) { + const [savedBaseUrl, userResult] = await Promise.all([ + getAppSetting(zammadBaseUrlSettingKey), + query<{ zammad_api_token: string | null }>("SELECT zammad_api_token FROM users WHERE id = $1;", [userId]) + ]); + + if (!savedBaseUrl) { + throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden"); + } + + const apiToken = userResult.rows[0]?.zammad_api_token ?? null; + + if (!apiToken) { + throw badRequest("Zammad-Token muss zuerst im Profil hinterlegt werden"); + } + + return { + baseUrl: parseZammadBaseUrl(savedBaseUrl), + apiToken + }; +} + +async function fetchZammadJson(baseUrl: string, apiToken: string, path: string) { + const response = await fetch(`${baseUrl}${path}`, { + headers: { + Authorization: `Token token=${apiToken}`, + Accept: "application/json" + } + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw Object.assign(new Error(`Zammad Anfrage fehlgeschlagen (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 }); + } + + return response.json() as Promise; +} + +function zammadTicketCandidates(payload: unknown): Record[] { + if (Array.isArray(payload)) { + return payload.filter(isPlainObject); + } + + if (isPlainObject(payload)) { + if (Array.isArray(payload.tickets)) { + return payload.tickets.filter(isPlainObject); + } + + if (Array.isArray(payload.items)) { + return payload.items.filter(isPlainObject); + } + + return [payload]; + } + + return []; +} + +function ticketNumberMatchesZammadNumber(localTicketNumber: string, zammadNumber: unknown) { + if (typeof zammadNumber !== "string" && typeof zammadNumber !== "number") { + return false; + } + + const local = localTicketNumber.replace(/^Ticket#/i, ""); + const remote = String(zammadNumber).trim(); + + return remote === local || remote.replace(/^0+/, "") === local.replace(/^0+/, ""); +} + +async function fetchZammadTicketByNumber(userId: string, ticketNumber: string): Promise { + const { baseUrl, apiToken } = await getZammadCredentialsForUser(userId); + const zammadNumber = ticketNumber.replace(/^Ticket#/i, ""); + + for (const path of [ + `/api/v1/tickets/search?query=${encodeURIComponent(`number:${zammadNumber}`)}`, + `/api/v1/tickets?query=${encodeURIComponent(`number:${zammadNumber}`)}`, + "/api/v1/tickets" + ]) { + let payload: unknown; + + try { + payload = await fetchZammadJson(baseUrl, apiToken, path); + } catch (error: any) { + if (path === "/api/v1/tickets") { + throw error; + } + + continue; + } + + const candidate = zammadTicketCandidates(payload).find((ticket) => ticketNumberMatchesZammadNumber(ticketNumber, ticket.number)); + const zammadTicketId = numberOrNull(candidate?.id); + + if (zammadTicketId) { + const detail = await fetchZammadJson(baseUrl, apiToken, `/api/v1/tickets/${zammadTicketId}`); + const rawTicket = isPlainObject(detail) ? detail : candidate; + + if (!rawTicket) { + return null; + } + + const groupId = numberOrNull(rawTicket.group_id); + const organizationId = numberOrNull(rawTicket.organization_id); + const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {}; + + if (!organizationId || !ticketNumberMatchesZammadNumber(ticketNumber, rawTicket.number)) { + return null; + } + + return { + zammadTicketId, + ticketNumber, + zammadGroupId: groupId, + zammadPriorityId: numberOrNull(rawTicket.priority_id), + zammadStateId: numberOrNull(rawTicket.state_id), + organizationId, + title: typeof rawTicket.title === "string" && rawTicket.title.trim() ? rawTicket.title.trim() : null, + zammadOwnerId: numberOrNull(rawTicket.owner_id), + workType: zammadWorkType(groupId), + preferences, + teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")), + teamspaceProject: + typeof zammadPreferenceValue(rawTicket, "project") === "string" && String(zammadPreferenceValue(rawTicket, "project")).trim() + ? String(zammadPreferenceValue(rawTicket, "project")).trim() + : null + }; + } + } + + return null; +} + +async function projectOwnerUserIdForZammadOwner(client: QueryClient, zammadOwnerId: number | null, fallbackUserId: string) { + if (!zammadOwnerId) { + return fallbackUserId; + } + + const result = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM users + WHERE zammad_user_id = $1 + AND active + ORDER BY id ASC + LIMIT 1; + `, + [zammadOwnerId] + ); + + return result.rows[0]?.id ?? fallbackUserId; +} + async function getOrganizationByZammadId( client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> }, zammadId: number @@ -265,6 +490,75 @@ async function getOrganizationByZammadId( return result.rows[0] ?? null; } +async function importZammadTicketForUser(client: QueryClient, zammadTicket: ZammadTicketImport, fallbackUserId: string) { + const organization = await getOrganizationByZammadId(client, zammadTicket.organizationId); + + if (!organization) { + throw badRequest("Zammad Organisation ist lokal nicht synchronisiert"); + } + + const result = await client.query<{ id: string }>( + ` + INSERT INTO tickets ( + ticket_number, + title, + organization_id, + customer_name, + work_type, + zammad_ticket_id, + zammad_group_id, + zammad_priority_id, + zammad_state_id, + zammad_owner_id, + zammad_preferences + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb) + ON CONFLICT (ticket_number) DO UPDATE SET + ticket_number = tickets.ticket_number + RETURNING id::text; + `, + [ + zammadTicket.ticketNumber, + zammadTicket.title, + organization.id, + organization.name, + zammadTicket.workType, + zammadTicket.zammadTicketId, + zammadTicket.zammadGroupId, + zammadTicket.zammadPriorityId, + zammadTicket.zammadStateId, + zammadTicket.zammadOwnerId, + JSON.stringify(zammadTicket.preferences) + ] + ); + + if (zammadTicket.workType === "consulting") { + const ownerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, fallbackUserId); + + await ensureProjectForTicket(client, result.rows[0].id, ownerUserId, { + teamspaceProject: zammadTicket.teamspaceProject, + hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes + }); + } + + return result.rows[0].id; +} + +async function ticketExists(ticketNumber: string) { + const result = await query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM tickets + WHERE ticket_number = $1 + ) AS exists; + `, + [ticketNumber] + ); + + return Boolean(result.rows[0]?.exists); +} + function optionalBoolean(value: unknown, fallback: boolean) { if (value === undefined || value === null) { return fallback; @@ -401,6 +695,7 @@ function parseProjectTeamspaceEntries( } return { + id: parseOptionalTeamspaceEntryId(entry.id), teamspaceProject: optionalString(entry.teamspaceProject), isDefault, hourBudgetMinutes: parseOptionalNonNegativeInteger(entry.hourBudgetMinutes, "teamspaceEntries.hourBudgetMinutes"), @@ -810,23 +1105,37 @@ async function cleanupTicketDayBillingIfEmpty( } async function ensureProjectForTicket( - client: { query: (text: string, params?: unknown[]) => Promise<{ rows: T[] }> }, + client: QueryClient, ticketId: string, - userId: string + userId: string, + defaults: { teamspaceProject?: string | null; hourBudgetMinutes?: number | null } = {} ) { const result = 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) - SELECT $2, t.id, COALESCE(t.title, ''), '', '' + SELECT $2, t.id, COALESCE($3, t.title, ''), '', '' 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; `, - [ticketId, userId] + [ticketId, userId, defaults.teamspaceProject ?? null] ); if ("rows" in result && result.rows[0]) { + if (defaults.hourBudgetMinutes !== undefined && defaults.hourBudgetMinutes !== null) { + await client.query( + ` + UPDATE projects + SET hour_budget_minutes = $1, + updated_at = now() + WHERE id = $2; + `, + [defaults.hourBudgetMinutes, result.rows[0].id] + ); + result.rows[0].hour_budget_minutes = defaults.hourBudgetMinutes; + } + await client.query( ` INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) @@ -843,6 +1152,226 @@ async function ensureProjectForTicket( } } +async function getProjectRowForTicket(client: QueryClient, ticketId: string, projectOwnerUserId: string, viewerUserId: string) { + const result = await client.query( + ` + SELECT + p.id::text AS id, + 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, + p.ticket_id::text AS ticket_id, + t.ticket_number, + t.title, + t.organization_id::text AS organization_id, + o.name AS organization_name, + COALESCE(o.name, t.customer_name) AS customer_name, + t.work_type, + p.teamspace_project, + p.description, + p.goals, + p.starts_on::text AS starts_on, + p.ends_on::text AS ends_on, + entry_totals.total_hour_budget_minutes AS hour_budget_minutes, + entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = $3 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + ) AS has_recurring_billing, + COALESCE(viewer_sessions.session_count, 0)::int AS session_count, + COALESCE(viewer_sessions.recurring_session_count, 0)::int AS recurring_session_count, + COALESCE(viewer_sessions.manual_session_count, 0)::int AS manual_session_count, + COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, + (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, + entry_totals.teamspace_entry_count, + entry_totals.teamspace_entries, + CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, + COALESCE(todo_totals.open_todos, 0)::int AS open_todos, + COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos, + COALESCE(todo_totals.done_todos, 0)::int AS done_todos, + p.closed_at, + p.created_at, + p.updated_at + FROM projects p + 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 ( + SELECT + COUNT(*)::int AS session_count, + COUNT(*) FILTER (WHERE s.recurring_billing_id IS NOT NULL)::int AS recurring_session_count, + COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count + FROM sessions s + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $3 + ) viewer_sessions ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes, + COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, + COUNT(*)::int AS teamspace_entry_count, + COALESCE( + json_agg( + json_build_object( + 'id', pte.id::text, + 'project_id', pte.project_id::text, + 'teamspace_project', pte.teamspace_project, + 'is_default', pte.is_default, + 'hour_budget_minutes', pte.hour_budget_minutes, + 'manual_consumption_minutes', pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, + 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, + 'display_order', pte.display_order + ) + ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC + ), + '[]'::json + ) AS teamspace_entries + FROM project_teamspace_entries pte + WHERE pte.project_id = p.id + ) entry_totals ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS session_count + FROM sessions s + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $3 + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL + ) unassigned_sessions ON true + LEFT JOIN LATERAL ( + SELECT + COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos, + COUNT(*) FILTER (WHERE status = 'open' AND ends_on IS NOT NULL AND ends_on < (now() AT TIME ZONE 'Europe/Berlin')::date)::int AS overdue_todos, + COUNT(*) FILTER (WHERE status = 'done')::int AS done_todos + FROM ticket_todos tt + WHERE tt.ticket_id = p.ticket_id + AND tt.user_id = $3 + ) todo_totals ON true + WHERE p.ticket_id = $1 + AND p.user_id = $2; + `, + [ticketId, projectOwnerUserId, viewerUserId] + ); + + return result.rows[0] ?? null; +} + +async function resolveProjectTeamspaceEntryForSession( + client: QueryClient, + ticketId: string, + userId: string, + workType: "support" | "consulting", + requestedEntryId: number | null, + zammadOwnerId: number | null = null +) { + if (workType !== "consulting") { + return null; + } + + const projectOwnerUserId = 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 + LIMIT 1; + `, + [ticketId, projectOwnerUserId, userId] + ); + const projectId = projectResult.rows[0]?.id; + + if (!projectId) { + return null; + } + + const entriesResult = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM project_teamspace_entries + WHERE project_id = $1 + ORDER BY is_default DESC, display_order ASC, id ASC; + `, + [projectId] + ); + + if (entriesResult.rows.length === 0) { + return null; + } + + if (entriesResult.rows.length === 1) { + return Number(entriesResult.rows[0].id); + } + + if (!requestedEntryId) { + throw badRequest("projectTeamspaceEntryId is required when a project has multiple Teamspace entries"); + } + + if (!entriesResult.rows.some((entry) => entry.id === String(requestedEntryId))) { + throw badRequest("projectTeamspaceEntryId must belong to the project"); + } + + return requestedEntryId; +} + +async function autoAssignSingleProjectTeamspaceEntry(client: QueryClient, ticketId: string, userId: string | null = null) { + await client.query( + ` + WITH single_entry_projects AS ( + SELECT + p.user_id, + p.ticket_id, + MIN(pte.id) AS project_teamspace_entry_id + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE p.ticket_id = $1 + AND ($2::bigint IS NULL OR p.user_id = $2) + GROUP BY p.id, p.user_id, p.ticket_id + HAVING COUNT(pte.id) = 1 + ) + UPDATE sessions s + SET project_teamspace_entry_id = sep.project_teamspace_entry_id + FROM single_entry_projects sep + WHERE s.ticket_id = sep.ticket_id + AND s.user_id = sep.user_id + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL; + `, + [ticketId, userId] + ); + + await client.query( + ` + WITH single_entry_tickets AS ( + SELECT + p.ticket_id, + MIN(pte.id) AS project_teamspace_entry_id + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE p.ticket_id = $1 + GROUP BY p.ticket_id + HAVING COUNT(pte.id) = 1 + ) + UPDATE sessions s + SET project_teamspace_entry_id = single_ticket.project_teamspace_entry_id + FROM single_entry_tickets single_ticket + WHERE s.ticket_id = single_ticket.ticket_id + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL + AND ($2::bigint IS NULL OR s.user_id = $2); + `, + [ticketId, userId] + ); +} + async function deleteProjectDataForTicket( client: { query: (text: string, params?: unknown[]) => Promise }, ticketId: string, @@ -1436,6 +1965,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); + await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId); const periodClosureJoin = config.type === "month" @@ -1514,7 +2044,13 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke created_at, recurring_billing_id, recurring_billing_slot_id, - recurring_occurrence_date + recurring_occurrence_date, + project_teamspace_entry_id::text AS project_teamspace_entry_id, + ( + SELECT teamspace_project + FROM project_teamspace_entries + WHERE id = sessions.project_teamspace_entry_id + ) AS project_teamspace_project FROM sessions WHERE ticket_id = $1 AND started_at >= $2::timestamptz @@ -1568,6 +2104,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon if (filters.month) { await ensureRecurringSessionsForUserPeriod(userId, filters.month.startIso, filters.month.endIso); } + await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId); const ticketResult = await query( ` @@ -1663,8 +2200,11 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon s.created_at, s.recurring_billing_id, s.recurring_billing_slot_id, - s.recurring_occurrence_date + s.recurring_occurrence_date, + s.project_teamspace_entry_id::text AS project_teamspace_entry_id, + pte.teamspace_project AS project_teamspace_project FROM sessions s + LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id WHERE ${sessionWhere} ORDER BY s.started_at ASC; `, @@ -1706,6 +2246,10 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon ` SELECT p.id::text AS id, + 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.ticket_id::text AS ticket_id, t.ticket_number, t.title, @@ -1723,24 +2267,25 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon EXISTS ( SELECT 1 FROM recurring_billings rb - WHERE rb.user_id = p.user_id + WHERE rb.user_id = $2 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing, COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, + CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, p.closed_at, p.created_at, p.updated_at FROM projects p + 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 ( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes FROM ticket_day_billings tdb WHERE tdb.ticket_id = p.ticket_id - AND tdb.user_id = p.user_id ) teamspace_totals ON true LEFT JOIN LATERAL ( SELECT @@ -1767,8 +2312,26 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon FROM project_teamspace_entries pte WHERE pte.project_id = p.id ) entry_totals ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS session_count + FROM sessions s + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $2 + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL + ) unassigned_sessions ON true WHERE p.ticket_id = $1 - AND p.user_id = $2; + AND ( + p.user_id = $2 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $2 + ) + ) + ORDER BY (p.user_id = $2::bigint) DESC, p.id ASC + LIMIT 1; `, [ticketId, userId] ), @@ -3305,6 +3868,8 @@ app.post("/api/sessions", requireUser, async (req, res) => { const activity = requireString(req.body.activity, "activity"); const requestedOrganizationId = req.body.organizationId === undefined || req.body.organizationId === null || req.body.organizationId === "" ? null : parseOrganizationId(req.body.organizationId); const requestedWorkType = optionalWorkType(req.body.workType); + const useZammadLookup = optionalBoolean(req.body.zammadLookup, false); + const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); const startedAt = parseIsoDate(req.body.startedAt, "startedAt"); const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds"); @@ -3334,13 +3899,27 @@ app.post("/api/sessions", requireUser, async (req, res) => { } const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); + const existingLocalTicket = useZammadLookup ? await ticketExists(ticketNumber) : false; + const zammadTicket = useZammadLookup && !existingLocalTicket ? await fetchZammadTicketByNumber(user.id, ticketNumber) : null; + + if (useZammadLookup && !existingLocalTicket && !zammadTicket) { + res.status(404).json({ error: "Ticket wurde in Zammad nicht gefunden" }); + return; + } + const created = await withTransaction(async (client) => { + if (zammadTicket) { + await importZammadTicketForUser(client, zammadTicket, user.id); + } + const existingTicketResult = await client.query<{ id: string; 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 FROM tickets t - LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $2 + LEFT JOIN projects p ON p.ticket_id = t.id WHERE t.ticket_number = $1 + ORDER BY (p.user_id = $2::bigint) DESC, p.id ASC + LIMIT 1 FOR UPDATE OF t; `, [ticketNumber, user.id] @@ -3385,6 +3964,28 @@ app.post("/api/sessions", requireUser, async (req, res) => { throw badRequest("workType must be support or consulting"); } + let projectTeamspaceEntryId: number | null = null; + + if (workType === "consulting") { + const projectOwnerUserId = await projectOwnerUserIdForZammadOwner( + client, + zammadTicket?.zammadOwnerId ?? null, + user.id + ); + await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, { + teamspaceProject: zammadTicket?.teamspaceProject ?? null, + hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null + }); + projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( + client, + ticket.id, + user.id, + workType, + requestedProjectTeamspaceEntryId, + zammadTicket?.zammadOwnerId ?? null + ); + } + const sessionResult = await client.query( ` INSERT INTO sessions ( @@ -3397,9 +3998,10 @@ app.post("/api/sessions", requireUser, async (req, res) => { started_at, ended_at, duration_seconds, - rounded_minutes + rounded_minutes, + project_teamspace_entry_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *; `, [ @@ -3412,14 +4014,11 @@ app.post("/api/sessions", requireUser, async (req, res) => { startedAt.toISOString(), endedAt.toISOString(), durationSeconds, - roundedMinutes + roundedMinutes, + projectTeamspaceEntryId ] ); - if (workType === "consulting") { - await ensureProjectForTicket(client, ticket.id, user.id); - } - await reopenPeriodsForSession(client, ticket.id, startedAt, user.id); return { @@ -3444,8 +4043,8 @@ app.get("/api/statistics/months/:month", requireUser, async (req, res) => { app.get("/api/tickets/lookup", requireUser, async (req, res) => { const ticketNumber = parseTicketNumber(req.query.ticketNumber); const userId = currentUser(req).id; - const result = await query( - ` + const source = req.query.source === "zammad" ? "zammad" : "local"; + const lookupSql = ` SELECT t.id, t.ticket_number, @@ -3454,9 +4053,20 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { 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, + t.zammad_state_id::text AS zammad_state_id, + t.zammad_owner_id::text AS zammad_owner_id, p.id::text AS project_id, (p.id IS NOT NULL) AS is_project, p.teamspace_project AS project_name, + p.hour_budget_minutes AS project_hour_budget_minutes, + COALESCE(project_entries.teamspace_entry_count, 0)::int AS project_teamspace_entry_count, + 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, EXISTS ( SELECT 1 FROM recurring_billings rb @@ -3465,11 +4075,49 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { ) AS has_recurring_billing FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id - LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $2 + LEFT JOIN LATERAL ( + SELECT projects.* + FROM projects + WHERE projects.ticket_id = t.id + ORDER BY (projects.user_id = $2::bigint) DESC, projects.id ASC + LIMIT 1 + ) p ON true + LEFT JOIN users pu ON pu.id = p.user_id + LEFT JOIN LATERAL ( + SELECT + COUNT(*)::int AS teamspace_entry_count, + COALESCE( + json_agg( + json_build_object( + 'id', pte.id::text, + 'project_id', pte.project_id::text, + 'teamspace_project', pte.teamspace_project, + 'is_default', pte.is_default, + 'hour_budget_minutes', pte.hour_budget_minutes, + 'manual_consumption_minutes', pte.manual_consumption_minutes, + 'display_order', pte.display_order + ) + ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC + ), + '[]'::json + ) AS teamspace_entries + FROM project_teamspace_entries pte + WHERE pte.project_id = p.id + ) project_entries ON true WHERE t.ticket_number = $1; - `, - [ticketNumber, userId] - ); + `; + let result = await query(lookupSql, [ticketNumber, userId]); + + if (result.rowCount === 0 && source === "zammad") { + const zammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber); + + if (zammadTicket) { + await withTransaction(async (client) => { + await importZammadTicketForUser(client, zammadTicket, userId); + }); + result = await query(lookupSql, [ticketNumber, userId]); + } + } res.json({ ticket: result.rows[0] ?? null @@ -3509,8 +4157,23 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { FROM tickets t LEFT JOIN sessions s ON s.ticket_id = t.id LEFT JOIN organizations o ON o.zammad_id = t.organization_id - LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $1 - WHERE (s.user_id = $1 OR p.id IS NOT NULL) + LEFT JOIN LATERAL ( + SELECT projects.* + FROM projects + WHERE projects.ticket_id = t.id + ORDER BY (projects.user_id = $1::bigint) DESC, projects.id ASC + LIMIT 1 + ) p ON true + WHERE ( + s.user_id = $1 + OR p.user_id = $1 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = t.id + AND viewer_sessions.user_id = $1 + ) + ) AND ( t.ticket_number ILIKE $2 OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3 @@ -3548,6 +4211,10 @@ app.get("/api/projects", requireUser, async (req, res) => { ` SELECT p.id::text AS id, + 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, p.ticket_id::text AS ticket_id, t.ticket_number, t.title, @@ -3571,17 +4238,19 @@ app.get("/api/projects", requireUser, async (req, res) => { EXISTS ( SELECT 1 FROM recurring_billings rb - WHERE rb.user_id = p.user_id + WHERE rb.user_id = $1 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing, COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, + CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, COALESCE(todo_totals.open_todos, 0)::int AS open_todos, 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 JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( @@ -3591,13 +4260,12 @@ app.get("/api/projects", requireUser, async (req, res) => { COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count FROM sessions s WHERE s.ticket_id = p.ticket_id - AND s.user_id = p.user_id + AND s.user_id = $1 ) session_totals ON true LEFT JOIN LATERAL ( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes FROM ticket_day_billings tdb WHERE tdb.ticket_id = p.ticket_id - AND tdb.user_id = p.user_id ) teamspace_totals ON true LEFT JOIN LATERAL ( SELECT @@ -3624,6 +4292,14 @@ app.get("/api/projects", requireUser, async (req, res) => { FROM project_teamspace_entries pte WHERE pte.project_id = p.id ) entry_totals ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS session_count + FROM sessions s + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $1 + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL + ) unassigned_sessions ON true LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos, @@ -3631,9 +4307,17 @@ app.get("/api/projects", requireUser, async (req, res) => { COUNT(*) FILTER (WHERE status = 'done')::int AS done_todos FROM ticket_todos tt WHERE tt.ticket_id = p.ticket_id - AND tt.user_id = p.user_id + AND tt.user_id = $1 ) todo_totals ON true - WHERE p.user_id = $1 + WHERE ( + p.user_id = $1 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $1 + ) + ) AND ($2::boolean OR p.closed_at IS NULL) ORDER BY CASE WHEN p.closed_at IS NULL THEN 0 ELSE 1 END, @@ -3653,6 +4337,10 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { ` SELECT p.id::text AS id, + 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.ticket_id::text AS ticket_id, t.ticket_number, t.title, @@ -3670,24 +4358,25 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { EXISTS ( SELECT 1 FROM recurring_billings rb - WHERE rb.user_id = p.user_id + WHERE rb.user_id = $2 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing, COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, + CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, p.closed_at, p.created_at, p.updated_at FROM projects p + 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 ( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes FROM ticket_day_billings tdb WHERE tdb.ticket_id = p.ticket_id - AND tdb.user_id = p.user_id ) teamspace_totals ON true LEFT JOIN LATERAL ( SELECT @@ -3714,8 +4403,24 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { FROM project_teamspace_entries pte WHERE pte.project_id = p.id ) entry_totals ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS session_count + FROM sessions s + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $2 + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL + ) unassigned_sessions ON true WHERE p.id = $1 - AND p.user_id = $2; + AND ( + p.user_id = $2 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $2 + ) + ); `, [projectId, userId] ); @@ -3731,6 +4436,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { app.post("/api/projects", requireUser, async (req, res) => { const userId = currentUser(req).id; const ticketNumber = parseTicketNumber(req.body.ticketNumber); + const useZammadLookup = optionalBoolean(req.body.zammadLookup, false); const title = optionalNullableString(req.body.title); const requestOrganizationId = req.body.organizationId === undefined || req.body.organizationId === "" ? null : parseOrganizationId(req.body.organizationId); const teamspaceProject = optionalString(req.body.teamspaceProject); @@ -3740,23 +4446,54 @@ app.post("/api/projects", requireUser, async (req, res) => { const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; - const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); - const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; + let teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); + let defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; if (startsOn && endsOn && endsOn < startsOn) { throw badRequest("endsOn must be after startsOn"); } + const existingLocalTicket = useZammadLookup ? await ticketExists(ticketNumber) : false; + const zammadTicket = useZammadLookup && !existingLocalTicket ? await fetchZammadTicketByNumber(userId, ticketNumber) : null; + + if (useZammadLookup && !existingLocalTicket && !zammadTicket) { + res.status(404).json({ error: "Ticket wurde in Zammad nicht gefunden" }); + return; + } + + if (zammadTicket && zammadTicket.workType !== "consulting") { + res.status(409).json({ error: "Das Zammad-Ticket ist kein Consulting-Ticket" }); + return; + } + + if (zammadTicket) { + teamspaceEntries = teamspaceEntries.map((entry) => + entry.isDefault + ? { + ...entry, + teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject, + hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes + } + : entry + ); + defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; + } + try { const created = await withTransaction(async (client) => { + if (zammadTicket) { + await importZammadTicketForUser(client, zammadTicket, userId); + } + const existingTicket = await client.query<{ id: string; title: string | null; organization_id: string | null; customer_name: string | null; + zammad_owner_id: string | null; }>( ` - SELECT id, title, organization_id::text AS organization_id, customer_name + SELECT id, title, organization_id::text AS organization_id, customer_name, zammad_owner_id::text AS zammad_owner_id FROM tickets WHERE ticket_number = $1 FOR UPDATE; @@ -3792,6 +4529,9 @@ app.post("/api/projects", requireUser, async (req, res) => { ); const ticketId = ticketResult.rows[0].id; + const projectOwnerUserId = zammadTicket + ? await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId) + : userId; await client.query( ` @@ -3806,6 +4546,15 @@ app.post("/api/projects", requireUser, async (req, res) => { [organization.id, organization.name, ticketId, userId] ); + if (zammadTicket && projectOwnerUserId !== userId) { + await ensureProjectForTicket(client, ticketId, projectOwnerUserId, { + teamspaceProject: zammadTicket.teamspaceProject, + hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes + }); + + return getProjectRowForTicket(client, ticketId, projectOwnerUserId, userId); + } + const projectResult = await client.query( ` INSERT INTO projects ( @@ -3820,8 +4569,24 @@ app.post("/api/projects", requireUser, async (req, res) => { manual_consumption_minutes ) VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, $8, $9) + ${zammadTicket ? ` + ON CONFLICT (user_id, ticket_id) + DO UPDATE SET + teamspace_project = EXCLUDED.teamspace_project, + description = EXCLUDED.description, + goals = EXCLUDED.goals, + starts_on = EXCLUDED.starts_on, + ends_on = EXCLUDED.ends_on, + hour_budget_minutes = EXCLUDED.hour_budget_minutes, + manual_consumption_minutes = EXCLUDED.manual_consumption_minutes, + updated_at = now() + ` : ""} RETURNING id::text, + user_id::text, + NULL::text AS owner_username, + NULL::text AS owner_display_name, + true AS can_edit, ticket_id::text, $10::text AS ticket_number, COALESCE($13::text, $14::text) AS title, @@ -3838,12 +4603,13 @@ app.post("/api/projects", requireUser, async (req, res) => { manual_consumption_minutes, 0::int AS teamspace_consumption_minutes, manual_consumption_minutes AS consumption_minutes, + 0::int AS unassigned_teamspace_session_count, NULL::timestamptz AS closed_at, created_at, updated_at; `, [ - userId, + projectOwnerUserId, ticketId, defaultTeamspaceEntry.teamspaceProject, description, @@ -3862,6 +4628,10 @@ app.post("/api/projects", requireUser, async (req, res) => { const project = projectResult.rows[0]; + if (zammadTicket) { + await client.query("DELETE FROM project_teamspace_entries WHERE project_id = $1;", [project.id]); + } + for (const entry of teamspaceEntries) { await client.query( ` @@ -3871,6 +4641,7 @@ app.post("/api/projects", requireUser, async (req, res) => { [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder] ); } + await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id); project.teamspace_entries = teamspaceEntries.map((entry, index) => ({ id: `new-${index}`, @@ -4017,6 +4788,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { WHERE tdb.ticket_id = projects.ticket_id AND tdb.user_id = projects.user_id ) + manual_consumption_minutes)::int AS consumption_minutes, + 0::int AS unassigned_teamspace_session_count, closed_at, created_at, updated_at; @@ -4040,20 +4812,58 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { const project = result.rows[0]; - await client.query("DELETE FROM project_teamspace_entries WHERE project_id = $1;", [project.id]); + await client.query("UPDATE project_teamspace_entries SET is_default = false, updated_at = now() WHERE project_id = $1;", [project.id]); + const savedEntryIds: string[] = []; for (const entry of teamspaceEntries) { - await client.query( - ` - INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) - VALUES ($1, $2, $3, $4, $5, $6); - `, - [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder] - ); + if (entry.id) { + const updatedEntry = await client.query<{ id: string }>( + ` + UPDATE project_teamspace_entries + SET teamspace_project = $1, + is_default = $2, + hour_budget_minutes = $3, + manual_consumption_minutes = $4, + display_order = $5, + updated_at = now() + WHERE id = $6 + AND project_id = $7 + RETURNING id::text AS id; + `, + [entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder, entry.id, project.id] + ); + + if (updatedEntry.rowCount === 0) { + return "teamspace-entry-not-found" as const; + } + + savedEntryIds.push(updatedEntry.rows[0].id); + } else { + const insertedEntry = await client.query<{ id: string }>( + ` + INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id::text AS id; + `, + [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder] + ); + + savedEntryIds.push(insertedEntry.rows[0].id); + } } + await client.query( + ` + DELETE FROM project_teamspace_entries + WHERE project_id = $1 + AND NOT (id = ANY($2::bigint[])); + `, + [project.id, savedEntryIds] + ); + await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id); + project.teamspace_entries = teamspaceEntries.map((entry, index) => ({ - id: `new-${index}`, + id: savedEntryIds[index] ?? `new-${index}`, teamspace_project: entry.teamspaceProject, is_default: entry.isDefault, hour_budget_minutes: entry.hourBudgetMinutes, @@ -4072,6 +4882,11 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { return; } + if (updated === "teamspace-entry-not-found") { + res.status(404).json({ error: "Teamspace-Eintrag nicht gefunden" }); + return; + } + if (!updated) { res.status(404).json({ error: "Projekt nicht gefunden" }); return; @@ -4134,6 +4949,7 @@ app.delete("/api/projects/:projectId", requireUser, async (req, res) => { } await deleteProjectDataForTicket(client, projectResult.rows[0].ticket_id, userId); + await cleanupEmptyTickets(client, [projectResult.rows[0].ticket_id]); return projectResult.rows[0]; }); @@ -4387,18 +5203,23 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { return "organization-not-found" as const; } - const projectResult = await client.query<{ id: string }>( + const projectsResult = await client.query<{ id: string; user_id: string }>( ` - SELECT id + SELECT id::text AS id, user_id::text AS user_id FROM projects WHERE ticket_id = $1 - AND user_id = $2 FOR UPDATE; `, - [ticketId, userId] + [ticketId] ); + const ownedProject = projectsResult.rows.find((project) => project.user_id === userId) ?? null; + const hasOtherProject = projectsResult.rows.some((project) => project.user_id !== userId); - if (projectResult.rowCount && projectResult.rowCount > 0 && workType === "support" && !removeProjectData) { + if (hasOtherProject && !ownedProject) { + return "project-not-owned" as const; + } + + if (ownedProject && workType === "support" && !removeProjectData) { return "project-removal-required" as const; } @@ -4420,7 +5241,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { return null; } - if (workType === "support" && projectResult.rowCount && projectResult.rowCount > 0) { + if (workType === "support" && ownedProject) { await deleteProjectDataForTicket(client, String(ticketId), userId); } @@ -4452,6 +5273,11 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { return; } + if (result === "project-not-owned") { + res.status(403).json({ error: "Nur der Zammad-Owner kann die Projektdaten ändern" }); + return; + } + if (!result) { res.status(404).json({ error: "Ticket not found" }); return; @@ -4901,6 +5727,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { const activity = requireString(req.body.activity, "activity"); const workType = parseWorkType(req.body.workType); const removeProjectData = optionalBoolean(req.body.removeProjectData, false); + const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); const startedAt = parseIsoDate(req.body.startedAt, "startedAt"); const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); @@ -4971,11 +5798,35 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { } const effectiveWorkType = current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType; + let projectTeamspaceEntryId: number | null = null; if (current.project_id && current.work_type === "consulting" && effectiveWorkType === "support" && !removeProjectData) { return "project-removal-required" as const; } + if (effectiveWorkType === "consulting") { + const projectCountResult = await client.query<{ count: string }>( + ` + SELECT COUNT(*) AS count + FROM projects + WHERE ticket_id = $1; + `, + [current.ticket_id] + ); + + if (Number(projectCountResult.rows[0]?.count ?? 0) === 0) { + await ensureProjectForTicket(client, current.ticket_id, userId); + } + + projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( + client, + current.ticket_id, + userId, + effectiveWorkType, + requestedProjectTeamspaceEntryId + ); + } + await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId); await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId); @@ -4989,12 +5840,25 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { started_at = $5, ended_at = $6, duration_seconds = $7, - rounded_minutes = $8 - WHERE id = $9 - AND user_id = $10 + rounded_minutes = $8, + project_teamspace_entry_id = $9 + WHERE id = $10 + AND user_id = $11 RETURNING *; `, - [organization.id, organization.name, activity, effectiveWorkType, startedAt.toISOString(), endedAt.toISOString(), durationSeconds, roundedMinutes, sessionId, userId] + [ + organization.id, + organization.name, + activity, + effectiveWorkType, + startedAt.toISOString(), + endedAt.toISOString(), + durationSeconds, + roundedMinutes, + projectTeamspaceEntryId, + sessionId, + userId + ] ); if (effectiveWorkType === "consulting") { @@ -5008,7 +5872,6 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { `, [current.ticket_id, organization.id, organization.name] ); - await ensureProjectForTicket(client, current.ticket_id, userId); } if (current.project_id && effectiveWorkType === "support" && removeProjectData) { @@ -5017,10 +5880,9 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { SELECT COUNT(*) AS count FROM sessions WHERE ticket_id = $1 - AND user_id = $2 AND work_type = 'consulting'; `, - [current.ticket_id, userId] + [current.ticket_id] ); if (Number(remainingConsultingResult.rows[0]?.count ?? 0) === 0) { @@ -5145,15 +6007,36 @@ app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => { ); const remainingSessions = Number(remainingResult.rows[0]?.count ?? 0); + const projectResult = await client.query<{ id: string; user_id: string }>( + ` + SELECT id::text AS id, user_id::text AS user_id + FROM projects + WHERE ticket_id = $1 + ORDER BY (user_id = $2::bigint) DESC, id ASC; + `, + [session.ticket_id, userId] + ); + const ownedProject = projectResult.rows.find((project) => project.user_id === userId) ?? null; - if (remainingSessions === 0) { - await client.query("DELETE FROM tickets WHERE id = $1;", [session.ticket_id]); - } + await cleanupEmptyTickets(client, [session.ticket_id]); + + const ticketStillExistsResult = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM tickets + WHERE id = $1 + ) AS exists; + `, + [session.ticket_id] + ); return { ...session, userTicketEmpty: Number(remainingUserResult.rows[0]?.count ?? 0) === 0, - ticketDeleted: remainingSessions === 0 + ticketDeleted: !ticketStillExistsResult.rows[0]?.exists, + projectDeleteAvailable: remainingSessions === 0 && Boolean(ownedProject), + project_id: ownedProject?.id ?? null }; }); diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 54a0e8f..a6d4ed8 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -139,6 +139,12 @@ export async function migrate() { await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS title TEXT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_ticket_id BIGINT UNIQUE;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_group_id BIGINT;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_priority_id BIGINT;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;"); await query(` ALTER TABLE tickets DROP CONSTRAINT IF EXISTS ticket_number_format, @@ -186,6 +192,7 @@ export async function migrate() { await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;"); + await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;"); if (adminId) { await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]); } @@ -505,6 +512,18 @@ export async function migrate() { await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;"); await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);"); + await query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'sessions_project_teamspace_entry_fkey' + ) THEN + ALTER TABLE sessions + ADD CONSTRAINT sessions_project_teamspace_entry_fkey + FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL; + END IF; + END $$; + `); await query(` UPDATE tickets t @@ -538,6 +557,45 @@ export async function migrate() { ); `); + await query(` + WITH single_entry_projects AS ( + SELECT + p.id AS project_id, + p.user_id, + p.ticket_id, + MIN(pte.id) AS project_teamspace_entry_id + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + GROUP BY p.id, p.user_id, p.ticket_id + HAVING COUNT(pte.id) = 1 + ) + UPDATE sessions s + SET project_teamspace_entry_id = sep.project_teamspace_entry_id + FROM single_entry_projects sep + WHERE s.ticket_id = sep.ticket_id + AND s.user_id = sep.user_id + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL; + `); + + await query(` + WITH single_entry_tickets AS ( + SELECT + p.ticket_id, + MIN(pte.id) AS project_teamspace_entry_id + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + GROUP BY p.ticket_id + HAVING COUNT(pte.id) = 1 + ) + UPDATE sessions s + SET project_teamspace_entry_id = single_ticket.project_teamspace_entry_id + FROM single_entry_tickets single_ticket + WHERE s.ticket_id = single_ticket.ticket_id + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL; + `); + await query(` CREATE TABLE IF NOT EXISTS ticket_todos ( id BIGSERIAL PRIMARY KEY, @@ -601,6 +659,7 @@ export async function migrate() { await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_started ON sessions(user_id, started_at);"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_ticket_started ON sessions(user_id, ticket_id, started_at);"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_organization_started ON sessions(user_id, organization_id, started_at);"); + await query("CREATE INDEX IF NOT EXISTS idx_sessions_project_teamspace_entry ON sessions(project_teamspace_entry_id);"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_recurring_occurrence_key ON sessions(recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL;"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);"); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index df61616..0b5449f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -26,6 +26,8 @@ type CreateSessionPayload = { startedAt: string; endedAt: string; durationSeconds: number; + zammadLookup?: boolean; + projectTeamspaceEntryId?: string | null; }; type DeleteSessionResult = { @@ -35,6 +37,8 @@ type DeleteSessionResult = { started_at: string; userTicketEmpty: boolean; ticketDeleted: boolean; + projectDeleteAvailable: boolean; + project_id: string | null; }; }; @@ -338,8 +342,10 @@ export function createProject(payload: { ticketNumber: string; title?: string | null; organizationId?: string | null; + zammadLookup?: boolean; teamspaceProject?: string | null; teamspaceEntries?: Array<{ + id?: string | null; teamspaceProject?: string | null; isDefault: boolean; hourBudgetMinutes?: number | null; @@ -366,6 +372,7 @@ export function updateProject( organizationId: string; teamspaceProject?: string | null; teamspaceEntries?: Array<{ + id?: string | null; teamspaceProject?: string | null; isDefault: boolean; hourBudgetMinutes?: number | null; @@ -461,8 +468,14 @@ export function createSession(payload: CreateSessionPayload) { }); } -export function lookupTicket(ticketNumber: string) { - return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`); +export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") { + const params = new URLSearchParams({ ticketNumber }); + + if (source === "zammad") { + params.set("source", source); + } + + return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?${params}`); } export function searchTickets(query: string) { @@ -492,6 +505,7 @@ export function updateSessionDetails( activity: string; workType: WorkType; removeProjectData?: boolean; + projectTeamspaceEntryId?: string | null; startedAt: string; endedAt: string; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index acb2751..098c73d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -89,9 +89,20 @@ export type TicketMeta = { 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; project_id?: string | null; is_project?: boolean; project_name?: string | null; + project_hour_budget_minutes?: number | null; + project_teamspace_entry_count?: number; + project_teamspace_entries?: ProjectTeamspaceEntry[]; + project_owner_user_id?: string | null; + project_owner_display_name?: string | null; + can_edit_project?: boolean; has_recurring_billing?: boolean; }; @@ -114,6 +125,10 @@ export type TicketSearchResult = { export type ProjectSummary = { id: string; + user_id?: string; + owner_username?: string | null; + owner_display_name?: string | null; + can_edit?: boolean; ticket_id: string; ticket_number: string; title: string | null; @@ -143,6 +158,7 @@ export type ProjectSummary = { done_todos?: number; teamspace_entry_count?: number; teamspace_entries?: ProjectTeamspaceEntry[]; + unassigned_teamspace_session_count?: number; }; export type ProjectTeamspaceEntry = { @@ -212,6 +228,8 @@ export type OpenSession = { customer_name: string; activity: string; work_type: WorkType; + project_teamspace_entry_id?: string | null; + project_teamspace_project?: string | null; started_at: string; rounded_minutes: number; recurring_billing_id?: string | null; @@ -260,6 +278,8 @@ export type SessionEntry = { customer_name: string; activity: string; work_type: WorkType; + project_teamspace_entry_id?: string | null; + project_teamspace_project?: string | null; started_at: string; ended_at: string; duration_seconds: number; diff --git a/frontend/src/views/ProjectsPage.tsx b/frontend/src/views/ProjectsPage.tsx index 90ae7ff..c86d7f0 100644 --- a/frontend/src/views/ProjectsPage.tsx +++ b/frontend/src/views/ProjectsPage.tsx @@ -352,8 +352,10 @@ type ProjectFormFieldsProps = { setForm: (form: ProjectForm) => void; organizationLocked: boolean; lookupStatus: "idle" | "loading" | "found" | "not-found"; + zammadLookupBusy?: boolean; saving: boolean; submitLabel: string; + onZammadLookup?: () => void; onSubmit: (event: FormEvent) => void; }; @@ -378,6 +380,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { } return { + id: /^\d+$/.test(entry.clientId) ? entry.clientId : null, teamspaceProject: entry.teamspaceProject.trim() || null, isDefault: entry.isDefault, hourBudgetMinutes, @@ -390,6 +393,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { } return parsedEntries as Array<{ + id?: string | null; teamspaceProject: string | null; isDefault: boolean; hourBudgetMinutes: number | null; @@ -483,7 +487,7 @@ function ProjectTeamspaceEntriesFields({ ); } -function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, saving, submitLabel, onSubmit }: ProjectFormFieldsProps) { +function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) { return (
@@ -508,7 +512,17 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa {form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ?

Format: Ticket#XXXXXX

: null} {lookupStatus === "loading" ?

Suche Ticket...

: null} {lookupStatus === "found" ?

Ticket gefunden. Daten werden übernommen.

: null} - {lookupStatus === "not-found" ?

Neues Ticket. Organisation bitte wählen.

: null} + {lookupStatus === "not-found" ? ( +
+

Neues Ticket. Organisation bitte wählen oder aus Zammad übernehmen.

+ {onZammadLookup ? ( + + ) : null} +
+ ) : null}