diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 0ef2664..2957d1d 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -21,8 +21,10 @@ export type AuthUser = { has_teamspace_credentials: boolean; zammad_email_session_webhook_enabled: boolean; session_billing_prompt_enabled: boolean; + session_work_segments_enabled: boolean; teamspace_time_tracking_enabled: boolean; zammad_session_note_default_enabled: boolean; + zammad_session_note_date_header_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; zammad_atoss_email_sync_enabled: boolean; @@ -97,8 +99,10 @@ function publicUser(user: AuthUser) { has_teamspace_credentials: Boolean(user.has_teamspace_credentials), zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled), session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled), + session_work_segments_enabled: Boolean(user.session_work_segments_enabled), teamspace_time_tracking_enabled: Boolean(user.teamspace_time_tracking_enabled ?? true), zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled), + zammad_session_note_date_header_enabled: Boolean(user.zammad_session_note_date_header_enabled), zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled), zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15), zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true), @@ -169,8 +173,10 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio (u.teamspace_device_id IS NOT NULL AND u.teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, u.zammad_email_session_webhook_enabled, u.session_billing_prompt_enabled, + u.session_work_segments_enabled, u.teamspace_time_tracking_enabled, u.zammad_session_note_default_enabled, + u.zammad_session_note_date_header_enabled, u.zammad_time_unit_rounding_enabled, u.zammad_time_unit_rounding_minutes, u.zammad_atoss_email_sync_enabled, diff --git a/backend/src/databaseExport.ts b/backend/src/databaseExport.ts index ac7c4e5..3fff6aa 100644 --- a/backend/src/databaseExport.ts +++ b/backend/src/databaseExport.ts @@ -12,12 +12,14 @@ export const databaseExportTables = [ "auth_sessions", "app_settings", "user_preferences", + "organization_attribute_options", + "teamspace_project_exclusions", + "teamspace_assignment_rules", "organizations", "tickets", "projects", "teamspace_project_resources", "project_teamspace_entries", - "teamspace_project_exclusions", "teamspace_monthly_user_actuals", "project_teamspace_monthly_actuals", "project_teamspace_subentries", @@ -45,6 +47,17 @@ type ColumnInfo = { }; const jsonDataTypes = new Set(["json", "jsonb"]); +const arrayElementTypes = new Map([ + ["_bool", "boolean"], + ["_date", "date"], + ["_int2", "smallint"], + ["_int4", "integer"], + ["_int8", "bigint"], + ["_numeric", "numeric"], + ["_text", "text"], + ["_timestamptz", "timestamptz"], + ["_uuid", "uuid"] +]); const insertBatchSize = 500; export function quoteIdentifier(value: string) { @@ -81,6 +94,20 @@ export function sqlLiteral(value: unknown, column?: Pick sqlLiteral(entry)).join(", ")}]::${elementType}[]`; + } + if (value instanceof Date) { return quoteSqlString(value.toISOString()); } diff --git a/backend/src/index.ts b/backend/src/index.ts index 1387ab0..4ae1030 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,11 @@ import { migrate } from "./migrations.js"; import { pool, query, withTransaction } from "./db.js"; import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js"; import { decryptSecret, encryptSecret } from "./secrets.js"; +import { + organizationAttributeNames, + parseZammadOrganizationAttributeOptions, + type OrganizationAttributeName +} from "./zammadOrganizationAttributes.js"; import { databaseExportTables as backupTables, exportDatabaseSql, @@ -41,6 +46,7 @@ const requestBodyLimit = process.env.REQUEST_BODY_LIMIT ?? "250mb"; const zammadEmailWebhookToken = process.env.ZAMMAD_EMAIL_WEBHOOK_TOKEN ?? process.env.ZAMMAD_WEBHOOK_TOKEN ?? ""; const zammadRequestTimeoutMs = positiveIntegerFromEnv("ZAMMAD_REQUEST_TIMEOUT_MS", 30_000); const atossSupportEmail = "support.atc@atoss.com"; +const teamspaceAssignmentTargetTicketNumber = "Fix#0"; app.set("etag", false); app.use(cors()); @@ -230,6 +236,14 @@ function optionalString(value: unknown) { return trimmed.length > 0 ? trimmed : null; } +function parseOptionalPositiveInteger(value: unknown, field: string) { + if (value === undefined || value === null || value === "") { + return null; + } + + return parsePositiveInteger(value, field); +} + function optionalSessionActivity(value: unknown) { if (value === undefined || value === null) { return ""; @@ -568,7 +582,15 @@ type OrganizationRow = { id: string; zammad_id: string; name: string; + customer_owner_option_id?: string | null; + customer_owner?: string | null; + customer_owner_display_name?: string | null; + hotline_contract_option_id?: string | null; + hotline_contract?: string | null; + hotline_contract_display_name?: string | null; synced_at: string; + created_at?: string; + updated_at?: string; }; type ProjectRow = { @@ -660,6 +682,38 @@ const zammadBaseUrlSettingKey = "zammad.base_url"; const zammadApiKeySettingKey = "zammad.api_key"; const teamspaceBaseUrlSettingKey = "teamspace.base_url"; const teamspaceAdminCredentialsUserIdSettingKey = "teamspace.admin_credentials_user_id"; +const adminUserSelectSql = ` + id, + username, + display_name, + role, + active, + zammad_user_id::text AS zammad_user_id, + (zammad_api_token IS NOT NULL) AS has_zammad_api_token, + teamspace_user_id::text AS teamspace_user_id, + (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, + zammad_email_session_webhook_enabled, + session_billing_prompt_enabled, + session_work_segments_enabled, + teamspace_time_tracking_enabled, + zammad_session_note_default_enabled, + zammad_session_note_date_header_enabled, + zammad_time_unit_rounding_enabled, + zammad_time_unit_rounding_minutes, + zammad_atoss_email_sync_enabled, + COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, + project_followup_enabled, + project_followup_weeks, + project_followup_second_enabled, + project_followup_second_weeks, + project_followup_complete_on_session, + teamspace_initial_actuals_synced_at::text AS teamspace_initial_actuals_synced_at, + teamspace_initial_actuals_synced_by::text AS teamspace_initial_actuals_synced_by, + teamspace_initial_actuals_unlocked_at::text AS teamspace_initial_actuals_unlocked_at, + teamspace_initial_actuals_unlocked_by::text AS teamspace_initial_actuals_unlocked_by, + created_at, + updated_at +`; type QueryClient = { query: (text: string, params?: unknown[]) => Promise<{ rows: T[]; rowCount: number | null }>; @@ -1161,6 +1215,35 @@ function zammadSessionArticleBodyForDelivery(body: string, contentType: "text/pl return /<\s*[a-z][a-z0-9-]*(?:\s|\/?>)/i.test(body) ? body : `
${body}
`; } +function berlinDateLabelForDate(date: Date) { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit" + }).formatToParts(date); + const year = parts.find((part) => part.type === "year")?.value; + const month = parts.find((part) => part.type === "month")?.value; + const day = parts.find((part) => part.type === "day")?.value; + + return `${year}-${month}-${day}`; +} + +function germanDateFromDateLabel(dateLabel: string) { + const [year, month, day] = dateLabel.split("-"); + return `${day}.${month}.${year}`; +} + +function zammadSessionArticleBodyWithDateHeader(body: string, contentType: "text/plain" | "text/html", dateLabel: string) { + const header = germanDateFromDateLabel(dateLabel); + + if (contentType === "text/html") { + return `
${header}

${body}`; + } + + return `${header}\n\n${body}`; +} + async function postZammadSessionArticleForUser(userId: string, payload: ZammadSessionArticlePayload) { const credentials = await getZammadCredentialsForUser(userId); return postZammadJson(credentials.baseUrl, credentials.apiToken, "/api/v1/ticket_articles", payload); @@ -1998,7 +2081,9 @@ type SessionCreationInput = { requestedProjectTeamspaceEntryId: number | null; billingStatus: "billable" | "billed" | "non_billable" | null; createZammadArticle: boolean; + zammadArticleDateHeader: boolean; zammadArticleTypeId: number; + workSegments: WorkSegment[]; startedAt: Date; endedAt: Date; durationSeconds: number; @@ -2021,7 +2106,9 @@ async function createSessionForUser(input: SessionCreationInput) { requestedProjectTeamspaceEntryId, billingStatus, createZammadArticle, + zammadArticleDateHeader, zammadArticleTypeId, + workSegments, startedAt, endedAt, durationSeconds, @@ -2170,7 +2257,7 @@ async function createSessionForUser(input: SessionCreationInput) { 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 }>( + const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null; atoss_support: boolean; zammad_ticket_id: string | null }>( ` INSERT INTO tickets (user_id, ticket_number, organization_id, customer_name, work_type) VALUES ($1, $2, $3, $4, $5) @@ -2187,7 +2274,7 @@ async function createSessionForUser(input: SessionCreationInput) { ) 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; + RETURNING id, ticket_number, organization_id, customer_name, work_type, atoss_support, zammad_ticket_id::text AS zammad_ticket_id; `, [ownerUserId, effectiveTicketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId] ); @@ -2208,15 +2295,34 @@ async function createSessionForUser(input: SessionCreationInput) { closeReviewRequired: zammadTicket?.zammadStateId === 4, zammadOwnerId: zammadTicket?.zammadOwnerId ?? null }); - const teamspaceAssignment = await resolveProjectTeamspaceAssignmentForSession( - client, - ticket.id, - userId, - workType, - requestedProjectTeamspaceEntryId, - zammadTicket?.zammadOwnerId ?? null - ); - projectTeamspaceEntryId = teamspaceAssignment.projectTeamspaceEntryId; + await autoAssignSingleProjectTeamspaceEntry(client, ticket.id); + if (zammadTicket) { + await refreshSyncedTeamspaceAssignment(client, ticket.id, zammadTeamspaceProjectList(zammadTicket)); + } + projectTeamspaceEntryId = requestedProjectTeamspaceEntryId === null + ? await resolveSyncedTeamspaceEntry(client, ticket.id, userId) + : null; + + if (projectTeamspaceEntryId === null) { + const teamspaceAssignment = await resolveProjectTeamspaceAssignmentForSession( + client, + ticket.id, + userId, + workType, + requestedProjectTeamspaceEntryId, + zammadTicket?.zammadOwnerId ?? null + ); + projectTeamspaceEntryId = teamspaceAssignment.projectTeamspaceEntryId; + } + } else if (workType === "support") { + if (zammadTicket) { + await refreshSyncedTeamspaceAssignment(client, ticket.id, zammadTeamspaceProjectList(zammadTicket)); + } + projectTeamspaceEntryId = await resolveSyncedTeamspaceEntry(client, ticket.id, userId); + + if (projectTeamspaceEntryId === null && ticket.atoss_support) { + projectTeamspaceEntryId = await resolveAtossSupportTeamspaceEntry(client, userId, startedAt); + } } const sessionResult = await client.query( @@ -2233,11 +2339,12 @@ async function createSessionForUser(input: SessionCreationInput) { ended_at, duration_seconds, rounded_minutes, + work_segments, project_teamspace_entry_id, billing_status, billing_updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, CASE WHEN $13::text IS NULL THEN NULL ELSE now() END) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, CASE WHEN $14::text IS NULL THEN NULL ELSE now() END) RETURNING *; `, [ @@ -2252,14 +2359,21 @@ async function createSessionForUser(input: SessionCreationInput) { endedAt.toISOString(), durationSeconds, roundedMinutes, + JSON.stringify(workSegments), projectTeamspaceEntryId, billingStatus ] ); const session = sessionResult.rows[0]; - const zammadArticleBody = zammadArticleHtml ?? ""; const zammadArticleContentType = "text/html" as const; + const zammadArticleDateLabel = berlinDateLabelForDate(startedAt); + const shouldAddZammadArticleDateHeader = + zammadArticleDateHeader + && zammadArticleDateLabel < currentBerlinDateLabel(); + const zammadArticleBody = shouldAddZammadArticleDateHeader + ? zammadSessionArticleBodyWithDateHeader(zammadArticleHtml ?? "", zammadArticleContentType, zammadArticleDateLabel) + : zammadArticleHtml ?? ""; const shouldCreateZammadArticle = createZammadArticle && Boolean(zammadArticleHtml); const zammadArticle: { requested: boolean; @@ -2760,7 +2874,9 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId: requestedProjectTeamspaceEntryId: null, billingStatus: null, createZammadArticle: false, + zammadArticleDateHeader: false, zammadArticleTypeId: 6, + workSegments: [], startedAt, endedAt, durationSeconds, @@ -3127,6 +3243,9 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm }); } + await autoAssignSingleProjectTeamspaceEntry(client, result.rows[0].id); + await refreshSyncedTeamspaceAssignment(client, result.rows[0].id, zammadTeamspaceProjectList(zammadTicket)); + return result.rows[0].id; } @@ -3173,6 +3292,7 @@ type ActiveTimerRow = { started_at: string; paused_total_ms: string | number; paused_at: string | null; + work_segments: unknown; phase: ActiveTimerPhase; }; @@ -3191,10 +3311,86 @@ const activeTimerSelect = ` started_at, paused_total_ms, paused_at, + work_segments, phase FROM active_timers `; +type WorkSegment = { + startedAt: string; + endedAt: string | null; +}; + +function normalizeWorkSegments(value: unknown): WorkSegment[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((item) => { + if (!isPlainObject(item)) { + return []; + } + + const startedAtRaw = typeof item.startedAt === "string" ? item.startedAt : typeof item.started_at === "string" ? item.started_at : null; + const endedAtRaw = typeof item.endedAt === "string" ? item.endedAt : typeof item.ended_at === "string" ? item.ended_at : null; + + if (!startedAtRaw) { + return []; + } + + const startedAt = new Date(startedAtRaw); + const endedAt = endedAtRaw ? new Date(endedAtRaw) : null; + + if (Number.isNaN(startedAt.getTime()) || (endedAt && Number.isNaN(endedAt.getTime()))) { + return []; + } + + if (endedAt && endedAt.getTime() < startedAt.getTime()) { + return []; + } + + return [{ + startedAt: startedAt.toISOString(), + endedAt: endedAt?.toISOString() ?? null + }]; + }).slice(0, 200); +} + +function closeOpenWorkSegment(segments: WorkSegment[], endedAt: Date) { + const closed = segments.map((segment, index) => + index === segments.length - 1 && segment.endedAt === null + ? { ...segment, endedAt: endedAt.toISOString() } + : segment + ); + + return closed.filter((segment) => !segment.endedAt || new Date(segment.endedAt).getTime() > new Date(segment.startedAt).getTime()); +} + +function appendOpenWorkSegment(segments: WorkSegment[], startedAt: Date) { + const closed = closeOpenWorkSegment(segments, startedAt); + return [...closed, { startedAt: startedAt.toISOString(), endedAt: null }].slice(-200); +} + +function workSegmentsForNewTimer(enabled: boolean, startedAt: Date, pausedAt: Date | null) { + if (!enabled) { + return []; + } + + return [{ + startedAt: startedAt.toISOString(), + endedAt: pausedAt?.toISOString() ?? null + }]; +} + +function workSegmentsForSession(value: unknown, startedAt: Date, endedAt: Date) { + return normalizeWorkSegments(value) + .map((segment) => ({ + startedAt: new Date(Math.max(startedAt.getTime(), new Date(segment.startedAt).getTime())).toISOString(), + endedAt: new Date(Math.min(endedAt.getTime(), new Date(segment.endedAt ?? endedAt.toISOString()).getTime())).toISOString() + })) + .filter((segment) => new Date(segment.endedAt).getTime() > new Date(segment.startedAt).getTime()); +} + function serializeActiveTimer(row: ActiveTimerRow) { return { id: row.id, @@ -3210,6 +3406,7 @@ function serializeActiveTimer(row: ActiveTimerRow) { startedAt: new Date(row.started_at).getTime(), pausedTotalMs: Number(row.paused_total_ms), pausedAt: row.paused_at ? new Date(row.paused_at).getTime() : null, + workSegments: normalizeWorkSegments(row.work_segments), phase: row.phase }; } @@ -3229,6 +3426,63 @@ async function activeTimersForUser(client: Pick, userId: return result.rows.map(serializeActiveTimer); } +async function pauseOtherRunningTimers( + client: QueryClient, + userId: string, + now: Date, + options: { exceptId?: number; exceptTicketNumber?: string }, + trackWorkSegments: boolean +) { + const filters = ["user_id = $1", "phase = 'running'"]; + const params: unknown[] = [userId]; + + if (options.exceptId !== undefined) { + params.push(options.exceptId); + filters.push(`id <> $${params.length}`); + } + + if (options.exceptTicketNumber !== undefined) { + params.push(options.exceptTicketNumber); + filters.push(`ticket_number <> $${params.length}`); + } + + const runningResult = await client.query( + ` + ${activeTimerSelect} + WHERE ${filters.join(" AND ")} + FOR UPDATE; + `, + params + ); + + for (const timer of runningResult.rows) { + const currentWorkSegments = normalizeWorkSegments(timer.work_segments); + const nextWorkSegments = trackWorkSegments + ? closeOpenWorkSegment( + currentWorkSegments.length > 0 ? currentWorkSegments : [{ startedAt: new Date(timer.started_at).toISOString(), endedAt: null }], + now + ) + : []; + await client.query( + ` + UPDATE active_timers + SET phase = 'paused', + paused_at = COALESCE(paused_at, $3), + work_segments = $4::jsonb, + updated_at = now() + WHERE id = $1 + AND user_id = $2; + `, + [ + timer.id, + userId, + now.toISOString(), + JSON.stringify(nextWorkSegments) + ] + ); + } +} + function parseTimerPhase(value: unknown, fallback: ActiveTimerPhase = "running") { if (value === undefined || value === null || value === "") { return fallback; @@ -3593,6 +3847,9 @@ type RecurringBillingRow = { organization_name: string; activity: string; work_type: "support" | "consulting"; + teamspace_project: string | null; + hour_budget_minutes: number | null; + manual_consumption_minutes: number; recurrence_type: "weekly" | "every_n_weeks"; interval_value: number; valid_from: string; @@ -3640,6 +3897,9 @@ async function ensureRecurringSessionsForUserPeriod(userId: string, startIso: st o.name AS organization_name, rb.activity, rb.work_type, + rb.teamspace_project, + rb.hour_budget_minutes, + rb.manual_consumption_minutes, rb.recurrence_type, rb.interval_value, rb.valid_from::text, @@ -3729,21 +3989,29 @@ async function ensureRecurringSessionsForUserPeriod(userId: string, startIso: st ON CONFLICT (ticket_number) DO UPDATE SET 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, + 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 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) + ELSE EXCLUDED.work_type END RETURNING id; `, [rule.ticket_number, rule.organization_id, rule.organization_name, rule.work_type, userId] ); - if (rule.work_type === "consulting") { - await ensureProjectForTicket(client, ticketResult.rows[0].id, userId); - } + let projectTeamspaceEntryId: number | null = await ensureRecurringBillingProjectConfiguration( + client, + ticketResult.rows[0].id, + userId, + { + workType: rule.work_type, + teamspaceProject: rule.teamspace_project, + hourBudgetMinutes: rule.hour_budget_minutes, + manualConsumptionMinutes: rule.manual_consumption_minutes + } + ); + projectTeamspaceEntryId ??= await resolveSyncedTeamspaceEntry(client, ticketResult.rows[0].id, userId); await client.query( ` @@ -3761,9 +4029,10 @@ async function ensureRecurringSessionsForUserPeriod(userId: string, startIso: st recurring_billing_id, recurring_billing_slot_id, recurring_occurrence_date, - recurring_occurrence_key + recurring_occurrence_key, + project_teamspace_entry_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::date, $14) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::date, $14, $15) ON CONFLICT (recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL DO NOTHING; `, [ @@ -3780,7 +4049,8 @@ async function ensureRecurringSessionsForUserPeriod(userId: string, startIso: st rule.id, slot.id, occurrenceDate, - occurrenceKey + occurrenceKey, + projectTeamspaceEntryId ] ); existingKeys.add(occurrenceKey); @@ -3942,6 +4212,110 @@ async function ensureProjectForTicket( } } +async function ensureRecurringBillingProjectConfiguration( + client: QueryClient, + ticketId: string, + userId: string, + configuration: { + workType: string; + teamspaceProject: string | null; + hourBudgetMinutes: number | null; + manualConsumptionMinutes: number; + removeProjectWhenUnconfigured?: boolean; + } +) { + const hasProjectConfiguration = Boolean( + configuration.teamspaceProject + || configuration.hourBudgetMinutes !== null + || configuration.manualConsumptionMinutes > 0 + ); + + if (configuration.workType !== "consulting" && !hasProjectConfiguration) { + if (configuration.removeProjectWhenUnconfigured) { + await deleteProjectDataForTicket(client, ticketId, userId); + } + return null; + } + + await ensureProjectForTicket(client, ticketId, userId, { + teamspaceProject: configuration.teamspaceProject, + hourBudgetMinutes: configuration.hourBudgetMinutes + }); + + const projectResult = await client.query<{ id: string }>( + ` + UPDATE projects + SET teamspace_project = $3, + hour_budget_minutes = $4, + manual_consumption_minutes = $5, + updated_at = now() + WHERE ticket_id = $1 + AND user_id = $2 + RETURNING id::text; + `, + [ + ticketId, + userId, + configuration.teamspaceProject, + configuration.hourBudgetMinutes, + configuration.manualConsumptionMinutes + ] + ); + const projectId = projectResult.rows[0]?.id; + + if (!projectId) { + return null; + } + + const entryResult = await client.query<{ id: string }>( + ` + UPDATE project_teamspace_entries + SET teamspace_project = $2, + hour_budget_minutes = $3, + budget_sync_disabled = $3::int IS NOT NULL, + manual_consumption_minutes = $4, + teamspace_api_project_id = CASE + WHEN teamspace_project IS NOT DISTINCT FROM $2::text THEN teamspace_api_project_id + ELSE NULL + END, + teamspace_api_project_url = CASE + WHEN teamspace_project IS NOT DISTINCT FROM $2::text THEN teamspace_api_project_url + ELSE NULL + END, + teamspace_project_resource_id = CASE + WHEN teamspace_project IS NOT DISTINCT FROM $2::text THEN teamspace_project_resource_id + ELSE NULL + END, + updated_at = now() + WHERE project_id = $1 + AND is_default + RETURNING id::text; + `, + [projectId, configuration.teamspaceProject, configuration.hourBudgetMinutes, configuration.manualConsumptionMinutes] + ); + const entryId = entryResult.rows[0]?.id ?? null; + + if (configuration.teamspaceProject) { + await consolidateSharedTeamspaceProjectData(client, [configuration.teamspaceProject]); + } + + if (entryId) { + await client.query( + ` + UPDATE project_teamspace_entries + SET hour_budget_minutes = $2, + budget_sync_disabled = $2::int IS NOT NULL, + manual_consumption_minutes = $3, + updated_at = now() + WHERE id = $1; + `, + [entryId, configuration.hourBudgetMinutes, configuration.manualConsumptionMinutes] + ); + } + + return entryId ? Number(entryId) : null; +} + async function getProjectRowForTicket(client: QueryClient, ticketId: string, projectOwnerUserId: string | null, viewerUserId: string, zammadOwnerId: number | null = null) { const result = await client.query( ` @@ -3959,6 +4333,11 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro AND viewer.active AND viewer.zammad_user_id = t.zammad_owner_id ) + ) AND NOT EXISTS ( + SELECT 1 + FROM recurring_billings rb_edit + WHERE rb_edit.user_id = $3 + AND COALESCE(rb_edit.ticket_number, 'Fix#' || rb_edit.id) = t.ticket_number ), false) AS can_edit, p.ticket_id::text AS ticket_id, t.ticket_number, @@ -4230,6 +4609,8 @@ async function resolveProjectTeamspaceAssignmentForSession( SELECT id::text AS id, is_default FROM project_teamspace_entries WHERE project_id = $1 + AND teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' ORDER BY is_default DESC, display_order ASC, id ASC; `, [projectId] @@ -4243,14 +4624,8 @@ async function resolveProjectTeamspaceAssignmentForSession( return { projectTeamspaceEntryId: Number(entriesResult.rows[0].id) }; } - const defaultEntries = entriesResult.rows.filter((entry) => entry.is_default); - - if (!requestedEntryId && defaultEntries.length === 1) { - return { projectTeamspaceEntryId: Number(defaultEntries[0].id) }; - } - if (!requestedEntryId) { - throw badRequest("projectTeamspaceEntryId is required when a project has multiple Teamspace entries"); + return { projectTeamspaceEntryId: null }; } if (!entriesResult.rows.some((entry) => entry.id === String(requestedEntryId))) { @@ -4260,11 +4635,395 @@ async function resolveProjectTeamspaceAssignmentForSession( return { projectTeamspaceEntryId: requestedEntryId }; } +async function resolveAtossSupportTeamspaceEntry(client: QueryClient, userId: string, startedAt: Date) { + const supportDate = berlinDateLabelForDate(startedAt); + const defaultResult = await client.query<{ teamspace_project: string }>( + ` + SELECT teamspace_project + FROM teamspace_project_exclusions + WHERE atoss_support_default = true + AND (atoss_support_valid_from IS NULL OR atoss_support_valid_from <= $1::date) + AND (atoss_support_valid_to IS NULL OR atoss_support_valid_to >= $1::date) + ORDER BY + atoss_support_valid_from DESC NULLS LAST, + atoss_support_valid_to ASC NULLS LAST, + teamspace_project ASC + LIMIT 1; + `, + [supportDate] + ); + const configuredProject = defaultResult.rows[0]?.teamspace_project ?? null; + + if (!configuredProject) { + return null; + } + + return ensureTeamspaceEntryForAssignmentTarget(client, configuredProject, userId); +} + +async function clearAtossSupportSessionAssignments(client: QueryClient, ticketId: string) { + await client.query( + ` + UPDATE sessions session + SET project_teamspace_entry_id = NULL + WHERE session.ticket_id = $1 + AND EXISTS ( + SELECT 1 + FROM project_teamspace_entries entry + JOIN projects target_project ON target_project.id = entry.project_id + JOIN tickets target_ticket ON target_ticket.id = target_project.ticket_id + WHERE entry.id = session.project_teamspace_entry_id + AND target_ticket.ticket_number = $2 + ); + `, + [ticketId, teamspaceAssignmentTargetTicketNumber] + ); +} + +async function assignAtossSupportSessions(client: QueryClient, ticketId: string) { + const sessionsResult = await client.query<{ id: string; user_id: string; started_at: string }>( + ` + SELECT id::text AS id, user_id::text AS user_id, started_at::text AS started_at + FROM sessions + WHERE ticket_id = $1 + ORDER BY started_at ASC, id ASC; + `, + [ticketId] + ); + let assignedSessions = 0; + + await clearAtossSupportSessionAssignments(client, ticketId); + + for (const session of sessionsResult.rows) { + const entryId = await resolveAtossSupportTeamspaceEntry(client, session.user_id, new Date(session.started_at)); + + if (entryId === null) { + continue; + } + + const updateResult = await client.query( + ` + UPDATE sessions + SET project_teamspace_entry_id = $2 + WHERE id = $1 + AND project_teamspace_entry_id IS DISTINCT FROM $2::bigint; + `, + [session.id, entryId] + ); + assignedSessions += updateResult.rowCount ?? 0; + } + + return assignedSessions; +} + +async function ticketHasConcreteTeamspaceProject(client: QueryClient, ticketId: string) { + const result = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM projects project + JOIN project_teamspace_entries entry ON entry.project_id = project.id + WHERE project.ticket_id = $1 + AND entry.teamspace_project IS NOT NULL + AND btrim(entry.teamspace_project) <> '' + ) AS exists; + `, + [ticketId] + ); + + return Boolean(result.rows[0]?.exists); +} + +type TeamspaceAssignmentMatch = { + id: string; + name: string; + teamspace_project: string; +}; + +async function findMatchingTeamspaceAssignmentRule( + client: QueryClient, + organizationId: number, + workType: "support" | "consulting" +) { + const ruleResult = await client.query( + ` + SELECT rule.id::text AS id, rule.name, rule.teamspace_project + FROM teamspace_assignment_rules rule + JOIN organizations organization ON organization.zammad_id = $1 + WHERE rule.active = true + AND ( + ($2::text = 'support' AND rule.support_enabled = true) + OR ($2::text = 'consulting' AND rule.consulting_enabled = true) + ) + AND ( + rule.customer_owner_option_id IS NULL + OR rule.customer_owner_option_id = organization.customer_owner_option_id + ) + AND ( + rule.hotline_contract_option_id IS NULL + OR rule.hotline_contract_option_id = organization.hotline_contract_option_id + ) + ORDER BY rule.priority ASC, rule.id ASC + LIMIT 1; + `, + [organizationId, workType] + ); + + return ruleResult.rows[0] ?? null; +} + +async function findTeamspaceEntryForProject(client: QueryClient, teamspaceProject: string, userId: string | null) { + const entryResult = await client.query<{ id: string }>( + ` + SELECT entry.id::text AS id + FROM project_teamspace_entries entry + JOIN projects project ON project.id = entry.project_id + WHERE lower(btrim(entry.teamspace_project)) = lower(btrim($1)) + ORDER BY + ($2::bigint IS NOT NULL AND project.user_id = $2::bigint) DESC, + (project.closed_at IS NULL) DESC, + entry.is_default DESC, + entry.display_order ASC, + entry.id ASC + LIMIT 1; + `, + [teamspaceProject, userId] + ); + + return entryResult.rows[0] ? Number(entryResult.rows[0].id) : null; +} + +async function ensureTeamspaceEntryForAssignmentTarget(client: QueryClient, teamspaceProject: string, userId: string | null) { + const existingEntryId = await findTeamspaceEntryForProject(client, teamspaceProject, userId); + + if (existingEntryId !== null) { + return existingEntryId; + } + + const exclusionResult = await client.query<{ + teamspace_api_project_id: string | null; + teamspace_api_project_ids: string[] | null; + teamspace_name: string | null; + }>( + ` + SELECT + teamspace_api_project_id::text AS teamspace_api_project_id, + ARRAY(SELECT value::text FROM unnest(teamspace_api_project_ids) AS value ORDER BY value) AS teamspace_api_project_ids, + teamspace_name + FROM teamspace_project_exclusions + WHERE lower(btrim(teamspace_project)) = lower(btrim($1)) + LIMIT 1; + `, + [teamspaceProject] + ); + const exclusion = exclusionResult.rows[0]; + + if (!exclusion) { + throw badRequest(`Ziel-TS-Projekt "${teamspaceProject}" ist nicht als globale Teamspace-Ausnahme gespeichert`); + } + + await client.query("SELECT pg_advisory_xact_lock(hashtext($1));", [`teamspace-assignment-target:${teamspaceProject.toLocaleLowerCase("de")}`]); + + const concurrentEntryId = await findTeamspaceEntryForProject(client, teamspaceProject, userId); + if (concurrentEntryId !== null) { + return concurrentEntryId; + } + + const targetTicketResult = await client.query<{ id: string }>( + ` + INSERT INTO tickets (ticket_number, title, work_type) + VALUES ($1, 'Technische TS-Zuordnungsziele', 'support') + ON CONFLICT (ticket_number) + DO UPDATE SET title = EXCLUDED.title + RETURNING id::text AS id; + `, + [teamspaceAssignmentTargetTicketNumber] + ); + const targetTicketId = targetTicketResult.rows[0].id; + let targetProjectResult = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM projects + WHERE ticket_id = $1 + AND user_id IS NULL + AND zammad_owner_id IS NULL + ORDER BY id ASC + LIMIT 1 + FOR UPDATE; + `, + [targetTicketId] + ); + + if (!targetProjectResult.rows[0]) { + targetProjectResult = await client.query<{ id: string }>( + ` + INSERT INTO projects (user_id, zammad_owner_id, ticket_id, teamspace_project, description, goals) + VALUES (NULL, NULL, $1, NULL, 'Technischer Container für automatische TS-Zuordnungen', '') + RETURNING id::text AS id; + `, + [targetTicketId] + ); + } + + const apiProjectId = exclusion.teamspace_api_project_id + ?? exclusion.teamspace_api_project_ids?.[0] + ?? null; + const insertResult = await client.query<{ id: string }>( + ` + INSERT INTO project_teamspace_entries ( + project_id, + teamspace_project, + teamspace_description, + is_default, + teamspace_api_project_id, + budget_sync_disabled, + manual_consumption_minutes, + display_order + ) + VALUES ( + $1, + $2, + $3, + false, + $4, + true, + 0, + COALESCE((SELECT MAX(display_order) + 1024 FROM project_teamspace_entries WHERE project_id = $1), 0) + ) + RETURNING id::text AS id; + `, + [targetProjectResult.rows[0].id, teamspaceProject, exclusion.teamspace_name ?? "", apiProjectId] + ); + + return Number(insertResult.rows[0].id); +} + +async function resolveSyncedTeamspaceEntry(client: QueryClient, ticketId: string, userId: string) { + const assignmentResult = await client.query<{ rule_name: string; teamspace_project: string }>( + ` + SELECT rule.name AS rule_name, ticket.teamspace_assignment_project AS teamspace_project + FROM tickets ticket + JOIN teamspace_assignment_rules rule ON rule.id = ticket.teamspace_assignment_rule_id + WHERE ticket.id = $1 + AND ticket.teamspace_assignment_project IS NOT NULL + AND btrim(ticket.teamspace_assignment_project) <> ''; + `, + [ticketId] + ); + const assignment = assignmentResult.rows[0]; + + if (!assignment) { + return null; + } + + return ensureTeamspaceEntryForAssignmentTarget(client, assignment.teamspace_project, userId); +} + +async function refreshSyncedTeamspaceAssignment( + client: QueryClient, + ticketId: string, + incomingTeamspaceProjects: string[] = [] +) { + const ticketResult = await client.query<{ organization_id: string | null; work_type: "support" | "consulting" | null; user_id: string | null; atoss_support: boolean }>( + ` + SELECT organization_id::text AS organization_id, work_type, user_id::text AS user_id, atoss_support + FROM tickets + WHERE id = $1 + FOR UPDATE; + `, + [ticketId] + ); + const ticket = ticketResult.rows[0]; + + if (!ticket) { + return null; + } + + if (ticket.work_type !== "support" && ticket.atoss_support) { + await client.query("UPDATE tickets SET atoss_support = false WHERE id = $1;", [ticketId]); + ticket.atoss_support = false; + } + + const clearAssignment = async () => { + await client.query( + ` + UPDATE tickets + SET teamspace_assignment_rule_id = NULL, + teamspace_assignment_project = NULL, + teamspace_assignment_synced_at = now() + WHERE id = $1; + `, + [ticketId] + ); + }; + + if ( + normalizedTeamspaceProjectList(incomingTeamspaceProjects).length > 0 + || await ticketHasConcreteTeamspaceProject(client, ticketId) + ) { + await clearAssignment(); + return null; + } + + const organizationId = ticket.organization_id ? Number(ticket.organization_id) : null; + + const rule = organizationId && ticket.work_type + ? await findMatchingTeamspaceAssignmentRule(client, organizationId, ticket.work_type) + : null; + + if (!rule) { + await clearAssignment(); + + if (ticket.work_type === "support" && ticket.atoss_support) { + const assignedSessions = await assignAtossSupportSessions(client, ticketId); + return { + id: null, + name: "Atoss Support", + teamspace_project: null, + projectTeamspaceEntryId: null, + assignedSessions + }; + } + + await clearAtossSupportSessionAssignments(client, ticketId); + return null; + } + + const entryId = await ensureTeamspaceEntryForAssignmentTarget(client, rule.teamspace_project, ticket.user_id); + + await client.query( + ` + UPDATE tickets + SET teamspace_assignment_rule_id = $2, + teamspace_assignment_project = $3, + teamspace_assignment_synced_at = now() + WHERE id = $1; + `, + [ticketId, rule.id, rule.teamspace_project] + ); + const sessionsResult = await client.query( + ` + UPDATE sessions + SET project_teamspace_entry_id = $2 + WHERE ticket_id = $1 + AND project_teamspace_entry_id IS DISTINCT FROM $2::bigint; + `, + [ticketId, entryId] + ); + + return { + ...rule, + projectTeamspaceEntryId: entryId, + assignedSessions: sessionsResult.rowCount ?? 0 + }; +} + async function autoAssignSingleProjectTeamspaceEntry(client: QueryClient, ticketId: string, userId: string | null = null) { await client.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 @@ -4272,16 +5031,26 @@ async function autoAssignSingleProjectTeamspaceEntry(client: QueryClient, ticket 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) + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' 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; + 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 + OR NOT EXISTS ( + SELECT 1 + FROM project_teamspace_entries current_pte + WHERE current_pte.id = s.project_teamspace_entry_id + AND current_pte.project_id = sep.project_id + ) + ); `, [ticketId, userId] ); @@ -4295,16 +5064,59 @@ async function autoAssignSingleProjectTeamspaceEntry(client: QueryClient, ticket FROM projects p JOIN project_teamspace_entries pte ON pte.project_id = p.id WHERE p.ticket_id = $1 + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' 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); + WHERE s.ticket_id = single_ticket.ticket_id + AND s.work_type = 'consulting' + AND ($2::bigint IS NULL OR s.user_id = $2) + AND ( + s.project_teamspace_entry_id IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM project_teamspace_entries current_pte + JOIN projects current_project ON current_project.id = current_pte.project_id + WHERE current_pte.id = s.project_teamspace_entry_id + AND current_project.ticket_id = single_ticket.ticket_id + ) + ); + `, + [ticketId, userId] + ); + + await client.query( + ` + WITH multiple_entry_tickets AS ( + SELECT p.ticket_id + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE p.ticket_id = $1 + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + GROUP BY p.ticket_id + HAVING COUNT(pte.id) > 1 + ) + UPDATE sessions session + SET project_teamspace_entry_id = NULL + FROM multiple_entry_tickets multiple_ticket + WHERE session.ticket_id = multiple_ticket.ticket_id + AND session.work_type = 'consulting' + AND ($2::bigint IS NULL OR session.user_id = $2) + AND session.project_teamspace_entry_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM project_teamspace_entries current_entry + JOIN projects current_project ON current_project.id = current_entry.project_id + WHERE current_entry.id = session.project_teamspace_entry_id + AND current_project.ticket_id = multiple_ticket.ticket_id + AND current_entry.teamspace_project IS NOT NULL + AND btrim(current_entry.teamspace_project) <> '' + ); `, [ticketId, userId] ); @@ -4885,6 +5697,7 @@ type TeamspaceRemoteProject = { apiProjectUrl: string | null; mainProjectApiProjectId: number | null; parentApiProjectId: number | null; + customerApiId: number | null; billingType: string | null; name: string | null; customerName: string | null; @@ -4919,7 +5732,9 @@ type TeamspaceProjectIdSyncItem = { type TeamspaceProjectIdSyncPosition = { apiProjectId: string; projectId: string | null; + mainProjectId?: string | null; label: string; + customerName?: string | null; isSubentry: boolean; billingType?: string | null; minutes: number; @@ -5126,6 +5941,7 @@ function teamspaceRemoteProjectFromItem(item: unknown): TeamspaceRemoteProject | apiProjectUrl: teamspaceString(data._url) ?? teamspaceString(item.href), mainProjectApiProjectId: teamspaceLinkApiProjectId(data.mainProject), parentApiProjectId: teamspaceLinkApiProjectId(data.parent), + customerApiId: teamspaceLinkApiProjectId(data.customer), billingType: teamspaceBillingType(data), name: teamspaceString(data.name), customerName: teamspaceProjectCustomerName(data), @@ -5220,6 +6036,76 @@ async function loadTeamspaceRemoteSubprojectsSingleCall(baseUrl: string, deviceI }; } +async function loadTeamspaceRemoteProjectsByApiId( + baseUrl: string, + deviceId: string, + devicePassword: string, + apiProjectIds: Iterable +) { + const ids = [...new Set(Array.from(apiProjectIds).filter((id) => Number.isInteger(id) && id > 0))]; + const projects = new Map(); + + // Teamspace ignores an _id filter on the collection endpoint. Direct lookups + // are therefore required to resolve IDs returned by the time API reliably. + for (let offset = 0; offset < ids.length; offset += 8) { + const chunk = ids.slice(offset, offset + 8); + const resolved = await Promise.all(chunk.map(async (apiProjectId) => { + try { + const payload = await fetchTeamspaceJson(baseUrl, deviceId, devicePassword, `/api/project/${apiProjectId}`); + const project = teamspaceRemoteProjectFromItem(payload); + return project?.apiProjectId === apiProjectId ? project : null; + } catch { + return null; + } + })); + + for (const project of resolved) { + if (project) { + projects.set(project.apiProjectId, project); + } + } + } + + const customerIds = [...new Set(Array.from(projects.values()) + .map((project) => project.customerApiId) + .filter((id): id is number => id !== null))]; + const customerNames = new Map(); + + for (let offset = 0; offset < customerIds.length; offset += 8) { + const chunk = customerIds.slice(offset, offset + 8); + const resolved = await Promise.all(chunk.map(async (customerApiId) => { + try { + const payload = await fetchTeamspaceJson(baseUrl, deviceId, devicePassword, `/api/organization/${customerApiId}`); + if (!isPlainObject(payload)) { + return null; + } + + const data = isPlainObject(payload.data) ? payload.data : payload; + const name = teamspaceString(data.name) ?? teamspaceString(data.companyName) ?? teamspaceString(data.displayName); + return name ? { customerApiId, name } : null; + } catch { + return null; + } + })); + + for (const customer of resolved) { + if (customer) { + customerNames.set(customer.customerApiId, customer.name); + } + } + } + + for (const [apiProjectId, project] of projects) { + const customerName = project.customerApiId ? customerNames.get(project.customerApiId) : null; + + if (customerName) { + projects.set(apiProjectId, { ...project, customerName }); + } + } + + return projects; +} + function teamspaceRemoteProjectsByProjectId(projects: TeamspaceRemoteProject[]) { const byProjectId = new Map(); @@ -5247,6 +6133,8 @@ function teamspaceRemoteProjectsByMainprojectId(projects: TeamspaceRemoteProject type TeamspaceRemoteTimeActual = { projectApiId: number; day: string | null; + description: string | null; + ticketNumber: string | null; minutes: number; billableMinutes: number; }; @@ -5258,6 +6146,13 @@ type TeamspaceTimeActualTotal = { days: Map; }; +type TeamspaceTimeActualSourcePosition = { + projectApiId: number; + ticketNumber: string | null; + description: string | null; + actual: TeamspaceTimeActualTotal; +}; + function currentBerlinMonthLabel() { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", @@ -5320,6 +6215,23 @@ function teamspaceTimeDay(value: unknown) { return match?.[0] ?? null; } +function teamspaceTimeDescription(data: Record) { + for (const value of [data.description, data.comment, data.note, data.text, data.subject, data.title]) { + const description = teamspaceLinkedLabel(value); + + if (description) { + return description; + } + } + + return null; +} + +function teamspaceTicketNumberFromText(value: string | null) { + const match = value?.match(/ticket#([0-9]{6})/i); + return match ? `Ticket#${match[1]}` : null; +} + function teamspaceRemoteTimeActualFromItem(item: unknown): TeamspaceRemoteTimeActual | null { if (!isPlainObject(item)) { return null; @@ -5340,10 +6252,13 @@ function teamspaceRemoteTimeActualFromItem(item: unknown): TeamspaceRemoteTimeAc const minutes = teamspaceOptionalInteger(data.amount) ?? 0; const billableMinutes = teamspaceOptionalInteger(data.amountBillable) ?? minutes; + const description = teamspaceTimeDescription(data); return { projectApiId, day: teamspaceTimeDay(data.date), + description, + ticketNumber: teamspaceTicketNumberFromText(description), minutes: Math.max(0, minutes), billableMinutes: Math.max(0, billableMinutes) }; @@ -5360,20 +6275,60 @@ function teamspaceTimeActualDays(total: TeamspaceTimeActualTotal | undefined) { .sort((left, right) => left.day.localeCompare(right.day)); } -function teamspaceTimeActualSummary(timeActuals: { totals: Map }) { - const positions = Array.from(timeActuals.totals.entries()) - .map(([apiProjectId, actual]) => ({ - apiProjectId: String(apiProjectId), - projectId: null, - label: `API ${apiProjectId}`, - isSubentry: false, - billingType: null, - minutes: actual.minutes, - billableMinutes: actual.billableMinutes, - entryCount: actual.count, - days: teamspaceTimeActualDays(actual) - })) - .sort((left, right) => Number(left.apiProjectId) - Number(right.apiProjectId)); +function addTeamspaceTimeActual(total: TeamspaceTimeActualTotal, actual: TeamspaceRemoteTimeActual) { + total.minutes += actual.minutes; + total.billableMinutes += actual.billableMinutes; + total.count += 1; + + if (actual.day) { + const day = total.days.get(actual.day) ?? { minutes: 0, billableMinutes: 0, count: 0 }; + day.minutes += actual.minutes; + day.billableMinutes += actual.billableMinutes; + day.count += 1; + total.days.set(actual.day, day); + } +} + +function teamspaceTimeActualSummary(timeActuals: { + totals: Map; + projectsByApiId?: Map; + sourceTotals?: Map; + sourceProjectsByApiId?: Map; + sourcePositions?: TeamspaceTimeActualSourcePosition[]; +}) { + const positionProjects = timeActuals.sourceProjectsByApiId ?? timeActuals.projectsByApiId; + const positionValues = timeActuals.sourcePositions + ?? Array.from(timeActuals.sourceTotals ?? timeActuals.totals, ([projectApiId, actual]) => ({ + projectApiId, + ticketNumber: null, + description: null, + actual + })); + const positions = positionValues + .map((position) => { + const { projectApiId, ticketNumber, description, actual } = position; + const project = positionProjects?.get(projectApiId); + const mainProjectId = project?.mainprojectId ?? project?.projectId ?? null; + const label = description || project?.name || project?.description || project?.projectId || `API ${projectApiId}`; + + return { + apiProjectId: String(projectApiId), + projectId: mainProjectId, + mainProjectId, + label, + description: description || project?.description || null, + ticketNumber, + customerName: project?.customerName ?? null, + isSubentry: Boolean(project?.mainProjectApiProjectId && project.mainProjectApiProjectId !== projectApiId), + billingType: project?.billingType ?? null, + minutes: actual.minutes, + billableMinutes: actual.billableMinutes, + entryCount: actual.count, + days: teamspaceTimeActualDays(actual) + }; + }) + .sort((left, right) => Number(left.apiProjectId) - Number(right.apiProjectId) + || String(left.ticketNumber ?? "").localeCompare(String(right.ticketNumber ?? ""))); return positions.reduce((summary, position) => ({ minutes: summary.minutes + position.minutes, @@ -5387,7 +6342,13 @@ async function upsertTeamspaceMonthlyUserActuals( client: QueryClient, userId: string, month: ReturnType, - timeActuals: { totals: Map } + timeActuals: { + totals: Map; + projectsByApiId?: Map; + sourceTotals?: Map; + sourceProjectsByApiId?: Map; + sourcePositions?: TeamspaceTimeActualSourcePosition[]; + } ) { const summary = teamspaceTimeActualSummary(timeActuals); @@ -5444,27 +6405,100 @@ async function loadTeamspaceRemoteTimeActualsSingleCall(baseUrl: string, deviceI } const totals = new Map(); + const sourcePositionsByKey = new Map(); + const actuals = items.map(teamspaceRemoteTimeActualFromItem).filter((entry): entry is TeamspaceRemoteTimeActual => Boolean(entry)); - for (const actual of items.map(teamspaceRemoteTimeActualFromItem).filter((entry): entry is TeamspaceRemoteTimeActual => Boolean(entry))) { + for (const actual of actuals) { const current = totals.get(actual.projectApiId) ?? { minutes: 0, billableMinutes: 0, count: 0, days: new Map() }; - current.minutes += actual.minutes; - current.billableMinutes += actual.billableMinutes; - current.count += 1; + addTeamspaceTimeActual(current, actual); + totals.set(actual.projectApiId, current); - if (actual.day) { - const day = current.days.get(actual.day) ?? { minutes: 0, billableMinutes: 0, count: 0 }; - day.minutes += actual.minutes; - day.billableMinutes += actual.billableMinutes; - day.count += 1; - current.days.set(actual.day, day); + const sourcePositionKey = `${actual.projectApiId}:${actual.ticketNumber ?? ""}`; + const sourcePosition = sourcePositionsByKey.get(sourcePositionKey) ?? { + projectApiId: actual.projectApiId, + ticketNumber: actual.ticketNumber, + description: actual.description, + actual: { minutes: 0, billableMinutes: 0, count: 0, days: new Map() } + }; + sourcePosition.description ??= actual.description; + addTeamspaceTimeActual(sourcePosition.actual, actual); + sourcePositionsByKey.set(sourcePositionKey, sourcePosition); + } + + const sourceProjectsByApiId = await loadTeamspaceRemoteProjectsByApiId( + baseUrl, + deviceId, + devicePassword, + totals.keys() + ); + const unresolvedSourceApiIds = Array.from(totals.keys()).filter((apiProjectId) => !sourceProjectsByApiId.has(apiProjectId)); + + if (unresolvedSourceApiIds.length > 0) { + throw Object.assign( + new Error(`Teamspace-Projekt-ID(s) konnten nicht auf das Hauptprojekt aufgelöst werden: ${unresolvedSourceApiIds.join(", ")}`), + { status: 502 } + ); + } + + const mainProjectIds = new Set(Array.from(totals.keys()).map((apiProjectId) => { + const project = sourceProjectsByApiId.get(apiProjectId); + return project?.mainProjectApiProjectId ?? apiProjectId; + })); + const mainProjectsByApiId = await loadTeamspaceRemoteProjectsByApiId( + baseUrl, + deviceId, + devicePassword, + mainProjectIds + ); + const unresolvedMainApiIds = Array.from(mainProjectIds).filter((apiProjectId) => !mainProjectsByApiId.has(apiProjectId)); + + if (unresolvedMainApiIds.length > 0) { + throw Object.assign( + new Error(`Teamspace-Hauptprojekt-ID(s) konnten nicht aufgelöst werden: ${unresolvedMainApiIds.join(", ")}`), + { status: 502 } + ); + } + + const normalizedTotals = new Map(); + const projectsByApiId = new Map(); + + for (const [sourceApiProjectId, sourceTotal] of totals) { + const sourceProject = sourceProjectsByApiId.get(sourceApiProjectId); + const mainApiProjectId = sourceProject?.mainProjectApiProjectId ?? sourceApiProjectId; + const current = normalizedTotals.get(mainApiProjectId) ?? { + minutes: 0, + billableMinutes: 0, + count: 0, + days: new Map() + }; + current.minutes += sourceTotal.minutes; + current.billableMinutes += sourceTotal.billableMinutes; + current.count += sourceTotal.count; + + for (const [dayLabel, sourceDay] of sourceTotal.days) { + const day = current.days.get(dayLabel) ?? { minutes: 0, billableMinutes: 0, count: 0 }; + day.minutes += sourceDay.minutes; + day.billableMinutes += sourceDay.billableMinutes; + day.count += sourceDay.count; + current.days.set(dayLabel, day); } - totals.set(actual.projectApiId, current); + normalizedTotals.set(mainApiProjectId, current); + const mainProject = mainProjectsByApiId.get(mainApiProjectId) + ?? (sourceProject?.apiProjectId === mainApiProjectId ? sourceProject : null); + + if (mainProject) { + projectsByApiId.set(mainApiProjectId, mainProject); + } } return { scanned: items.length, - totals + totals: normalizedTotals, + projectsByApiId, + sourceTotals: totals, + sourceProjectsByApiId, + sourcePositions: Array.from(sourcePositionsByKey.values()) }; } @@ -5486,6 +6520,16 @@ function monthLabelsForDateRange(startDate: string, endExclusiveDate: string) { return labels; } +function addMonthsToMonthLabel(monthLabel: string, amount: number) { + const date = new Date(`${monthLabel}-01T00:00:00.000Z`); + date.setUTCMonth(date.getUTCMonth() + amount); + return date.toISOString().slice(0, 7); +} + +function compareMonthLabels(left: string, right: string) { + return left.localeCompare(right); +} + async function syncTeamspaceMonthlyActualsForEntries( userId: string, credentials: Awaited>, @@ -5493,14 +6537,6 @@ async function syncTeamspaceMonthlyActualsForEntries( month: ReturnType, entries: TeamspaceMonthlyActualSyncEntry[] ) { - if (entries.length === 0) { - return { - scanned: 0, - mapped: 0, - billableMinutes: 0 - }; - } - const timeActuals = await loadTeamspaceRemoteTimeActualsSingleCall( credentials.baseUrl, credentials.deviceId, @@ -5519,44 +6555,18 @@ async function syncTeamspaceMonthlyActualsForEntries( billing_type: string | null; }>( ` - SELECT DISTINCT ON (entry_id, api_project_id) - entry_id, - api_project_id::text AS api_project_id, - project_id, - label, - is_subentry, - billing_type - FROM ( - SELECT - pte.id::text AS entry_id, - pte.teamspace_api_project_id AS api_project_id, - pte.teamspace_project AS project_id, - NULLIF(btrim(pte.teamspace_description), '') AS label, - false AS is_subentry, - NULL::text AS billing_type, - 0 AS display_order - FROM project_teamspace_entries pte - WHERE pte.id = ANY($2::bigint[]) - AND pte.teamspace_api_project_id IS NOT NULL - - UNION ALL - - SELECT - pte.id::text AS entry_id, - pts.teamspace_api_project_id AS api_project_id, - pts.teamspace_project_id AS project_id, - COALESCE(NULLIF(btrim(pts.alias), ''), NULLIF(btrim(pts.name), ''), pts.teamspace_project_id) AS label, - true AS is_subentry, - pts.billing_type, - pts.display_order + 1 AS display_order - FROM project_teamspace_entries pte - JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte.id - WHERE pte.id = ANY($2::bigint[]) - AND pts.user_id = $1 - ) api_ids - ORDER BY entry_id, api_project_id, display_order ASC; + SELECT + pte.id::text AS entry_id, + pte.teamspace_api_project_id::text AS api_project_id, + pte.teamspace_project AS project_id, + NULLIF(btrim(pte.teamspace_description), '') AS label, + false AS is_subentry, + NULL::text AS billing_type + FROM project_teamspace_entries pte + WHERE pte.id = ANY($1::bigint[]) + AND pte.teamspace_api_project_id IS NOT NULL; `, - [userId, entryIds] + [entryIds] ); const apiRowsByEntryId = new Map(); @@ -5661,6 +6671,335 @@ async function syncTeamspaceMonthlyActualsForEntries( }, { scanned: timeActuals.scanned, mapped: 0, billableMinutes: 0 }); } +type TeamspaceInitialActualsResult = { + userId: string; + fromMonth: string; + toMonth: string; + checkedEntries: number; + matchedEntries: number; + updatedProjectIds: number; + missingEntries: number; + ambiguousEntries: number; + monthsChecked: number; + monthsSkippedClosed: number; + teamspaceTimeEntriesScanned: number; + updatedManualEntries: number; + importedManualMinutes: number; + existingDayBillingMinutes: number; +}; + +async function runTeamspaceInitialActualsSyncForUser(userId: string, adminUserId: string, requestedFromMonth: string | null, requestedToMonth: string | null): Promise { + const userResult = await query<{ + id: string; + active: boolean; + teamspace_user_id: string | null; + has_teamspace_credentials: boolean; + teamspace_initial_actuals_synced_at: string | null; + teamspace_initial_actuals_unlocked_at: string | null; + }>( + ` + SELECT id::text AS id, + active, + teamspace_user_id::text AS teamspace_user_id, + (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, + teamspace_initial_actuals_synced_at::text AS teamspace_initial_actuals_synced_at, + teamspace_initial_actuals_unlocked_at::text AS teamspace_initial_actuals_unlocked_at + FROM users + WHERE id = $1 + AND role = 'user'; + `, + [userId] + ); + const targetUser = userResult.rows[0]; + + if (!targetUser) { + throw Object.assign(new Error("User not found"), { status: 404 }); + } + + if (!targetUser.active) { + throw badRequest("Der Benutzer ist inaktiv"); + } + + if (!targetUser.teamspace_user_id || !targetUser.has_teamspace_credentials) { + throw badRequest("Der Benutzer braucht Teamspace User-ID und Geräte-Credentials"); + } + + if ( + targetUser.teamspace_initial_actuals_synced_at && + (!targetUser.teamspace_initial_actuals_unlocked_at || targetUser.teamspace_initial_actuals_unlocked_at <= targetUser.teamspace_initial_actuals_synced_at) + ) { + throw Object.assign(new Error("Der initiale Teamspace-Istzeiten-Sync ist für diesen Benutzer gesperrt"), { status: 409 }); + } + + const entriesResult = await query<{ + project_id: string; + ticket_id: string; + ticket_number: string; + entry_id: string; + teamspace_project: string; + teamspace_api_project_id: string | null; + teamspace_api_project_url: string | null; + is_default: boolean; + starts_month: string | null; + }>( + ` + SELECT + p.id::text AS project_id, + p.ticket_id::text AS ticket_id, + t.ticket_number, + pte.id::text AS entry_id, + btrim(pte.teamspace_project) AS teamspace_project, + pte.teamspace_api_project_id::text AS teamspace_api_project_id, + pte.teamspace_api_project_url, + pte.is_default, + to_char(date_trunc('month', p.starts_on), 'YYYY-MM') AS starts_month + FROM projects p + JOIN tickets t ON t.id = p.ticket_id + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE p.user_id = $1 + AND p.closed_at IS NULL + AND t.work_type = 'consulting' + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + ORDER BY t.ticket_number ASC, pte.display_order ASC, pte.id ASC; + `, + [userId] + ); + + const toMonth = requestedToMonth ? parseMonth(requestedToMonth).label : currentBerlinMonthLabel(); + const inferredStartMonth = entriesResult.rows + .map((entry) => entry.starts_month) + .filter((month): month is string => Boolean(month)) + .sort()[0] ?? addMonthsToMonthLabel(toMonth, -24); + const fromMonth = requestedFromMonth ? parseMonth(requestedFromMonth).label : inferredStartMonth; + + if (compareMonthLabels(fromMonth, toMonth) > 0) { + throw badRequest("fromMonth must not be after toMonth"); + } + + const credentials = await getTeamspaceCredentialsForUser(userId); + const remote = await loadTeamspaceRemoteProjectsSingleCall( + credentials.baseUrl, + credentials.deviceId, + credentials.devicePassword, + entriesResult.rows.map((entry) => entry.teamspace_project) + ); + const remoteByProjectId = teamspaceRemoteProjectsByProjectId(remote.projects); + const remoteByMainprojectId = teamspaceRemoteProjectsByMainprojectId(remote.projects); + const matchedEntries: TeamspaceSubentrySyncEntry[] = []; + const monthEntries: TeamspaceMonthlyActualSyncEntry[] = []; + let missingEntries = 0; + let ambiguousEntries = 0; + let updatedProjectIds = 0; + + await withTransaction(async (client) => { + const touchedProjects = new Set(); + + for (const entry of entriesResult.rows) { + let matches = teamspaceProjectMatchesForLocalId(entry.teamspace_project, remoteByProjectId, remoteByMainprojectId); + const previousApiMatch = entry.teamspace_api_project_id + ? matches.filter((match) => String(match.apiProjectId) === entry.teamspace_api_project_id) + : []; + + if (previousApiMatch.length === 1) { + matches = previousApiMatch; + } + + if (matches.length === 0) { + missingEntries += 1; + continue; + } + + if (matches.length > 1) { + ambiguousEntries += 1; + continue; + } + + const [match] = matches; + const nextApiProjectUrl = match.apiProjectUrl ?? `${credentials.baseUrl}/api/project/${match.apiProjectId}`; + const nextBudgetMinutes = teamspaceHourBudgetMinutes(match); + await client.query( + ` + UPDATE project_teamspace_entries + SET teamspace_api_project_id = $1, + teamspace_api_project_url = $2, + hour_budget_minutes = $3, + budget_sync_disabled = false, + updated_at = now() + WHERE id = $4; + `, + [match.apiProjectId, nextApiProjectUrl, nextBudgetMinutes, entry.entry_id] + ); + touchedProjects.add(entry.project_id); + matchedEntries.push({ + project_id: entry.project_id, + entry_id: entry.entry_id, + teamspace_project: entry.teamspace_project, + teamspace_api_project_id: String(match.apiProjectId) + }); + monthEntries.push({ + entryId: entry.entry_id, + apiProjectId: match.apiProjectId + }); + } + + await consolidateSharedTeamspaceProjectData(client, entriesResult.rows.map((entry) => entry.teamspace_project)); + + for (const projectId of touchedProjects) { + await refreshProjectBudgetFromTeamspaceEntries(client, projectId); + } + + updatedProjectIds = touchedProjects.size; + }); + + let monthsChecked = 0; + let monthsSkippedClosed = 0; + let teamspaceTimeEntriesScanned = 0; + const importedMinutesByEntryId = new Map(); + + for (let monthLabel = fromMonth; compareMonthLabels(monthLabel, toMonth) <= 0; monthLabel = addMonthsToMonthLabel(monthLabel, 1)) { + const month = parseMonth(monthLabel); + monthsChecked += 1; + + const closureResult = await query<{ closed_at: string }>( + ` + SELECT closed_at + FROM month_closures + WHERE user_id = $1 + AND month = $2::date; + `, + [userId, month.start] + ); + + if (closureResult.rowCount && closureResult.rowCount > 0) { + monthsSkippedClosed += 1; + continue; + } + + const monthSync = await syncTeamspaceMonthlyActualsForEntries(userId, credentials, targetUser.teamspace_user_id, month, monthEntries); + teamspaceTimeEntriesScanned += monthSync.scanned; + + const actualsResult = await query<{ + project_teamspace_entry_id: string; + teamspace_billable_minutes: number; + }>( + ` + SELECT project_teamspace_entry_id::text AS project_teamspace_entry_id, + teamspace_billable_minutes + FROM project_teamspace_monthly_actuals + WHERE user_id = $1 + AND month = $2::date + AND project_teamspace_entry_id = ANY($3::bigint[]); + `, + [userId, month.start, monthEntries.map((entry) => entry.entryId)] + ); + + for (const actual of actualsResult.rows) { + importedMinutesByEntryId.set( + actual.project_teamspace_entry_id, + (importedMinutesByEntryId.get(actual.project_teamspace_entry_id) ?? 0) + Number(actual.teamspace_billable_minutes ?? 0) + ); + } + } + + const localDayValuesResult = await query<{ + entry_id: string; + local_day_minutes: number; + }>( + ` + SELECT + pte.id::text AS entry_id, + COALESCE(SUM(tdb.billed_minutes), 0)::int AS local_day_minutes + FROM project_teamspace_entries pte + JOIN projects p ON p.id = pte.project_id + LEFT JOIN ticket_day_billings tdb + ON tdb.ticket_id = p.ticket_id + AND tdb.user_id = $1 + AND tdb.day >= $2::date + AND tdb.day < ($3::date + interval '1 month')::date + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + AND NOT EXISTS ( + SELECT 1 + FROM month_closures mc + WHERE mc.user_id = $1 + AND mc.month = date_trunc('month', tdb.day)::date + ) + WHERE pte.id = ANY($4::bigint[]) + GROUP BY pte.id; + `, + [userId, `${fromMonth}-01`, `${toMonth}-01`, monthEntries.map((entry) => entry.entryId)] + ); + const localDayMinutesByEntryId = new Map(localDayValuesResult.rows.map((row) => [row.entry_id, Number(row.local_day_minutes ?? 0)])); + let updatedManualEntries = 0; + let importedManualMinutes = 0; + let existingDayBillingMinutes = 0; + + await withTransaction(async (client) => { + const touchedProjects = new Set(); + + for (const entry of matchedEntries) { + const importedMinutes = importedMinutesByEntryId.get(entry.entry_id) ?? 0; + const localDayMinutes = localDayMinutesByEntryId.get(entry.entry_id) ?? 0; + const manualMinutes = Math.max(importedMinutes - localDayMinutes, 0); + const updateResult = await client.query<{ project_id: string; manual_consumption_minutes: number }>( + ` + UPDATE project_teamspace_entries + SET manual_consumption_minutes = $1, + updated_at = CASE + WHEN manual_consumption_minutes IS DISTINCT FROM $1 THEN now() + ELSE updated_at + END + WHERE id = $2 + RETURNING project_id::text AS project_id, manual_consumption_minutes; + `, + [manualMinutes, entry.entry_id] + ); + + if (updateResult.rowCount && updateResult.rowCount > 0) { + updatedManualEntries += 1; + importedManualMinutes += Number(updateResult.rows[0]?.manual_consumption_minutes ?? 0); + existingDayBillingMinutes += localDayMinutes; + touchedProjects.add(updateResult.rows[0].project_id); + } + } + + for (const projectId of touchedProjects) { + await refreshProjectBudgetFromTeamspaceEntries(client, projectId); + } + }); + + await query( + ` + UPDATE users + SET teamspace_initial_actuals_synced_at = now(), + teamspace_initial_actuals_synced_by = $2, + updated_at = now() + WHERE id = $1; + `, + [userId, adminUserId] + ); + + return { + userId, + fromMonth, + toMonth, + checkedEntries: entriesResult.rows.length, + matchedEntries: matchedEntries.length, + updatedProjectIds, + missingEntries, + ambiguousEntries, + monthsChecked, + monthsSkippedClosed, + teamspaceTimeEntriesScanned, + updatedManualEntries, + importedManualMinutes, + existingDayBillingMinutes + }; +} + async function refreshTeamspaceActualsForActivePeriod(userId: string, period: ParsedPeriod, teamspaceTimeTrackingEnabled: boolean) { if (!teamspaceTimeTrackingEnabled) { return; @@ -5698,9 +7037,7 @@ async function refreshTeamspaceActualsForActivePeriod(userId: string, period: Pa pte.teamspace_api_project_id::int AS "apiProjectId" FROM projects p JOIN project_teamspace_entries pte ON pte.project_id = p.id - LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) WHERE pte.teamspace_api_project_id IS NOT NULL - AND tpe.teamspace_project IS NULL AND ( p.user_id = $1 OR EXISTS ( @@ -5716,10 +7053,6 @@ async function refreshTeamspaceActualsForActivePeriod(userId: string, period: Pa [userId, period.startIso, period.endIso] ); - if (entriesResult.rows.length === 0) { - return; - } - for (const monthLabel of monthLabelsForDateRange(periodStart, periodEndExclusive)) { await syncTeamspaceMonthlyActualsForEntries( userId, @@ -6421,16 +7754,22 @@ async function refreshProjectBudgetFromTeamspaceEntries(client: QueryClient, pro ` UPDATE projects p SET hour_budget_minutes = entry_budget.hour_budget_minutes, + manual_consumption_minutes = entry_budget.manual_consumption_minutes, updated_at = now() FROM ( SELECT project_id, - COALESCE(SUM(hour_budget_minutes) FILTER (WHERE hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes + COALESCE(SUM(hour_budget_minutes) FILTER (WHERE hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes, + COALESCE(SUM(manual_consumption_minutes), 0)::int AS manual_consumption_minutes FROM project_teamspace_entries WHERE project_id = $1 GROUP BY project_id ) entry_budget - WHERE p.id = entry_budget.project_id; + WHERE p.id = entry_budget.project_id + AND ( + p.hour_budget_minutes IS DISTINCT FROM entry_budget.hour_budget_minutes + OR p.manual_consumption_minutes IS DISTINCT FROM entry_budget.manual_consumption_minutes + ); `, [projectId] ); @@ -6983,11 +8322,24 @@ async function syncProjectTeamspaceEntriesFromZammad( const firstZammadProject = zammadProjects[0]; const firstZammadEntry = existingEntries.find((entry) => entry.teamspace_project?.trim() === firstZammadProject); + if (zammadProjects.length > 1 && blankEntries.length > 0) { + await client.query( + ` + UPDATE sessions + SET project_teamspace_entry_id = NULL + WHERE project_teamspace_entry_id = ANY($1::bigint[]); + `, + [blankEntries.map((entry) => Number(entry.id))] + ); + } + if (blankEntries.length > 0 && firstZammadProject) { if (firstZammadEntry) { await mergeTeamspaceEntriesIntoTarget(client, blankEntries.map((entry) => entry.id), firstZammadEntry.id); await client.query("UPDATE project_teamspace_entries SET is_default = (id = $2), updated_at = now() WHERE project_id = $1;", [projectId, firstZammadEntry.id]); - await assignUnassignedProjectSessionsToTeamspaceEntry(client, projectId, firstZammadEntry.id); + if (zammadProjects.length === 1) { + await assignUnassignedProjectSessionsToTeamspaceEntry(client, projectId, firstZammadEntry.id); + } defaultEntry = { ...firstZammadEntry, is_default: true }; existingEntries = existingEntries .filter((entry) => !blankEntries.some((blank) => blank.id === entry.id)) @@ -7009,7 +8361,9 @@ async function syncProjectTeamspaceEntriesFromZammad( `, [defaultEntry.id, firstZammadProject] ); - await assignUnassignedProjectSessionsToTeamspaceEntry(client, projectId, defaultEntry.id); + if (zammadProjects.length === 1) { + await assignUnassignedProjectSessionsToTeamspaceEntry(client, projectId, defaultEntry.id); + } defaultEntry = { ...defaultEntry, teamspace_project: firstZammadProject, is_default: true }; existingEntries = existingEntries .filter((entry) => !additionalBlankEntryIds.includes(entry.id)) @@ -7051,6 +8405,147 @@ async function syncProjectTeamspaceEntriesFromZammad( await consolidateSharedTeamspaceProjectData(client, zammadProjects); } +async function plannedTeamspaceAssignmentChanges( + client: QueryClient, + ticketId: string, + organizationId: number | null, + workType: "support" | "consulting" | null, + incomingTeamspaceProjects: string[], + preferredUserId: string | null, + projectWillBeRemoved: boolean +) { + const currentResult = await client.query<{ + rule_id: string | null; + rule_name: string | null; + teamspace_project: string | null; + }>( + ` + SELECT + ticket.teamspace_assignment_rule_id::text AS rule_id, + rule.name AS rule_name, + ticket.teamspace_assignment_project AS teamspace_project + FROM tickets ticket + LEFT JOIN teamspace_assignment_rules rule ON rule.id = ticket.teamspace_assignment_rule_id + WHERE ticket.id = $1; + `, + [ticketId] + ); + const current = currentResult.rows[0] ?? null; + const concreteEntriesResult = projectWillBeRemoved + ? { rows: [] as Array<{ id: string; teamspace_project: string }> } + : await client.query<{ id: string; teamspace_project: string }>( + ` + SELECT entry.id::text AS id, entry.teamspace_project + FROM projects project + JOIN project_teamspace_entries entry ON entry.project_id = project.id + WHERE project.ticket_id = $1 + AND entry.teamspace_project IS NOT NULL + AND btrim(entry.teamspace_project) <> '' + ORDER BY entry.display_order ASC, entry.id ASC; + `, + [ticketId] + ); + const concreteEntries = concreteEntriesResult.rows; + const hasConcreteProject = concreteEntries.length > 0; + const hasIncomingProject = normalizedTeamspaceProjectList(incomingTeamspaceProjects).length > 0; + const rule = !hasConcreteProject && !hasIncomingProject && organizationId && workType + ? await findMatchingTeamspaceAssignmentRule(client, organizationId, workType) + : null; + const changes: ZammadSyncChange[] = []; + + if (!rule) { + if (current?.rule_id || current?.teamspace_project) { + changes.push({ + field: "Automatische TS-Zuordnung", + from: current.teamspace_project ? `${current.teamspace_project}${current.rule_name ? ` (${current.rule_name})` : ""}` : current.rule_name, + to: null + }); + } + + if (concreteEntries.length === 1) { + const mismatchResult = await client.query<{ count: string }>( + ` + SELECT COUNT(*)::text AS count + FROM sessions + WHERE ticket_id = $1 + AND work_type = 'consulting' + AND project_teamspace_entry_id IS DISTINCT FROM $2::bigint; + `, + [ticketId, concreteEntries[0].id] + ); + const mismatchCount = Number(mismatchResult.rows[0]?.count ?? 0); + + if (mismatchCount > 0) { + changes.push({ + field: "Session-Zuordnung", + from: `${mismatchCount} Session(s) abweichend oder ohne TS-Projekt`, + to: concreteEntries[0].teamspace_project + }); + } + } else if (concreteEntries.length > 1) { + const invalidResult = await client.query<{ count: string }>( + ` + SELECT COUNT(*)::text AS count + FROM sessions session + WHERE session.ticket_id = $1 + AND session.work_type = 'consulting' + AND session.project_teamspace_entry_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM project_teamspace_entries entry + JOIN projects project ON project.id = entry.project_id + WHERE entry.id = session.project_teamspace_entry_id + AND project.ticket_id = $1 + ); + `, + [ticketId] + ); + const invalidCount = Number(invalidResult.rows[0]?.count ?? 0); + + if (invalidCount > 0) { + changes.push({ + field: "Session-Zuordnung", + from: `${invalidCount} automatische Session-Zuordnung(en)`, + to: "manuelle Auswahl aus mehreren TS-Projekten" + }); + } + } + + return changes; + } + + const entryId = await findTeamspaceEntryForProject(client, rule.teamspace_project, preferredUserId); + + if (current?.rule_id !== rule.id || current?.teamspace_project?.trim().toLocaleLowerCase("de") !== rule.teamspace_project.trim().toLocaleLowerCase("de")) { + changes.push({ + field: "Automatische TS-Zuordnung", + from: current?.teamspace_project ? `${current.teamspace_project}${current.rule_name ? ` (${current.rule_name})` : ""}` : null, + to: `${rule.teamspace_project} (${rule.name})` + }); + } + + const mismatchedSessionsResult = await client.query<{ count: string }>( + ` + SELECT COUNT(*)::text AS count + FROM sessions + WHERE ticket_id = $1 + AND ($2::bigint IS NULL OR project_teamspace_entry_id IS DISTINCT FROM $2::bigint); + `, + [ticketId, entryId] + ); + const mismatchedSessions = Number(mismatchedSessionsResult.rows[0]?.count ?? 0); + + if (mismatchedSessions > 0) { + changes.push({ + field: "Session-Zuordnung", + from: `${mismatchedSessions} Session(s) abweichend oder ohne TS-Projekt`, + to: rule.teamspace_project + }); + } + + return changes; +} + async function planExistingZammadTicketSync( client: QueryClient, localTicket: LocalZammadSyncTicket, @@ -7156,7 +8651,34 @@ async function planExistingZammadTicketSync( changes.push({ field: "ToDo", from: null, to: `${pendingTodo.title} (${pendingTodo.day})` }); } - if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) { + const closedSupportNeedsTypeSync = + zammadTicket.workType === "support" + && zammadTicket.zammadStateId === 4 + && fields.type + && !isProjectDataProtected + && (localTicket.work_type !== "support" || await ticketHasAnyProject(client, localTicket.id)); + const metadataOnly = zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4 && !closedSupportNeedsTypeSync; + + if (!isMerge) { + const effectiveOrganizationId = !metadataOnly && fields.organization + ? zammadTicket.organizationId + : numberOrNull(localTicket.organization_id); + const effectiveWorkType = !metadataOnly && fields.type && !isProjectDataProtected + ? zammadTicket.workType + : localTicket.work_type ?? zammadTicket.workType; + const projectWillBeRemoved = !metadataOnly && fields.type && !isProjectDataProtected && zammadTicket.workType === "support"; + changes.push(...await plannedTeamspaceAssignmentChanges( + client, + localTicket.id, + effectiveOrganizationId, + effectiveWorkType, + zammadTeamspaceProjectList(zammadTicket), + localTicket.user_id, + projectWillBeRemoved + )); + } + + if (metadataOnly) { const resultChanges = zammadSyncResultChanges(changes); return resultChanges.length > 0 @@ -7924,7 +9446,9 @@ async function runTicketZammadMailSessionImport( requestedProjectTeamspaceEntryId: null, billingStatus: "billable", createZammadArticle: false, + zammadArticleDateHeader: false, zammadArticleTypeId: 6, + workSegments: [], startedAt, endedAt, durationSeconds: article.durationSeconds, @@ -8645,7 +10169,13 @@ async function applyZammadTicketSync( localTicket = resolvedLocalTicket; const isMergeProtected = isMerge || isLocalMergedTicket(localTicket); - const metadataOnly = zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4; + const closedSupportNeedsTypeSync = + zammadTicket.workType === "support" + && zammadTicket.zammadStateId === 4 + && fields.type + && !isMergeProtected + && (localTicket.work_type !== "support" || await ticketHasAnyProject(client, localTicket.id)); + const metadataOnly = zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4 && !closedSupportNeedsTypeSync; const shouldResolveOrganization = !metadataOnly && (fields.organization || !localTicket.work_type); const organization = shouldResolveOrganization ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; const ownerMapping = await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId); @@ -8811,6 +10341,38 @@ async function applyZammadTicketSync( return "updated" as const; } +async function applyZammadTicketSyncWithAssignment( + client: QueryClient, + localTicket: LocalZammadSyncTicket, + zammadTicket: ZammadTicketImport, + userId: string, + fields: ZammadSyncExistingFields +) { + const status = await applyZammadTicketSync(client, localTicket, zammadTicket, userId, fields); + + if (status === "organization-missing" || status === "not-owner") { + return status; + } + + const targetResult = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM tickets + WHERE ticket_number = $1 OR zammad_ticket_id = $2 + ORDER BY CASE WHEN ticket_number = $1 THEN 0 ELSE 1 END, id ASC + LIMIT 1; + `, + [zammadTicket.ticketNumber, zammadTicket.zammadTicketId] + ); + + if (targetResult.rows[0]) { + await autoAssignSingleProjectTeamspaceEntry(client, targetResult.rows[0].id); + await refreshSyncedTeamspaceAssignment(client, targetResult.rows[0].id, zammadTeamspaceProjectList(zammadTicket)); + } + + return status; +} + async function reopenPeriodsForSession( client: { query: (text: string, params?: unknown[]) => Promise }, ticketId: string, @@ -8887,7 +10449,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use CASE WHEN entry_time_comparison.ignored THEN 0 ELSE ptma.teamspace_billable_minutes - - CASE WHEN entry_time_comparison.comparable THEN COALESCE(tt_entry.billed_minutes, 0) ELSE 0 END + - COALESCE(tt_entry.billed_minutes, 0) END ), 0)::int AS teamspace_month_delta_minutes, COALESCE(jsonb_agg( @@ -8903,7 +10465,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use CASE WHEN entry_time_comparison.ignored THEN 0 ELSE ptma.teamspace_billable_minutes - - CASE WHEN entry_time_comparison.comparable THEN COALESCE(tt_entry.billed_minutes, 0) ELSE 0 END + - COALESCE(tt_entry.billed_minutes, 0) END )::int, 'positions', ptma.positions @@ -9102,6 +10664,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio ? `ptma.month >= date_trunc('month', ${periodStartDateSql})::date AND ptma.month < (date_trunc('month', (${periodEndDateSql} - 1)) + interval '1 month')::date` : `ptma.month = date_trunc('month', ${periodStartDateSql})::date`; + const teamspaceActualMonthPredicateFor = (alias: string) => teamspaceActualMonthPredicate.replaceAll("ptma.", `${alias}.`); const projectTeamspaceEntryPeriodActualsCte = periodType === "week" ? ` @@ -9147,16 +10710,31 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio : ` project_teamspace_entry_period_actuals AS ( SELECT - ptma.project_teamspace_entry_id, - COALESCE(SUM(ptma.teamspace_minutes), 0)::int AS teamspace_minutes, - COALESCE(SUM(ptma.teamspace_billable_minutes), 0)::int AS teamspace_billable_minutes, - COALESCE(SUM(ptma.teamspace_time_entry_count), 0)::int AS teamspace_time_entry_count, - COALESCE(jsonb_agg(position.value ORDER BY ptma.month ASC, position.ordinality ASC) FILTER (WHERE position.value IS NOT NULL), '[]'::jsonb) AS positions - FROM project_teamspace_monthly_actuals ptma - LEFT JOIN LATERAL jsonb_array_elements(COALESCE(ptma.positions, '[]'::jsonb)) WITH ORDINALITY AS position(value, ordinality) ON true - WHERE ptma.user_id = $3 - AND ${teamspaceActualMonthPredicate} - GROUP BY ptma.project_teamspace_entry_id + totals.project_teamspace_entry_id, + totals.teamspace_minutes, + totals.teamspace_billable_minutes, + totals.teamspace_time_entry_count, + COALESCE(positions.positions, '[]'::jsonb) AS positions + FROM ( + SELECT + ptma.project_teamspace_entry_id, + COALESCE(SUM(ptma.teamspace_minutes), 0)::int AS teamspace_minutes, + COALESCE(SUM(ptma.teamspace_billable_minutes), 0)::int AS teamspace_billable_minutes, + COALESCE(SUM(ptma.teamspace_time_entry_count), 0)::int AS teamspace_time_entry_count + FROM project_teamspace_monthly_actuals ptma + WHERE ptma.user_id = $3 + AND ${teamspaceActualMonthPredicate} + GROUP BY ptma.project_teamspace_entry_id + ) totals + LEFT JOIN LATERAL ( + SELECT + COALESCE(jsonb_agg(position.value ORDER BY ptma_pos.month ASC, position.ordinality ASC) FILTER (WHERE position.value IS NOT NULL), '[]'::jsonb) AS positions + FROM project_teamspace_monthly_actuals ptma_pos + LEFT JOIN LATERAL jsonb_array_elements(COALESCE(ptma_pos.positions, '[]'::jsonb)) WITH ORDINALITY AS position(value, ordinality) ON true + WHERE ptma_pos.user_id = $3 + AND ${teamspaceActualMonthPredicateFor("ptma_pos")} + AND ptma_pos.project_teamspace_entry_id = totals.project_teamspace_entry_id + ) positions ON true )`; const teamspaceApiBillableMinutesSql = periodType === "week" @@ -9359,7 +10937,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio CASE WHEN entry_time_comparison.ignored THEN 0 ELSE ptma.teamspace_billable_minutes - - CASE WHEN entry_time_comparison.comparable THEN COALESCE(tt_entry.billed_minutes, 0) ELSE 0 END + - COALESCE(tt_entry.billed_minutes, 0) END ), 0)::int AS teamspace_month_delta_minutes, COALESCE(jsonb_agg( @@ -9375,7 +10953,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio CASE WHEN entry_time_comparison.ignored THEN 0 ELSE ptma.teamspace_billable_minutes - - CASE WHEN entry_time_comparison.comparable THEN COALESCE(tt_entry.billed_minutes, 0) ELSE 0 END + - COALESCE(tt_entry.billed_minutes, 0) END )::int, 'positions', ptma.positions @@ -9461,6 +11039,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio acknowledgedMissingCrmResult, mismatchResult, teamspaceDayResult, + scopedTicketDayResult, + invalidTeamspaceBillingResult, teamspaceMonthMismatchResult, closureResult ] = await Promise.all([ @@ -9701,55 +11281,168 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio ${baseCte}, teamspace_actual_day AS ( SELECT - p.ticket_id, (day.value->>'day') AS day, + position.value->>'apiProjectId' AS api_project_id, + position.value->>'projectId' AS project_id, + position.value->>'mainProjectId' AS main_project_id, + COALESCE( + lower(NULLIF(position.value->>'ticketNumber', '')), + substring( + lower(concat_ws(' ', position.value->>'label', position.value->>'projectId', position.value->>'description')) + FROM '(ticket#[0-9]{6})' + ) + ) AS ticket_number_hint, + NULLIF(position.value->>'customerName', '') AS customer_name, + COALESCE(NULLIF(position.value->>'label', ''), concat('API ', position.value->>'apiProjectId')) AS project_teamspace_project, COALESCE(SUM((day.value->>'billableMinutes')::int), 0)::int AS crm_billed_minutes, - COALESCE(SUM((day.value->>'entryCount')::int), 0)::int AS teamspace_entry_count, - NULLIF( - string_agg( - DISTINCT NULLIF(COALESCE(position.value->>'label', pte.teamspace_description, pte.teamspace_project), ''), - ' + ' - ORDER BY NULLIF(COALESCE(position.value->>'label', pte.teamspace_description, pte.teamspace_project), '') - ), - '' - ) AS project_teamspace_project - FROM projects p - JOIN project_teamspace_entries pte ON pte.project_id = p.id - JOIN project_teamspace_entry_period_actuals ptma ON ptma.project_teamspace_entry_id = pte.id - LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) - JOIN LATERAL jsonb_array_elements(COALESCE(ptma.positions, '[]'::jsonb)) AS position(value) ON true + COALESCE(SUM((day.value->>'entryCount')::int), 0)::int AS teamspace_entry_count + FROM teamspace_monthly_user_actuals tmua + JOIN LATERAL jsonb_array_elements(COALESCE(tmua.positions, '[]'::jsonb)) AS position(value) ON true JOIN LATERAL jsonb_array_elements(COALESCE(position.value->'days', '[]'::jsonb)) AS day(value) ON (day.value->>'day') >= ${periodStartDateSql}::text AND (day.value->>'day') < ${periodEndDateSql}::text - WHERE tpe.teamspace_project IS NULL - AND ( - p.user_id = $3 - OR EXISTS ( - SELECT 1 - FROM sessions viewer_sessions - WHERE viewer_sessions.ticket_id = p.ticket_id - AND viewer_sessions.user_id = $3 - AND viewer_sessions.started_at >= $1::timestamptz - AND viewer_sessions.started_at < $2::timestamptz + WHERE tmua.user_id = $3 + AND ${teamspaceActualMonthPredicateFor("tmua")} + GROUP BY + day.value->>'day', + position.value->>'apiProjectId', + position.value->>'projectId', + position.value->>'mainProjectId', + COALESCE( + lower(NULLIF(position.value->>'ticketNumber', '')), + substring( + lower(concat_ws(' ', position.value->>'label', position.value->>'projectId', position.value->>'description')) + FROM '(ticket#[0-9]{6})' ) - ) - GROUP BY p.ticket_id, day.value->>'day' + ), + NULLIF(position.value->>'customerName', ''), + COALESCE(NULLIF(position.value->>'label', ''), concat('API ', position.value->>'apiProjectId')) ) SELECT - tad.ticket_id::text AS ticket_id, - t.ticket_number, - COALESCE(day_sessions.organization_name, o.name, t.customer_name, 'Keine Organisation') AS organization_name, + mapped_ticket.ticket_id::text AS ticket_id, + mapped_ticket.ticket_number, + COALESCE(day_sessions.organization_name, mapped_ticket.organization_name, tad.customer_name, 'Nicht zugeordnet') AS organization_name, tad.day::text AS day, COALESCE(td.tracked_minutes, 0)::int AS tracked_minutes, tad.crm_billed_minutes, tad.teamspace_entry_count, (tad.crm_billed_minutes - COALESCE(td.tracked_minutes, 0))::int AS delta_minutes, COALESCE(day_sessions.session_count, 0)::int AS session_count, - COALESCE(day_sessions.project_teamspace_project, tad.project_teamspace_project) AS project_teamspace_project + COALESCE(day_sessions.project_teamspace_project, mapped_ticket.project_teamspace_project, tad.project_teamspace_project) AS project_teamspace_project, + tad.api_project_id, + tad.project_id AS teamspace_project_id, + COALESCE(mapped_ticket.teamspace_excluded, false) AS teamspace_excluded, + COALESCE(mapped_ticket.has_recurring_teamspace_project, false) AS has_recurring_teamspace_project, + mapped_ticket.work_type FROM teamspace_actual_day tad - JOIN tickets t ON t.id = tad.ticket_id - LEFT JOIN organizations o ON o.zammad_id = t.organization_id - LEFT JOIN ticket_day td ON td.ticket_id = tad.ticket_id AND td.day::text = tad.day + LEFT JOIN LATERAL ( + SELECT + t.id AS ticket_id, + t.ticket_number, + t.work_type, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = $3 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + AND rb.teamspace_project IS NOT NULL + AND btrim(rb.teamspace_project) <> '' + ) AS has_recurring_teamspace_project, + COALESCE(o.name, t.customer_name, 'Keine Organisation') AS organization_name, + COALESCE(NULLIF(pte.teamspace_description, ''), pte.teamspace_project, tad.project_teamspace_project) AS project_teamspace_project + FROM tickets t + LEFT JOIN projects p ON p.ticket_id = t.id + LEFT JOIN project_teamspace_entries pte ON pte.project_id = p.id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + WHERE ( + (tad.ticket_number_hint IS NOT NULL AND lower(t.ticket_number) = tad.ticket_number_hint) + OR ( + tad.ticket_number_hint IS NULL + AND ( + pte.teamspace_api_project_id::text = tad.api_project_id + OR lower(btrim(pte.teamspace_project)) = lower(btrim(tad.project_id)) + OR lower(btrim(pte.teamspace_project)) = lower(btrim(tad.main_project_id)) + OR EXISTS ( + SELECT 1 + FROM recurring_billings rb + LEFT JOIN teamspace_project_resources resource + ON lower(btrim(resource.teamspace_project)) = lower(btrim(rb.teamspace_project)) + WHERE rb.user_id = $3 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + AND rb.teamspace_project IS NOT NULL + AND btrim(rb.teamspace_project) <> '' + AND ( + lower(btrim(rb.teamspace_project)) = lower(btrim(tad.project_id)) + OR lower(btrim(rb.teamspace_project)) = lower(btrim(tad.main_project_id)) + OR lower(btrim(rb.teamspace_project)) = lower(btrim(tad.api_project_id)) + OR resource.teamspace_api_project_id::text = tad.api_project_id + ) + ) + ) + ) + ) + AND ( + tad.ticket_number_hint IS NOT NULL + OR p.user_id = $3 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = t.id + AND viewer_sessions.user_id = $3 + AND viewer_sessions.started_at >= $1::timestamptz + AND viewer_sessions.started_at < $2::timestamptz + ) + ) + ORDER BY + (tad.ticket_number_hint IS NOT NULL AND lower(t.ticket_number) = tad.ticket_number_hint) DESC, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = $3 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + AND rb.teamspace_project IS NOT NULL + AND btrim(rb.teamspace_project) <> '' + ) DESC, + (p.user_id = $3) DESC, + pte.id DESC NULLS LAST + LIMIT 1 + ) entry_ticket ON true + LEFT JOIN LATERAL ( + SELECT + tpe.teamspace_project, + tpe.teamspace_name, + tpe.customer_name + FROM teamspace_project_exclusions tpe + WHERE ( + tad.api_project_id ~ '^[0-9]+$' + AND ( + tad.api_project_id::bigint = tpe.teamspace_api_project_id + OR tad.api_project_id::bigint = ANY(tpe.teamspace_api_project_ids) + ) + ) + OR ( + tad.project_id IS NOT NULL + AND btrim(tad.project_id) <> '' + AND lower(btrim(tpe.teamspace_project)) = lower(btrim(tad.project_id)) + ) + OR ( + tad.main_project_id IS NOT NULL + AND btrim(tad.main_project_id) <> '' + AND lower(btrim(tpe.teamspace_project)) = lower(btrim(tad.main_project_id)) + ) + LIMIT 1 + ) excluded_project ON true + LEFT JOIN LATERAL ( + SELECT + entry_ticket.ticket_id, + entry_ticket.ticket_number, + entry_ticket.work_type, + entry_ticket.has_recurring_teamspace_project, + COALESCE(entry_ticket.organization_name, excluded_project.customer_name) AS organization_name, + COALESCE(entry_ticket.project_teamspace_project, excluded_project.teamspace_name, excluded_project.teamspace_project) AS project_teamspace_project, + (excluded_project.teamspace_project IS NOT NULL) AS teamspace_excluded + ) mapped_ticket ON true + LEFT JOIN ticket_day td ON td.ticket_id = mapped_ticket.ticket_id AND td.day::text = tad.day LEFT JOIN LATERAL ( SELECT MIN(sb.organization_name) AS organization_name, @@ -9760,11 +11453,207 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio '' ) AS project_teamspace_project FROM session_base sb - WHERE sb.ticket_id = tad.ticket_id + WHERE sb.ticket_id = mapped_ticket.ticket_id AND sb.day::text = tad.day ) day_sessions ON true WHERE tad.crm_billed_minutes > 0 - ORDER BY tad.day ASC, t.ticket_number ASC; + ORDER BY tad.day ASC, mapped_ticket.ticket_number ASC NULLS LAST, tad.api_project_id ASC; + `, + [period.startIso, period.endIso, userId] + ), + query( + ` + WITH scoped_ticket_days AS ( + SELECT + t.id AS ticket_id, + t.ticket_number, + COALESCE(o.name, t.customer_name, 'Keine Organisation') AS organization_name, + (s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day, + COUNT(*)::int AS session_count, + NULLIF( + string_agg(DISTINCT COALESCE(NULLIF(pte.teamspace_description, ''), pte.teamspace_project), ' + '), + '' + ) AS project_teamspace_project, + EXISTS ( + SELECT 1 + FROM project_teamspace_entries comparison_entry + JOIN projects comparison_project ON comparison_project.id = comparison_entry.project_id + JOIN teamspace_project_exclusions comparison_exclusion ON ( + lower(btrim(comparison_exclusion.teamspace_project)) = lower(btrim(comparison_entry.teamspace_project)) + OR comparison_entry.teamspace_api_project_id = comparison_exclusion.teamspace_api_project_id + OR comparison_entry.teamspace_api_project_id = ANY(comparison_exclusion.teamspace_api_project_ids) + ) + WHERE comparison_project.ticket_id = t.id + AND comparison_project.user_id = $3 + ) AS teamspace_excluded, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = $3 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + AND rb.teamspace_project IS NOT NULL + AND btrim(rb.teamspace_project) <> '' + ) AS has_recurring_teamspace_project, + t.work_type + FROM sessions s + JOIN tickets t ON t.id = s.ticket_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id + WHERE s.user_id = $3 + AND s.started_at >= $1::timestamptz + AND s.started_at < $2::timestamptz + AND s.billing_status = 'billed' + AND ( + t.work_type = 'support' + OR ( + t.work_type = 'consulting' + AND EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions ticket_exclusion + WHERE lower(btrim(ticket_exclusion.teamspace_project)) = lower(btrim(pte.teamspace_project)) + OR pte.teamspace_api_project_id = ticket_exclusion.teamspace_api_project_id + OR pte.teamspace_api_project_id = ANY(ticket_exclusion.teamspace_api_project_ids) + ) + ) + OR EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = $3 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + AND rb.teamspace_project IS NOT NULL + AND btrim(rb.teamspace_project) <> '' + ) + ) + GROUP BY + t.id, + t.ticket_number, + COALESCE(o.name, t.customer_name, 'Keine Organisation'), + (s.started_at AT TIME ZONE 'Europe/Berlin')::date, + t.work_type + ) + SELECT + scoped.ticket_id::text AS ticket_id, + scoped.ticket_number, + scoped.organization_name, + scoped.day::text AS day, + COALESCE(local_teamspace.billed_minutes, 0)::int AS tracked_minutes, + scoped.session_count, + scoped.project_teamspace_project, + scoped.teamspace_excluded, + scoped.has_recurring_teamspace_project, + scoped.work_type + FROM scoped_ticket_days scoped + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS billed_minutes + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = scoped.ticket_id + AND tdb.user_id = $3 + AND tdb.day = scoped.day + AND ( + scoped.work_type = 'support' + OR EXISTS ( + SELECT 1 + FROM projects billing_project + JOIN project_teamspace_entries billing_pte ON billing_pte.project_id = billing_project.id + JOIN teamspace_project_exclusions billing_exclusion ON true + WHERE billing_project.ticket_id = scoped.ticket_id + AND billing_project.user_id = $3 + AND ( + billing_pte.id = tdb.project_teamspace_entry_id + OR (tdb.project_teamspace_entry_id IS NULL AND billing_pte.is_default) + ) + AND ( + lower(btrim(billing_exclusion.teamspace_project)) = lower(btrim(billing_pte.teamspace_project)) + OR billing_pte.teamspace_api_project_id = billing_exclusion.teamspace_api_project_id + OR billing_pte.teamspace_api_project_id = ANY(billing_exclusion.teamspace_api_project_ids) + ) + ) + ) + ) local_teamspace ON true + ORDER BY scoped.day ASC, scoped.ticket_number ASC; + `, + [period.startIso, period.endIso, userId] + ), + query( + ` + WITH local_teamspace_values AS ( + SELECT + tdb.ticket_id, + tdb.day, + COALESCE(SUM(tdb.billed_minutes), 0)::int AS tracked_minutes, + NULLIF( + string_agg( + DISTINCT COALESCE( + NULLIF(direct_entry.teamspace_description, ''), + direct_entry.teamspace_project, + NULLIF(default_entry.teamspace_description, ''), + default_entry.teamspace_project + ), + ' + ' + ), + '' + ) AS project_teamspace_project + FROM ticket_day_billings tdb + LEFT JOIN project_teamspace_entries direct_entry ON direct_entry.id = tdb.project_teamspace_entry_id + LEFT JOIN LATERAL ( + SELECT pte.teamspace_description, pte.teamspace_project + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE p.ticket_id = tdb.ticket_id + AND p.user_id = $3 + AND pte.is_default + ORDER BY pte.id ASC + LIMIT 1 + ) default_entry ON tdb.project_teamspace_entry_id IS NULL + WHERE tdb.user_id = $3 + AND tdb.day >= ($1::timestamptz AT TIME ZONE 'Europe/Berlin')::date + AND tdb.day < ($2::timestamptz AT TIME ZONE 'Europe/Berlin')::date + GROUP BY tdb.ticket_id, tdb.day + HAVING COALESCE(SUM(tdb.billed_minutes), 0) > 0 + ) + SELECT + local_value.ticket_id::text AS ticket_id, + t.ticket_number, + COALESCE(o.name, t.customer_name, 'Keine Organisation') AS organization_name, + local_value.day::text AS day, + local_value.tracked_minutes, + COALESCE(day_sessions.session_count, 0)::int AS session_count, + COALESCE(day_sessions.billed_session_count, 0)::int AS billed_session_count, + COALESCE(day_sessions.billable_session_count, 0)::int AS billable_session_count, + COALESCE(day_sessions.non_billable_session_count, 0)::int AS non_billable_session_count, + COALESCE(day_sessions.unrated_session_count, 0)::int AS unrated_session_count, + local_value.project_teamspace_project, + EXISTS ( + SELECT 1 + FROM projects project + JOIN project_teamspace_entries project_entry ON project_entry.project_id = project.id + JOIN teamspace_project_exclusions project_exclusion ON ( + lower(btrim(project_exclusion.teamspace_project)) = lower(btrim(project_entry.teamspace_project)) + OR project_entry.teamspace_api_project_id = project_exclusion.teamspace_api_project_id + OR project_entry.teamspace_api_project_id = ANY(project_exclusion.teamspace_api_project_ids) + ) + WHERE project.ticket_id = local_value.ticket_id + AND project.user_id = $3 + ) AS teamspace_excluded, + t.work_type, + true AS tt_value_without_billed_session + FROM local_teamspace_values local_value + JOIN tickets t ON t.id = local_value.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.billing_status = 'billed')::int AS billed_session_count, + COUNT(*) FILTER (WHERE s.billing_status = 'billable')::int AS billable_session_count, + COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_session_count, + COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS unrated_session_count + FROM sessions s + WHERE s.ticket_id = local_value.ticket_id + AND s.user_id = $3 + AND (s.started_at AT TIME ZONE 'Europe/Berlin')::date = local_value.day + ) day_sessions ON true + WHERE COALESCE(day_sessions.billed_session_count, 0) = 0 + ORDER BY local_value.day ASC, t.ticket_number ASC; `, [period.startIso, period.endIso, userId] ), @@ -9825,8 +11714,80 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio crm_billed_minutes: 0, teamspace_api_billable_minutes: 0 }; + const ticketTeamspaceDays = new Map(); + + for (const row of scopedTicketDayResult.rows) { + ticketTeamspaceDays.set(`${row.ticket_id}:${row.day}`, { + ...row, + crm_billed_minutes: 0, + teamspace_entry_count: 0, + delta_minutes: -Number(row.tracked_minutes ?? 0), + api_project_id: null, + teamspace_project_id: null, + tt_value_without_billed_session: false + }); + } + + for (const row of invalidTeamspaceBillingResult.rows) { + ticketTeamspaceDays.set(`${row.ticket_id}:${row.day}`, { + ...row, + crm_billed_minutes: 0, + teamspace_entry_count: 0, + delta_minutes: -Number(row.tracked_minutes ?? 0), + api_project_id: null, + teamspace_project_id: null, + tt_value_without_billed_session: true + }); + } + + for (const row of teamspaceDayResult.rows) { + if (!row.ticket_id) { + continue; + } + + const key = `${row.ticket_id}:${row.day}`; + const existing = ticketTeamspaceDays.get(key); + const ticketComparisonEnabled = row.work_type === "support" + || (row.work_type === "consulting" && row.teamspace_excluded) + || row.has_recurring_teamspace_project; + + if (!ticketComparisonEnabled && !existing?.tt_value_without_billed_session) { + continue; + } + + const teamspaceMinutes = Number(row.crm_billed_minutes ?? 0); + + if (existing) { + existing.crm_billed_minutes = Number(existing.crm_billed_minutes ?? 0) + teamspaceMinutes; + existing.teamspace_entry_count = Number(existing.teamspace_entry_count ?? 0) + Number(row.teamspace_entry_count ?? 0); + existing.api_project_id ??= row.api_project_id ?? null; + existing.teamspace_project_id ??= row.teamspace_project_id ?? null; + existing.project_teamspace_project ??= row.project_teamspace_project ?? null; + existing.teamspace_excluded = Boolean(existing.teamspace_excluded || row.teamspace_excluded); + existing.delta_minutes = existing.crm_billed_minutes - Number(existing.tracked_minutes ?? 0); + continue; + } + + ticketTeamspaceDays.set(key, { + ...row, + tracked_minutes: 0, + session_count: 0, + delta_minutes: teamspaceMinutes, + tt_value_without_billed_session: false + }); + } + + const ticketTeamspaceMismatches = Array.from(ticketTeamspaceDays.values()) + .filter((row) => Number(row.delta_minutes ?? 0) !== 0 || row.tt_value_without_billed_session) + .sort((left, right) => left.day.localeCompare(right.day) + || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" })); const effectiveCrmBilledMinutes = teamspaceTimeTrackingEnabled ? totals.crm_billed_minutes : totals.minutes; const teamspaceApiBillableMinutes = Number(totals.teamspace_api_billable_minutes ?? 0); + const teamspaceApiDeltaMinutes = teamspaceApiBillableMinutes - effectiveCrmBilledMinutes; + const blockingTeamspaceMonthMismatches = + teamspaceTimeTrackingEnabled && teamspaceApiDeltaMinutes !== 0 + ? teamspaceMonthMismatchResult.rows + : []; const applyTeamspaceDelta = (row: any) => teamspaceTimeTrackingEnabled ? { ...row, crm_delta_minutes: row.crm_billed_minutes - row.total_minutes } : { ...row, crm_billed_minutes: row.total_minutes, crm_delta_minutes: 0 }; @@ -9860,7 +11821,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio crmBilledMinutes: effectiveCrmBilledMinutes, crmDeltaMinutes: teamspaceTimeTrackingEnabled ? effectiveCrmBilledMinutes - totals.minutes : 0, teamspaceApiBillableMinutes, - teamspaceApiDeltaMinutes: teamspaceApiBillableMinutes - effectiveCrmBilledMinutes + teamspaceApiDeltaMinutes }, dailySeries: dailyResult.rows.map(applyTeamspaceDaily), organizations: organizationResult.rows.map(applyTeamspaceDelta), @@ -9871,7 +11832,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio missingCrmDays: missingCrmResult.rows, acknowledgedMissingCrmDays: acknowledgedMissingCrmResult.rows, crmMismatches: mismatchResult.rows, - teamspaceMonthMismatches: teamspaceMonthMismatchResult.rows + teamspaceMonthMismatches: blockingTeamspaceMonthMismatches, + ticketTeamspaceMismatches: teamspaceTimeTrackingEnabled ? ticketTeamspaceMismatches : [] }, teamspaceDays: teamspaceTimeTrackingEnabled ? teamspaceDayResult.rows : [] }; @@ -10277,6 +12239,209 @@ async function restartProjectFollowupPeriodForTicket(client: QueryClient, ticket ); } +async function getTicketTeamspaceDays(ticketId: number, userId: string, period: ParsedPeriod) { + return query( + ` + WITH target_ticket AS ( + SELECT + t.id, + t.ticket_number, + t.work_type, + EXISTS ( + SELECT 1 + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + JOIN teamspace_project_exclusions tpe ON ( + lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + OR pte.teamspace_api_project_id = tpe.teamspace_api_project_id + OR pte.teamspace_api_project_id = ANY(tpe.teamspace_api_project_ids) + ) + WHERE p.ticket_id = t.id + AND p.user_id = $2 + ) AS teamspace_excluded, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = $2 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + AND rb.teamspace_project IS NOT NULL + AND btrim(rb.teamspace_project) <> '' + ) AS has_recurring_teamspace_project, + ( + SELECT btrim(rb.teamspace_project) + FROM recurring_billings rb + WHERE rb.user_id = $2 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + AND rb.teamspace_project IS NOT NULL + AND btrim(rb.teamspace_project) <> '' + ORDER BY rb.id ASC + LIMIT 1 + ) AS recurring_teamspace_project + FROM tickets t + WHERE t.id = $1 + ), + ticket_scope AS ( + SELECT + target_ticket.*, + ( + work_type = 'support' + OR (work_type = 'consulting' AND teamspace_excluded) + OR has_recurring_teamspace_project + ) AS comparison_enabled + FROM target_ticket + ), + session_days AS ( + SELECT + (s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day, + COUNT(*)::int AS session_count, + COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_session_count, + COUNT(*) FILTER (WHERE s.billing_status = 'billable')::int AS billable_session_count, + COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_session_count, + COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS unrated_session_count + FROM sessions s + WHERE s.ticket_id = $1 + AND s.user_id = $2 + AND s.started_at >= $3::timestamptz + AND s.started_at < $4::timestamptz + GROUP BY (s.started_at AT TIME ZONE 'Europe/Berlin')::date + ), + local_days AS ( + SELECT + tdb.day, + COALESCE(SUM(tdb.billed_minutes), 0)::int AS tracked_minutes, + COUNT(*) > 0 AS has_tt_value + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = $1 + AND tdb.user_id = $2 + AND tdb.day >= ($3::timestamptz AT TIME ZONE 'Europe/Berlin')::date + AND tdb.day < ($4::timestamptz AT TIME ZONE 'Europe/Berlin')::date + GROUP BY tdb.day + ), + api_rows AS ( + SELECT + (day.value->>'day')::date AS day, + COALESCE((day.value->>'billableMinutes')::int, 0) AS billable_minutes, + COALESCE((day.value->>'entryCount')::int, 0) AS entry_count, + position.value->>'apiProjectId' AS api_project_id, + position.value->>'projectId' AS project_id, + position.value->>'mainProjectId' AS main_project_id, + COALESCE( + lower(NULLIF(position.value->>'ticketNumber', '')), + substring( + lower(concat_ws(' ', position.value->>'label', position.value->>'projectId', position.value->>'description')) + FROM '(ticket#[0-9]{6})' + ) + ) AS ticket_number_hint + FROM teamspace_monthly_user_actuals tmua + JOIN LATERAL jsonb_array_elements(COALESCE(tmua.positions, '[]'::jsonb)) AS position(value) ON true + JOIN LATERAL jsonb_array_elements(COALESCE(position.value->'days', '[]'::jsonb)) AS day(value) + ON (day.value->>'day') >= (($3::timestamptz AT TIME ZONE 'Europe/Berlin')::date)::text + AND (day.value->>'day') < (($4::timestamptz AT TIME ZONE 'Europe/Berlin')::date)::text + WHERE tmua.user_id = $2 + AND tmua.month >= date_trunc('month', ($3::timestamptz AT TIME ZONE 'Europe/Berlin')::date)::date + AND tmua.month < (date_trunc('month', (($4::timestamptz AT TIME ZONE 'Europe/Berlin')::date - 1)) + interval '1 month')::date + ), + api_days AS ( + SELECT + api_rows.day, + COALESCE(SUM(api_rows.billable_minutes), 0)::int AS crm_billed_minutes, + COALESCE(SUM(api_rows.entry_count), 0)::int AS teamspace_entry_count + FROM api_rows + CROSS JOIN ticket_scope ts + WHERE api_rows.ticket_number_hint = lower(ts.ticket_number) + OR ( + api_rows.ticket_number_hint IS NULL + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE p.ticket_id = ts.id + AND p.user_id = $2 + AND ( + pte.teamspace_api_project_id::text = api_rows.api_project_id + OR lower(btrim(pte.teamspace_project)) = lower(btrim(api_rows.project_id)) + OR lower(btrim(pte.teamspace_project)) = lower(btrim(api_rows.main_project_id)) + ) + ) + OR ( + api_rows.ticket_number_hint IS NULL + AND + ts.recurring_teamspace_project IS NOT NULL + AND ( + lower(btrim(ts.recurring_teamspace_project)) = lower(btrim(api_rows.project_id)) + OR lower(btrim(ts.recurring_teamspace_project)) = lower(btrim(api_rows.main_project_id)) + OR lower(btrim(ts.recurring_teamspace_project)) = lower(btrim(api_rows.api_project_id)) + OR EXISTS ( + SELECT 1 + FROM teamspace_project_resources resource + WHERE lower(btrim(resource.teamspace_project)) = lower(btrim(ts.recurring_teamspace_project)) + AND resource.teamspace_api_project_id::text = api_rows.api_project_id + ) + ) + ) + ) + GROUP BY api_rows.day + ), + all_days AS ( + SELECT day FROM session_days + UNION + SELECT day FROM local_days + UNION + SELECT day FROM api_days + ) + SELECT + all_days.day::text AS day, + COALESCE(local_days.tracked_minutes, 0)::int AS tracked_minutes, + COALESCE(api_days.crm_billed_minutes, 0)::int AS crm_billed_minutes, + COALESCE(api_days.teamspace_entry_count, 0)::int AS teamspace_entry_count, + (COALESCE(api_days.crm_billed_minutes, 0) - COALESCE(local_days.tracked_minutes, 0))::int AS delta_minutes, + COALESCE(session_days.session_count, 0)::int AS session_count, + COALESCE(session_days.billed_session_count, 0)::int AS billed_session_count, + COALESCE(session_days.billable_session_count, 0)::int AS billable_session_count, + COALESCE(session_days.non_billable_session_count, 0)::int AS non_billable_session_count, + COALESCE(session_days.unrated_session_count, 0)::int AS unrated_session_count, + COALESCE(local_days.has_tt_value, false) AS has_tt_value, + ( + ticket_scope.comparison_enabled + AND COALESCE(session_days.billed_session_count, 0) > 0 + AND NOT COALESCE(local_days.has_tt_value, false) + ) AS missing_tt_value, + ( + COALESCE(local_days.tracked_minutes, 0) > 0 + AND COALESCE(session_days.billed_session_count, 0) = 0 + ) AS tt_value_without_billed_session, + acknowledgement.acknowledged_at IS NOT NULL AS acknowledged, + acknowledgement.acknowledged_at, + ticket_scope.comparison_enabled, + ticket_scope.teamspace_excluded, + ticket_scope.work_type + FROM all_days + CROSS JOIN ticket_scope + LEFT JOIN session_days ON session_days.day = all_days.day + LEFT JOIN local_days ON local_days.day = all_days.day + LEFT JOIN api_days ON api_days.day = all_days.day + LEFT JOIN ticket_day_billing_acknowledgements acknowledgement + ON acknowledgement.ticket_id = ticket_scope.id + AND acknowledgement.user_id = $2 + AND acknowledgement.day = all_days.day + WHERE ( + ticket_scope.comparison_enabled + AND ( + COALESCE(session_days.billed_session_count, 0) > 0 + OR COALESCE(local_days.tracked_minutes, 0) > 0 + OR COALESCE(api_days.crm_billed_minutes, 0) > 0 + ) + ) + OR ( + COALESCE(local_days.tracked_minutes, 0) > 0 + AND COALESCE(session_days.billed_session_count, 0) = 0 + ) + ORDER BY all_days.day ASC; + `, + [ticketId, userId, period.startIso, period.endIso] + ); +} + async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); @@ -10284,6 +12449,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke return null; } + const canManageAtossSupport = await userCanManageTicket({ query }, ticketId, userId); await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId); const periodClosureJoin = @@ -10300,6 +12466,11 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, + t.atoss_support, + t.teamspace_assignment_rule_id::text AS teamspace_assignment_rule_id, + assignment_rule.name AS teamspace_assignment_rule_name, + t.teamspace_assignment_project, + t.teamspace_assignment_synced_at, 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, NULL::timestamptz AS closed_at, @@ -10315,6 +12486,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke ) AS has_recurring_billing FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN teamspace_assignment_rules assignment_rule ON assignment_rule.id = t.teamspace_assignment_rule_id ${periodClosureJoin} LEFT JOIN LATERAL ( SELECT @@ -10361,6 +12533,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke ended_at, duration_seconds, rounded_minutes, + work_segments, billing_status, billing_updated_at, created_at, @@ -10427,6 +12600,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke `, [ticketId, userId, period.start, period.endIso] ); + const ticketTeamspaceDaysResult = await getTicketTeamspaceDays(ticketId, userId, period); const sessions = sessionsResult.rows; const openCount = sessions.filter((session: any) => session.billing_status === null || session.billing_status === "billable").length; @@ -10440,6 +12614,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke period: period.label, ticket: { ...ticket, + can_manage_atoss_support: canManageAtossSupport, requires_closure: false, recurring_session_count: recurringSessionCount, manual_session_count: manualSessionCount @@ -10448,6 +12623,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke closedAt: config.type === "month" ? ticketResult.rows[0].period_closed_at ?? null : null, sessions, dayBillings: dayBillingResult.rows, + ticketTeamspaceDays: ticketTeamspaceDaysResult.rows, canClose: false, openCount, ticketOpenCount @@ -10463,6 +12639,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon return null; } + const canManageAtossSupport = await userCanManageTicket({ query }, ticketId, userId); await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId); const ticketResult = await query( @@ -10475,6 +12652,11 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, + t.atoss_support, + t.teamspace_assignment_rule_id::text AS teamspace_assignment_rule_id, + assignment_rule.name AS teamspace_assignment_rule_name, + t.teamspace_assignment_project, + t.teamspace_assignment_synced_at, 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, @@ -10484,7 +12666,16 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon 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, + ( + t.zammad_ticket_id IS NULL + AND (t.user_id = $2::bigint OR t.user_id IS NULL) + AND NOT EXISTS ( + SELECT 1 + FROM recurring_billings rb_edit + WHERE rb_edit.user_id = $2 + AND COALESCE(rb_edit.ticket_number, 'Fix#' || rb_edit.id) = t.ticket_number + ) + ) 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, @@ -10499,6 +12690,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon FROM tickets t JOIN users viewer ON viewer.id = $2 LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN teamspace_assignment_rules assignment_rule ON assignment_rule.id = t.teamspace_assignment_rule_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 @@ -10567,6 +12759,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon s.ended_at, s.duration_seconds, s.rounded_minutes, + s.work_segments, s.billing_status, s.billing_updated_at, s.created_at, @@ -10655,6 +12848,11 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon AND viewer.active AND viewer.zammad_user_id = t.zammad_owner_id ) + ) AND NOT EXISTS ( + SELECT 1 + FROM recurring_billings rb_edit + WHERE rb_edit.user_id = $2 + AND COALESCE(rb_edit.ticket_number, 'Fix#' || rb_edit.id) = t.ticket_number ), false) AS can_edit, p.ticket_id::text AS ticket_id, t.ticket_number, @@ -10915,7 +13113,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon CASE WHEN entry_time_comparison.ignored THEN 0 ELSE ptma.teamspace_billable_minutes - - CASE WHEN entry_time_comparison.comparable THEN COALESCE(tt_entry.billed_minutes, 0) ELSE 0 END + - COALESCE(tt_entry.billed_minutes, 0) END )::int AS delta_minutes, ptma.positions @@ -10990,6 +13188,9 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon `, [ticketId, userId, filters.month.start, filters.month.endIso] ) : { rows: [] as any[] }; + const ticketTeamspaceDaysResult = filters.month + ? await getTicketTeamspaceDays(ticketId, userId, filters.month) + : { rows: [] as any[] }; return { periodType: "all", @@ -11004,12 +13205,14 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon availableMonths: monthsResult.rows, ticket: { ...ticket, + can_manage_atoss_support: canManageAtossSupport, requires_closure: false, recurring_session_count: recurringSessionCount, manual_session_count: manualSessionCount }, sessions, dayBillings: dayBillingResult.rows, + ticketTeamspaceDays: ticketTeamspaceDaysResult.rows, teamspaceMonthActuals: teamspaceMonthActualsResult.rows, teamspaceMonthBillableMinutes: teamspaceMonthActualsResult.rows.reduce((sum: number, actual: any) => sum + Number(actual.teamspace_billable_minutes ?? 0), 0), teamspaceMonthDeltaMinutes: teamspaceMonthActualsResult.rows.reduce((sum: number, actual: any) => sum + Number(actual.delta_minutes ?? 0), 0), @@ -11064,8 +13267,10 @@ app.post("/api/setup/admin", async (req, res) => { has_teamspace_credentials: boolean; zammad_email_session_webhook_enabled: boolean; session_billing_prompt_enabled: boolean; + session_work_segments_enabled: boolean; teamspace_time_tracking_enabled: boolean; zammad_session_note_default_enabled: boolean; + zammad_session_note_date_header_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; zammad_atoss_email_sync_enabled: boolean; @@ -11079,7 +13284,7 @@ app.post("/api/setup/admin", async (req, res) => { ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, 'admin', true) - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, session_work_segments_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_session_note_date_header_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session; `, [username, displayName, await hashPassword(password)] ); @@ -11139,8 +13344,10 @@ app.post("/api/auth/login", async (req, res) => { has_teamspace_credentials: boolean; zammad_email_session_webhook_enabled: boolean; session_billing_prompt_enabled: boolean; + session_work_segments_enabled: boolean; teamspace_time_tracking_enabled: boolean; zammad_session_note_default_enabled: boolean; + zammad_session_note_date_header_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; zammad_atoss_email_sync_enabled: boolean; @@ -11163,8 +13370,10 @@ app.post("/api/auth/login", async (req, res) => { (zammad_api_token IS NOT NULL) AS has_zammad_api_token, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, + session_work_segments_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, + zammad_session_note_date_header_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, @@ -11223,8 +13432,10 @@ app.post("/api/auth/login", async (req, res) => { has_teamspace_credentials: user.has_teamspace_credentials, zammad_email_session_webhook_enabled: user.zammad_email_session_webhook_enabled, session_billing_prompt_enabled: user.session_billing_prompt_enabled, + session_work_segments_enabled: user.session_work_segments_enabled, teamspace_time_tracking_enabled: user.teamspace_time_tracking_enabled, zammad_session_note_default_enabled: user.zammad_session_note_default_enabled, + zammad_session_note_date_header_enabled: user.zammad_session_note_date_header_enabled, zammad_time_unit_rounding_enabled: user.zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled: user.zammad_atoss_email_sync_enabled, @@ -11590,29 +13801,9 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { } }); - let remoteSubprojectsScanned = 0; - let subentriesSynced = 0; - let subentriesRemoved = 0; - - if (subentrySyncEntries.length > 0) { - const subprojects = await loadTeamspaceRemoteSubprojectsSingleCall( - credentials.baseUrl, - credentials.deviceId, - credentials.devicePassword, - subentrySyncEntries.map((entry) => Number(entry.teamspace_api_project_id)).filter((id) => Number.isInteger(id) && id > 0) - ); - const syncResult = await withTransaction((client) => syncTeamspaceSubentriesForEntries( - client, - user.id, - subentrySyncEntries, - subprojects.projects, - credentials.baseUrl - )); - remoteSubprojectsScanned = subprojects.scanned; - subentriesSynced = syncResult.synced; - subentriesRemoved = syncResult.removed; - await withTransaction((client) => consolidateSharedTeamspaceProjectData(client, subentrySyncEntries.map((entry) => entry.teamspace_project))); - } + const remoteSubprojectsScanned = 0; + const subentriesSynced = 0; + const subentriesRemoved = 0; let teamspaceTimeEntriesScanned = 0; let teamspaceTimeEntriesMapped = 0; @@ -11639,44 +13830,18 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { billing_type: string | null; }>( ` - SELECT DISTINCT ON (entry_id, api_project_id) - entry_id, - api_project_id::text AS api_project_id, - project_id, - label, - is_subentry, - billing_type - FROM ( - SELECT - pte.id::text AS entry_id, - pte.teamspace_api_project_id AS api_project_id, - pte.teamspace_project AS project_id, - NULLIF(btrim(pte.teamspace_description), '') AS label, - false AS is_subentry, - NULL::text AS billing_type, - 0 AS display_order - FROM project_teamspace_entries pte - WHERE pte.id = ANY($2::bigint[]) - AND pte.teamspace_api_project_id IS NOT NULL - - UNION ALL - - SELECT - pte.id::text AS entry_id, - pts.teamspace_api_project_id AS api_project_id, - pts.teamspace_project_id AS project_id, - COALESCE(NULLIF(btrim(pts.alias), ''), NULLIF(btrim(pts.name), ''), pts.teamspace_project_id) AS label, - true AS is_subentry, - pts.billing_type, - pts.display_order + 1 AS display_order - FROM project_teamspace_entries pte - JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte.id - WHERE pte.id = ANY($2::bigint[]) - AND pts.user_id = $1 - ) api_ids - ORDER BY entry_id, api_project_id, display_order ASC; + SELECT + pte.id::text AS entry_id, + pte.teamspace_api_project_id::text AS api_project_id, + pte.teamspace_project AS project_id, + NULLIF(btrim(pte.teamspace_description), '') AS label, + false AS is_subentry, + NULL::text AS billing_type + FROM project_teamspace_entries pte + WHERE pte.id = ANY($1::bigint[]) + AND pte.teamspace_api_project_id IS NOT NULL; `, - [user.id, monthSyncEntries.map((entry) => entry.entryId)] + [monthSyncEntries.map((entry) => entry.entryId)] ); const apiRowsByEntryId = new Map(); @@ -12008,12 +14173,18 @@ app.patch("/api/auth/me", async (req, res) => { const sessionBillingPromptEnabled = user.role === "user" ? optionalBoolean(req.body.sessionBillingPromptEnabled, user.session_billing_prompt_enabled) : false; + const sessionWorkSegmentsEnabled = user.role === "user" + ? optionalBoolean(req.body.sessionWorkSegmentsEnabled, user.session_work_segments_enabled) + : false; const teamspaceTimeTrackingEnabled = user.role === "user" ? optionalBoolean(req.body.teamspaceTimeTrackingEnabled, user.teamspace_time_tracking_enabled) : true; const zammadSessionNoteDefaultEnabled = user.role === "user" ? optionalBoolean(req.body.zammadSessionNoteDefaultEnabled, user.zammad_session_note_default_enabled) : false; + const zammadSessionNoteDateHeaderEnabled = user.role === "user" + ? optionalBoolean(req.body.zammadSessionNoteDateHeaderEnabled, user.zammad_session_note_date_header_enabled) + : false; const zammadTimeUnitRoundingEnabled = user.role === "user" ? optionalBoolean(req.body.zammadTimeUnitRoundingEnabled, user.zammad_time_unit_rounding_enabled) : false; @@ -12081,20 +14252,22 @@ app.patch("/api/auth/me", async (req, res) => { teamspace_user_id = CASE WHEN $8::boolean THEN $11 ELSE teamspace_user_id END, zammad_email_session_webhook_enabled = CASE WHEN $12::boolean THEN $13 ELSE zammad_email_session_webhook_enabled END, session_billing_prompt_enabled = CASE WHEN $12::boolean THEN $14 ELSE session_billing_prompt_enabled END, - teamspace_time_tracking_enabled = CASE WHEN $12::boolean THEN $15 ELSE teamspace_time_tracking_enabled END, - zammad_session_note_default_enabled = CASE WHEN $12::boolean THEN $16 ELSE zammad_session_note_default_enabled END, - zammad_time_unit_rounding_enabled = CASE WHEN $12::boolean THEN $17 ELSE zammad_time_unit_rounding_enabled END, - zammad_time_unit_rounding_minutes = CASE WHEN $12::boolean THEN $18 ELSE zammad_time_unit_rounding_minutes END, - zammad_atoss_email_sync_enabled = CASE WHEN $12::boolean THEN $19 ELSE zammad_atoss_email_sync_enabled END, - zammad_article_type_id = CASE WHEN $12::boolean THEN $20 ELSE zammad_article_type_id END, - project_followup_enabled = CASE WHEN $12::boolean THEN $21 ELSE project_followup_enabled END, - project_followup_weeks = CASE WHEN $12::boolean THEN $22 ELSE project_followup_weeks END, - project_followup_second_enabled = CASE WHEN $12::boolean THEN $23 ELSE project_followup_second_enabled END, - project_followup_second_weeks = CASE WHEN $12::boolean THEN $24 ELSE project_followup_second_weeks END, - project_followup_complete_on_session = CASE WHEN $12::boolean THEN $25 ELSE project_followup_complete_on_session END, + session_work_segments_enabled = CASE WHEN $12::boolean THEN $15 ELSE session_work_segments_enabled END, + teamspace_time_tracking_enabled = CASE WHEN $12::boolean THEN $16 ELSE teamspace_time_tracking_enabled END, + zammad_session_note_default_enabled = CASE WHEN $12::boolean THEN $17 ELSE zammad_session_note_default_enabled END, + zammad_session_note_date_header_enabled = CASE WHEN $12::boolean THEN $18 ELSE zammad_session_note_date_header_enabled END, + zammad_time_unit_rounding_enabled = CASE WHEN $12::boolean THEN $19 ELSE zammad_time_unit_rounding_enabled END, + zammad_time_unit_rounding_minutes = CASE WHEN $12::boolean THEN $20 ELSE zammad_time_unit_rounding_minutes END, + zammad_atoss_email_sync_enabled = CASE WHEN $12::boolean THEN $21 ELSE zammad_atoss_email_sync_enabled END, + zammad_article_type_id = CASE WHEN $12::boolean THEN $22 ELSE zammad_article_type_id END, + project_followup_enabled = CASE WHEN $12::boolean THEN $23 ELSE project_followup_enabled END, + project_followup_weeks = CASE WHEN $12::boolean THEN $24 ELSE project_followup_weeks END, + project_followup_second_enabled = CASE WHEN $12::boolean THEN $25 ELSE project_followup_second_enabled END, + project_followup_second_weeks = CASE WHEN $12::boolean THEN $26 ELSE project_followup_second_weeks END, + project_followup_complete_on_session = CASE WHEN $12::boolean THEN $27 ELSE project_followup_complete_on_session END, updated_at = now() WHERE id = $4 - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, session_work_segments_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_session_note_date_header_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session; `, [ username, @@ -12111,8 +14284,10 @@ app.patch("/api/auth/me", async (req, res) => { user.role === "user", zammadEmailSessionWebhookEnabled, sessionBillingPromptEnabled, + sessionWorkSegmentsEnabled, teamspaceTimeTrackingEnabled, zammadSessionNoteDefaultEnabled, + zammadSessionNoteDateHeaderEnabled, zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingMinutes, zammadAtossEmailSyncEnabled, @@ -12181,7 +14356,7 @@ app.patch("/api/auth/me", async (req, res) => { app.get("/api/admin/users", requireAdmin, async (_req, res) => { const result = await query( ` - SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, teamspace_time_tracking_enabled, zammad_time_unit_rounding_minutes, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at + SELECT ${adminUserSelectSql} FROM users ORDER BY username ASC; ` @@ -12205,7 +14380,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => { ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, $4, $5) - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at; + RETURNING ${adminUserSelectSql}; `, [username, displayName, await hashPassword(password), "user", active] ); @@ -12266,7 +14441,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => { password_hash = COALESCE($4, password_hash), updated_at = now() WHERE id = $5 - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at; + RETURNING ${adminUserSelectSql}; `, [username, displayName, active, password ? await hashPassword(password) : null, userId] ); @@ -12296,6 +14471,42 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => { } }); +app.post("/api/admin/users/:userId/teamspace-initial-actuals/sync", requireAdmin, async (req, res) => { + const userId = String(parsePositiveInteger(req.params.userId, "userId")); + const fromMonth = req.body?.fromMonth === undefined || req.body?.fromMonth === null || req.body?.fromMonth === "" + ? null + : parseMonth(String(req.body.fromMonth)).label; + const toMonth = req.body?.toMonth === undefined || req.body?.toMonth === null || req.body?.toMonth === "" + ? null + : parseMonth(String(req.body.toMonth)).label; + const result = await runTeamspaceInitialActualsSyncForUser(userId, currentUser(req).id, fromMonth, toMonth); + + res.json({ result }); +}); + +app.post("/api/admin/users/:userId/teamspace-initial-actuals/unlock", requireAdmin, async (req, res) => { + const userId = parsePositiveInteger(req.params.userId, "userId"); + const result = await query( + ` + UPDATE users + SET teamspace_initial_actuals_unlocked_at = now(), + teamspace_initial_actuals_unlocked_by = $2, + updated_at = now() + WHERE id = $1 + AND role = 'user' + RETURNING ${adminUserSelectSql}; + `, + [userId, currentUser(req).id] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: "User not found" }); + return; + } + + res.json({ user: result.rows[0] }); +}); + app.get("/api/organizations", async (req, res) => { const search = optionalString(req.query.search); const params: unknown[] = []; @@ -12331,6 +14542,81 @@ app.get("/api/admin/zammad/settings", requireAdmin, async (_req, res) => { }); }); +app.get("/api/admin/organizations", requireAdmin, async (req, res) => { + const search = optionalString(req.query.search); + const missingAssignment = req.query.missingAssignment === "true"; + const pageSizeRaw = String(req.query.pageSize ?? "20"); + const pageSize = pageSizeRaw === "all" ? null : parsePositiveInteger(pageSizeRaw, "pageSize"); + const page = pageSize === null ? 1 : parsePositiveInteger(req.query.page ?? "1", "page"); + const params: unknown[] = []; + const where: string[] = []; + + if (pageSize !== null && ![10, 20, 50, 100].includes(pageSize)) { + throw badRequest("pageSize must be one of 10, 20, 50, 100 or all"); + } + + if (search) { + params.push(`%${search}%`); + where.push(`( + organization.name ILIKE $${params.length} + OR organization.zammad_id::text ILIKE $${params.length} + OR customer_owner.option_key ILIKE $${params.length} + OR customer_owner.display_name ILIKE $${params.length} + OR hotline_contract.option_key ILIKE $${params.length} + OR hotline_contract.display_name ILIKE $${params.length} + )`); + } + + if (missingAssignment) { + where.push(`( + organization.customer_owner_option_id IS NULL + OR organization.hotline_contract_option_id IS NULL + )`); + } + + const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const fromSql = ` + FROM organizations organization + LEFT JOIN organization_attribute_options customer_owner ON customer_owner.id = organization.customer_owner_option_id + LEFT JOIN organization_attribute_options hotline_contract ON hotline_contract.id = organization.hotline_contract_option_id + `; + const countResult = await query<{ count: string }>(`SELECT COUNT(*)::text AS count ${fromSql} ${whereSql};`, params); + const total = Number(countResult.rows[0]?.count ?? 0); + const pagingSql = pageSize === null + ? "" + : `LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; + const pagingParams = pageSize === null ? [] : [pageSize, (page - 1) * pageSize]; + const result = await query( + ` + SELECT + organization.zammad_id::text AS id, + organization.zammad_id::text AS zammad_id, + organization.name, + organization.customer_owner_option_id::text AS customer_owner_option_id, + customer_owner.option_key AS customer_owner, + customer_owner.display_name AS customer_owner_display_name, + organization.hotline_contract_option_id::text AS hotline_contract_option_id, + hotline_contract.option_key AS hotline_contract, + hotline_contract.display_name AS hotline_contract_display_name, + organization.synced_at::text AS synced_at, + organization.created_at::text AS created_at, + organization.updated_at::text AS updated_at + ${fromSql} + ${whereSql} + ORDER BY lower(organization.name) ASC, organization.zammad_id ASC + ${pagingSql}; + `, + [...params, ...pagingParams] + ); + + res.json({ + organizations: result.rows, + total, + page, + pageSize: pageSize ?? "all" + }); +}); + app.put("/api/admin/zammad/settings", requireAdmin, async (req, res) => { const baseUrl = parseZammadBaseUrl(req.body.baseUrl); const apiKey = optionalString(req.body.apiKey); @@ -12400,15 +14686,298 @@ app.put("/api/admin/teamspace/settings", requireAdmin, async (req, res) => { type TeamspaceProjectExclusionRow = { teamspace_project: string; + teamspace_api_project_id: string | null; + teamspace_api_project_ids: string[] | null; teamspace_name: string | null; customer_name: string | null; + atoss_support_default: boolean; + atoss_support_valid_from: string | null; + atoss_support_valid_to: string | null; + customer_owner_option_id: string | null; + hotline_contract_option_id: string | null; note: string; }; +type TeamspaceProjectExclusionInput = { + project: string; + atossSupportDefault: boolean; + atossSupportValidFrom: string | null; + atossSupportValidTo: string | null; + customerOwnerOptionId: number | null; + hotlineContractOptionId: number | null; +}; + +type OrganizationAttributeOptionRow = { + id: string; + zammad_attribute_id: string; + attribute_name: OrganizationAttributeName; + option_key: string; + display_name: string; + active: boolean; +}; + +type TeamspaceAssignmentRuleRow = { + id: string; + name: string; + active: boolean; + priority: number; + customer_owner_option_id: string | null; + hotline_contract_option_id: string | null; + support_enabled: boolean; + consulting_enabled: boolean; + teamspace_project: string; + created_at: string; + updated_at: string; +}; + +type TeamspaceAssignmentRuleInput = { + id: number | null; + name: string; + active: boolean; + priority: number; + customerOwnerOptionId: number | null; + hotlineContractOptionId: number | null; + supportEnabled: boolean; + consultingEnabled: boolean; + teamspaceProject: string; +}; + +const teamspaceAssignmentRuleSelectSql = ` + SELECT + id::text AS id, + name, + active, + priority, + customer_owner_option_id::text AS customer_owner_option_id, + hotline_contract_option_id::text AS hotline_contract_option_id, + support_enabled, + consulting_enabled, + teamspace_project, + created_at::text AS created_at, + updated_at::text AS updated_at + FROM teamspace_assignment_rules +`; + +function parseTeamspaceAssignmentRuleInput(value: unknown): TeamspaceAssignmentRuleInput { + if (!isPlainObject(value)) { + throw badRequest("Zuordnungsregel muss ein Objekt sein"); + } + + const name = optionalString(value.name); + const teamspaceProject = optionalString(value.teamspace_project ?? value.teamspaceProject); + const customerOwnerOptionId = parseOptionalPositiveInteger(value.customer_owner_option_id ?? value.customerOwnerOptionId, "customerOwnerOptionId"); + const hotlineContractOptionId = parseOptionalPositiveInteger(value.hotline_contract_option_id ?? value.hotlineContractOptionId, "hotlineContractOptionId"); + const supportEnabled = optionalBoolean(value.support_enabled ?? value.supportEnabled, false); + const consultingEnabled = optionalBoolean(value.consulting_enabled ?? value.consultingEnabled, false); + const priority = value.priority === undefined || value.priority === null || value.priority === "" + ? 100 + : parsePositiveInteger(value.priority, "priority"); + + if (!name) { + throw badRequest("Regelname ist erforderlich"); + } + + if (!teamspaceProject) { + throw badRequest(`Ziel-TS-Projekt ist für Regel "${name}" erforderlich`); + } + + if (priority > 100000) { + throw badRequest(`Priorität ist für Regel "${name}" zu groß`); + } + + if (customerOwnerOptionId === null && hotlineContractOptionId === null) { + throw badRequest(`Regel "${name}" benötigt mindestens eine Bedingung`); + } + + if (!supportEnabled && !consultingEnabled) { + throw badRequest(`Regel "${name}" muss für Support und/oder Consulting gelten`); + } + + return { + id: parseOptionalPositiveInteger(value.id, "id"), + name, + active: optionalBoolean(value.active, true), + priority, + customerOwnerOptionId, + hotlineContractOptionId, + supportEnabled, + consultingEnabled, + teamspaceProject + }; +} + +app.get("/api/admin/zammad/organization-attribute-options", requireAdmin, async (_req, res) => { + const result = await query( + ` + SELECT + id::text AS id, + zammad_attribute_id::text AS zammad_attribute_id, + attribute_name, + option_key, + display_name, + active + FROM organization_attribute_options + ORDER BY attribute_name ASC, active DESC, lower(display_name) ASC, lower(option_key) ASC; + ` + ); + + res.json({ options: result.rows }); +}); + +app.get("/api/admin/teamspace/assignment-rules", requireAdmin, async (_req, res) => { + const result = await query( + `${teamspaceAssignmentRuleSelectSql} ORDER BY priority ASC, id ASC;` + ); + + res.json({ rules: result.rows }); +}); + +app.put("/api/admin/teamspace/assignment-rules", requireAdmin, async (req, res) => { + if (!Array.isArray(req.body.rules)) { + throw badRequest("rules must be an array"); + } + + const rawRules: unknown[] = req.body.rules; + const rules: TeamspaceAssignmentRuleInput[] = rawRules.map(parseTeamspaceAssignmentRuleInput); + const suppliedIds = rules.flatMap((rule) => rule.id === null ? [] : [rule.id]); + + if (new Set(suppliedIds).size !== suppliedIds.length) { + throw badRequest("Eine Zuordnungsregel wurde mehrfach übermittelt"); + } + + const requestedOptionIds = [...new Set(rules.flatMap((rule) => [rule.customerOwnerOptionId, rule.hotlineContractOptionId]).filter((id): id is number => id !== null))]; + const optionAttributesById = new Map(); + + if (requestedOptionIds.length > 0) { + const optionResult = await query<{ id: string; attribute_name: OrganizationAttributeName }>( + "SELECT id::text AS id, attribute_name FROM organization_attribute_options WHERE id = ANY($1::bigint[]);", + [requestedOptionIds] + ); + + for (const option of optionResult.rows) { + optionAttributesById.set(option.id, option.attribute_name); + } + } + + for (const rule of rules) { + if (rule.customerOwnerOptionId !== null && optionAttributesById.get(String(rule.customerOwnerOptionId)) !== "customer_owner") { + throw badRequest(`Ungültige Zugeordnet-Option für Regel "${rule.name}"`); + } + + if (rule.hotlineContractOptionId !== null && optionAttributesById.get(String(rule.hotlineContractOptionId)) !== "hotline_contract") { + throw badRequest(`Ungültige Hotline-Option für Regel "${rule.name}"`); + } + } + + const targetProjects = [...new Set(rules.map((rule) => rule.teamspaceProject))]; + + if (targetProjects.length > 0) { + const targetsResult = await query<{ teamspace_project: string }>( + ` + SELECT teamspace_project + FROM teamspace_project_exclusions + WHERE lower(btrim(teamspace_project)) = ANY($1::text[]); + `, + [targetProjects.map((project) => project.toLocaleLowerCase("de"))] + ); + const existingTargets = new Set(targetsResult.rows.map((row) => row.teamspace_project.toLocaleLowerCase("de"))); + const missingTargets = targetProjects.filter((project) => !existingTargets.has(project.toLocaleLowerCase("de"))); + + if (missingTargets.length > 0) { + throw badRequest(`Ziel-TS-Projekt ist nicht als Ausnahme gespeichert: ${missingTargets.join(", ")}`); + } + } + + await withTransaction(async (client) => { + const retainedIds: number[] = []; + + for (const rule of rules) { + if (rule.id !== null) { + const updateResult = await client.query<{ id: string }>( + ` + UPDATE teamspace_assignment_rules + SET name = $2, + active = $3, + priority = $4, + customer_owner_option_id = $5, + hotline_contract_option_id = $6, + support_enabled = $7, + consulting_enabled = $8, + teamspace_project = $9, + updated_at = now() + WHERE id = $1 + RETURNING id::text AS id; + `, + [rule.id, rule.name, rule.active, rule.priority, rule.customerOwnerOptionId, rule.hotlineContractOptionId, rule.supportEnabled, rule.consultingEnabled, rule.teamspaceProject] + ); + + if (!updateResult.rows[0]) { + throw badRequest(`Zuordnungsregel ${rule.id} wurde nicht gefunden`); + } + + retainedIds.push(rule.id); + continue; + } + + const insertResult = await client.query<{ id: string }>( + ` + INSERT INTO teamspace_assignment_rules ( + name, + active, + priority, + customer_owner_option_id, + hotline_contract_option_id, + support_enabled, + consulting_enabled, + teamspace_project + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id::text AS id; + `, + [rule.name, rule.active, rule.priority, rule.customerOwnerOptionId, rule.hotlineContractOptionId, rule.supportEnabled, rule.consultingEnabled, rule.teamspaceProject] + ); + retainedIds.push(Number(insertResult.rows[0].id)); + } + + if (retainedIds.length > 0) { + await client.query("DELETE FROM teamspace_assignment_rules WHERE NOT (id = ANY($1::bigint[]));", [retainedIds]); + } else { + await client.query("DELETE FROM teamspace_assignment_rules;"); + } + + await client.query( + ` + UPDATE tickets + SET teamspace_assignment_project = NULL, + teamspace_assignment_synced_at = now() + WHERE teamspace_assignment_rule_id IS NULL + AND teamspace_assignment_project IS NOT NULL; + ` + ); + }); + + const result = await query( + `${teamspaceAssignmentRuleSelectSql} ORDER BY priority ASC, id ASC;` + ); + + res.json({ rules: result.rows }); +}); + app.get("/api/admin/teamspace/project-exclusions", requireAdmin, async (_req, res) => { const result = await query( ` - SELECT teamspace_project, teamspace_name, customer_name, note + SELECT + teamspace_project, + teamspace_api_project_id::text AS teamspace_api_project_id, + ARRAY(SELECT value::text FROM unnest(teamspace_api_project_ids) AS value ORDER BY value) AS teamspace_api_project_ids, + teamspace_name, + customer_name, + atoss_support_default, + atoss_support_valid_from::text AS atoss_support_valid_from, + atoss_support_valid_to::text AS atoss_support_valid_to, + customer_owner_option_id::text AS customer_owner_option_id, + hotline_contract_option_id::text AS hotline_contract_option_id, + note FROM teamspace_project_exclusions ORDER BY teamspace_project ASC; ` @@ -12417,20 +14986,119 @@ app.get("/api/admin/teamspace/project-exclusions", requireAdmin, async (_req, re res.json({ exclusions: result.rows }); }); +function parseTeamspaceProjectExclusionInput(value: unknown): TeamspaceProjectExclusionInput | null { + if (typeof value === "string") { + const project = value.trim(); + return project + ? { + project, + atossSupportDefault: false, + atossSupportValidFrom: null, + atossSupportValidTo: null, + customerOwnerOptionId: null, + hotlineContractOptionId: null + } + : null; + } + + if (!isPlainObject(value)) { + return null; + } + + const project = optionalString(value.teamspace_project ?? value.project); + if (!project) { + return null; + } + + const atossSupportDefault = optionalBoolean(value.atoss_support_default ?? value.atossSupportDefault, false); + const atossSupportValidFrom = parseOptionalDateOnly(value.atoss_support_valid_from ?? value.atossSupportValidFrom, "atossSupportValidFrom"); + const atossSupportValidTo = parseOptionalDateOnly(value.atoss_support_valid_to ?? value.atossSupportValidTo, "atossSupportValidTo"); + const customerOwnerOptionId = parseOptionalPositiveInteger(value.customer_owner_option_id ?? value.customerOwnerOptionId, "customerOwnerOptionId"); + const hotlineContractOptionId = parseOptionalPositiveInteger(value.hotline_contract_option_id ?? value.hotlineContractOptionId, "hotlineContractOptionId"); + + if (atossSupportValidFrom && atossSupportValidTo && atossSupportValidTo < atossSupportValidFrom) { + throw badRequest("Atoss-Support gültig bis darf nicht vor gültig von liegen"); + } + + return { + project, + atossSupportDefault, + atossSupportValidFrom, + atossSupportValidTo, + customerOwnerOptionId, + hotlineContractOptionId + }; +} + +function validateAtossSupportDefaultPeriods(exclusions: TeamspaceProjectExclusionInput[]) { + const defaults = exclusions + .filter((entry) => entry.atossSupportDefault) + .sort((left, right) => { + const leftFrom = left.atossSupportValidFrom ?? "0000-01-01"; + const rightFrom = right.atossSupportValidFrom ?? "0000-01-01"; + return leftFrom.localeCompare(rightFrom); + }); + + for (let index = 0; index < defaults.length; index += 1) { + const current = defaults[index]; + const currentTo = current.atossSupportValidTo ?? "9999-12-31"; + + for (const next of defaults.slice(index + 1)) { + const nextFrom = next.atossSupportValidFrom ?? "0000-01-01"; + const nextTo = next.atossSupportValidTo ?? "9999-12-31"; + + if (nextFrom <= currentTo && nextTo >= (current.atossSupportValidFrom ?? "0000-01-01")) { + throw badRequest(`Atoss-Support-Gültigkeiten überschneiden sich: ${current.project} und ${next.project}`); + } + } + } +} + app.put("/api/admin/teamspace/project-exclusions", requireAdmin, async (req, res) => { const rawProjects: unknown[] = Array.isArray(req.body.projects) ? req.body.projects : typeof req.body.projectsText === "string" ? req.body.projectsText.split(/\r?\n|,/) : []; - const projects = Array.from(new Map( + const projectEntries = Array.from(new Map( rawProjects - .map((value) => typeof value === "string" ? value.trim() : "") - .filter(Boolean) - .map((value) => [value.toLocaleLowerCase("de"), value]) + .map(parseTeamspaceProjectExclusionInput) + .filter((value): value is TeamspaceProjectExclusionInput => Boolean(value)) + .map((value) => [value.project.toLocaleLowerCase("de"), value]) ).values()); + validateAtossSupportDefaultPeriods(projectEntries); + const projects = projectEntries.map((entry) => entry.project); + + const requestedOptionIds = [...new Set(projectEntries.flatMap((entry) => [entry.customerOwnerOptionId, entry.hotlineContractOptionId]).filter((id): id is number => id !== null))]; + const optionAttributesById = new Map(); + + if (requestedOptionIds.length > 0) { + const optionResult = await query<{ id: string; attribute_name: OrganizationAttributeName }>( + ` + SELECT id::text AS id, attribute_name + FROM organization_attribute_options + WHERE id = ANY($1::bigint[]); + `, + [requestedOptionIds] + ); + + for (const option of optionResult.rows) { + optionAttributesById.set(option.id, option.attribute_name); + } + + for (const entry of projectEntries) { + if (entry.customerOwnerOptionId !== null && optionAttributesById.get(String(entry.customerOwnerOptionId)) !== "customer_owner") { + throw badRequest(`Ungültige Zugeordnet-Option für ${entry.project}`); + } + + if (entry.hotlineContractOptionId !== null && optionAttributesById.get(String(entry.hotlineContractOptionId)) !== "hotline_contract") { + throw badRequest(`Ungültige Hotline-Option für ${entry.project}`); + } + } + } const remoteProjectsById = new Map(); + const exclusionApiIdsByProject = new Map(); if (projects.length > 0) { const credentials = await getTeamspaceCredentialsForAdminAction(); @@ -12439,6 +15107,10 @@ app.put("/api/admin/teamspace/project-exclusions", requireAdmin, async (req, res for (const project of remoteProjectsResult.projects) { remoteProjectsById.set(project.projectId.toLocaleLowerCase("de"), project); } + + for (const project of remoteProjectsResult.projects) { + exclusionApiIdsByProject.set(project.projectId.toLocaleLowerCase("de"), [project.apiProjectId]); + } } const missingProjects = projects.filter((project) => !remoteProjectsById.has(project.toLocaleLowerCase("de"))); @@ -12452,20 +15124,66 @@ app.put("/api/admin/teamspace/project-exclusions", requireAdmin, async (req, res for (const project of projects) { const remoteProject = remoteProjectsById.get(project.toLocaleLowerCase("de")); + const projectEntry = projectEntries.find((entry) => entry.project === project); + const apiProjectIds = [...new Set(exclusionApiIdsByProject.get(project.toLocaleLowerCase("de")) ?? [])].sort((left, right) => left - right); await client.query( ` - INSERT INTO teamspace_project_exclusions (teamspace_project, teamspace_name, customer_name, updated_at) - VALUES ($1, $2, $3, now()) + INSERT INTO teamspace_project_exclusions ( + teamspace_project, + teamspace_api_project_id, + teamspace_api_project_ids, + teamspace_name, + customer_name, + atoss_support_default, + atoss_support_valid_from, + atoss_support_valid_to, + customer_owner_option_id, + hotline_contract_option_id, + updated_at + ) + VALUES ($1, $2, $3::bigint[], $4, $5, $6, $7::date, $8::date, $9, $10, now()) ON CONFLICT (teamspace_project) DO UPDATE SET + teamspace_api_project_id = EXCLUDED.teamspace_api_project_id, + teamspace_api_project_ids = EXCLUDED.teamspace_api_project_ids, teamspace_name = EXCLUDED.teamspace_name, customer_name = EXCLUDED.customer_name, + atoss_support_default = EXCLUDED.atoss_support_default, + atoss_support_valid_from = EXCLUDED.atoss_support_valid_from, + atoss_support_valid_to = EXCLUDED.atoss_support_valid_to, + customer_owner_option_id = EXCLUDED.customer_owner_option_id, + hotline_contract_option_id = EXCLUDED.hotline_contract_option_id, updated_at = now(); `, - [project, remoteProject?.name ?? remoteProject?.description ?? null, remoteProject?.customerName ?? null] + [ + project, + remoteProject?.apiProjectId ?? null, + apiProjectIds, + remoteProject?.name ?? remoteProject?.description ?? null, + remoteProject?.customerName ?? null, + projectEntry?.atossSupportDefault ?? false, + projectEntry?.atossSupportValidFrom ?? null, + projectEntry?.atossSupportValidTo ?? null, + projectEntry?.customerOwnerOptionId ?? null, + projectEntry?.hotlineContractOptionId ?? null + ] ); } + await client.query( + ` + UPDATE teamspace_assignment_rules rule + SET active = false, + updated_at = now() + WHERE NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions exclusion + WHERE lower(btrim(exclusion.teamspace_project)) = lower(btrim(rule.teamspace_project)) + ) + AND rule.active = true; + ` + ); + await client.query( ` UPDATE project_teamspace_entries pte @@ -12489,11 +15207,52 @@ app.put("/api/admin/teamspace/project-exclusions", requireAdmin, async (req, res ); ` ); + + await client.query( + ` + UPDATE project_teamspace_entries entry + SET teamspace_api_project_id = exclusion.teamspace_api_project_id, + teamspace_description = COALESCE(exclusion.teamspace_name, entry.teamspace_description), + updated_at = now() + FROM projects target_project + JOIN tickets target_ticket ON target_ticket.id = target_project.ticket_id + JOIN teamspace_project_exclusions exclusion ON true + WHERE entry.project_id = target_project.id + AND target_ticket.ticket_number = $1 + AND lower(btrim(entry.teamspace_project)) = lower(btrim(exclusion.teamspace_project)); + `, + [teamspaceAssignmentTargetTicketNumber] + ); + + const fallbackTickets = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM tickets + WHERE work_type = 'support' + AND atoss_support = true + ORDER BY id ASC; + ` + ); + + for (const ticket of fallbackTickets.rows) { + await refreshSyncedTeamspaceAssignment(client, ticket.id); + } }); const result = await query( ` - SELECT teamspace_project, teamspace_name, customer_name, note + SELECT + teamspace_project, + teamspace_api_project_id::text AS teamspace_api_project_id, + ARRAY(SELECT value::text FROM unnest(teamspace_api_project_ids) AS value ORDER BY value) AS teamspace_api_project_ids, + teamspace_name, + customer_name, + atoss_support_default, + atoss_support_valid_from::text AS atoss_support_valid_from, + atoss_support_valid_to::text AS atoss_support_valid_to, + customer_owner_option_id::text AS customer_owner_option_id, + hotline_contract_option_id::text AS hotline_contract_option_id, + note FROM teamspace_project_exclusions ORDER BY teamspace_project ASC; ` @@ -12514,11 +15273,37 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) throw badRequest("apiKey is required"); } + const requestHeaders = { + Authorization: `Token token=${apiKey}`, + Accept: "application/json" + }; + const attributeResponse = await zammadFetch(`${baseUrl}/api/v1/object_manager_attributes`, { + headers: requestHeaders + }); + + if (!attributeResponse.ok) { + const body = await attributeResponse.text().catch(() => ""); + res.status(502).json({ + error: `Zammad Objektattribute konnten nicht geladen werden (${attributeResponse.status})`, + detail: body.slice(0, 500) + }); + return; + } + + let attributeOptions: ReturnType; + + try { + attributeOptions = parseZammadOrganizationAttributeOptions(await attributeResponse.json()); + } catch (error) { + res.status(502).json({ + error: "Zammad Objektattribute konnten nicht verarbeitet werden", + detail: errorMessage(error) + }); + return; + } + const response = await zammadFetch(`${baseUrl}/api/v1/organizations`, { - headers: { - Authorization: `Token token=${apiKey}`, - Accept: "application/json" - } + headers: requestHeaders }); if (!response.ok) { @@ -12545,6 +15330,8 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) const raw = item as Record; const zammadId = Number(raw.id); const name = typeof raw.name === "string" ? raw.name.trim() : ""; + const customerOwner = optionalString(raw.customer_owner); + const hotlineContract = optionalString(raw.hotline_contract); if (!Number.isInteger(zammadId) || zammadId <= 0 || name.length === 0) { return null; @@ -12552,26 +15339,82 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) return { zammadId, - name + name, + customerOwner, + hotlineContract }; }) .filter((organization): organization is NonNullable => Boolean(organization)); const syncResult = await withTransaction(async (client) => { let synced = 0; + const optionIdsByAttributeAndKey = new Map(); + + await client.query( + ` + UPDATE organization_attribute_options + SET active = false, + synced_at = now(), + updated_at = now() + WHERE attribute_name = ANY($1::text[]); + `, + [[...organizationAttributeNames]] + ); + + for (const option of attributeOptions) { + const optionResult = await client.query<{ id: string }>( + ` + INSERT INTO organization_attribute_options ( + zammad_attribute_id, + attribute_name, + option_key, + display_name, + active, + synced_at, + updated_at + ) + VALUES ($1, $2, $3, $4, true, now(), now()) + ON CONFLICT (attribute_name, option_key) + DO UPDATE SET + zammad_attribute_id = EXCLUDED.zammad_attribute_id, + display_name = EXCLUDED.display_name, + active = true, + synced_at = now(), + updated_at = now() + RETURNING id::text AS id; + `, + [option.zammadAttributeId, option.attributeName, option.optionKey, option.displayName] + ); + const optionId = optionResult.rows[0]?.id; + + if (!optionId) { + throw new Error(`Attributoption konnte nicht gespeichert werden: ${option.attributeName}/${option.optionKey}`); + } + + optionIdsByAttributeAndKey.set(`${option.attributeName}\u0000${option.optionKey}`, optionId); + } for (const organization of organizations) { + const customerOwnerOptionId = organization.customerOwner + ? optionIdsByAttributeAndKey.get(`customer_owner\u0000${organization.customerOwner}`) ?? null + : null; + const hotlineContractOptionId = organization.hotlineContract + ? optionIdsByAttributeAndKey.get(`hotline_contract\u0000${organization.hotlineContract}`) ?? null + : null; + await client.query( ` - INSERT INTO organizations (zammad_id, name, synced_at, updated_at) - VALUES ($1, $2, now(), now()) + INSERT INTO organizations (zammad_id, name, customer_owner_option_id, hotline_contract_option_id, synced_at, updated_at) + VALUES ($1, $2, $3, $4, now(), now()) ON CONFLICT (zammad_id) DO UPDATE SET name = EXCLUDED.name, + customer_owner_option_id = EXCLUDED.customer_owner_option_id, + hotline_contract_option_id = EXCLUDED.hotline_contract_option_id, synced_at = now(), updated_at = now(); `, - [organization.zammadId, organization.name] + [organization.zammadId, organization.name, customerOwnerOptionId, hotlineContractOptionId] ); synced += 1; } @@ -12633,6 +15476,7 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) return { synced, + attributeOptions: attributeOptions.length, removed, unlinkedTickets, unlinkedSessions @@ -12925,6 +15769,9 @@ function parseRecurringBillingPayload(body: Record) { const organizationId = parseOrganizationId(body.organizationId); const activity = requireString(body.activity, "activity"); const workType = parseWorkType(body.workType); + const teamspaceProject = optionalString(body.teamspaceProject); + const hourBudgetMinutes = parseOptionalNonNegativeInteger(body.hourBudgetMinutes, "hourBudgetMinutes"); + const manualConsumptionMinutes = parseOptionalNonNegativeInteger(body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; const recurrenceType = parseRecurrenceType(body.recurrenceType); const intervalValue = recurrenceType === "weekly" ? 1 : Math.max(1, parsePositiveInteger(body.intervalValue ?? 1, "intervalValue")); const validFrom = parseDay(requireString(body.validFrom, "validFrom")).start; @@ -12937,6 +15784,9 @@ function parseRecurringBillingPayload(body: Record) { organizationId, activity, workType, + teamspaceProject, + hourBudgetMinutes, + manualConsumptionMinutes, recurrenceType, intervalValue, validFrom, @@ -12960,6 +15810,9 @@ async function recurringBillingResponse(userId: string) { o.name AS organization_name, rb.activity, rb.work_type, + rb.teamspace_project, + rb.hour_budget_minutes, + rb.manual_consumption_minutes, rb.recurrence_type, rb.interval_value, rb.valid_from::text, @@ -13017,13 +15870,16 @@ app.post("/api/recurring-billings", requireUser, async (req, res) => { organization_id, activity, work_type, + teamspace_project, + hour_budget_minutes, + manual_consumption_minutes, recurrence_type, interval_value, valid_from, valid_until, start_time ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8::date, $9::date, $10::time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::date, $12::date, $13::time) RETURNING id; `, [ @@ -13032,6 +15888,9 @@ app.post("/api/recurring-billings", requireUser, async (req, res) => { organization.id, payload.activity, payload.workType, + payload.teamspaceProject, + payload.hourBudgetMinutes, + payload.manualConsumptionMinutes, payload.recurrenceType, payload.intervalValue, payload.validFrom, @@ -13050,6 +15909,29 @@ app.post("/api/recurring-billings", requireUser, async (req, res) => { ); } + const ticketNumber = payload.ticketNumber ?? `Fix#${billingResult.rows[0].id}`; + const ticketResult = await client.query<{ id: string }>( + ` + 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 + 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 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 EXCLUDED.work_type ELSE tickets.work_type END + RETURNING id::text; + `, + [userId, ticketNumber, organization.id, organization.name, payload.workType] + ); + + await ensureRecurringBillingProjectConfiguration(client, ticketResult.rows[0].id, userId, { + workType: payload.workType, + teamspaceProject: payload.teamspaceProject, + hourBudgetMinutes: payload.hourBudgetMinutes, + manualConsumptionMinutes: payload.manualConsumptionMinutes + }); + return billingResult.rows[0].id; }); @@ -13071,6 +15953,9 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => body.organizationId === undefined && body.activity === undefined && body.workType === undefined && + body.teamspaceProject === undefined && + body.hourBudgetMinutes === undefined && + body.manualConsumptionMinutes === undefined && body.recurrenceType === undefined && body.intervalValue === undefined && body.validFrom === undefined && @@ -13140,21 +16025,27 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => organization_id = $2, activity = $3, work_type = $4, - recurrence_type = $5, - interval_value = $6, - valid_from = $7::date, - valid_until = $8::date, - start_time = $9::time, - active = $10, + teamspace_project = $5, + hour_budget_minutes = $6, + manual_consumption_minutes = $7, + recurrence_type = $8, + interval_value = $9, + valid_from = $10::date, + valid_until = $11::date, + start_time = $12::time, + active = $13, updated_at = now() - WHERE id = $11 - AND user_id = $12; + WHERE id = $14 + AND user_id = $15; `, [ payload.ticketNumber, organization.id, payload.activity, payload.workType, + payload.teamspaceProject, + payload.hourBudgetMinutes, + payload.manualConsumptionMinutes, payload.recurrenceType, payload.intervalValue, payload.validFrom, @@ -13254,21 +16145,29 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => ON CONFLICT (ticket_number) DO UPDATE SET 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, + 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 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) + ELSE EXCLUDED.work_type END RETURNING id; `, [targetTicketNumber, organization.id, organization.name, payload.workType, userId] ); - if (payload.workType === "consulting") { - await ensureProjectForTicket(client, targetTicketResult.rows[0].id, userId); - } + const projectTeamspaceEntryId = await ensureRecurringBillingProjectConfiguration( + client, + targetTicketResult.rows[0].id, + userId, + { + workType: payload.workType, + teamspaceProject: payload.teamspaceProject, + hourBudgetMinutes: payload.hourBudgetMinutes, + manualConsumptionMinutes: payload.manualConsumptionMinutes, + removeProjectWhenUnconfigured: true + } + ); const updatedSessions = await client.query<{ ticket_id: string; started_at: string }>( ` @@ -13281,7 +16180,8 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => started_at = (s.recurring_occurrence_date::timestamp + slot.start_time)::timestamptz, ended_at = ((s.recurring_occurrence_date::timestamp + slot.start_time) + (slot.duration_minutes || ' minutes')::interval)::timestamptz, duration_seconds = slot.duration_minutes * 60, - rounded_minutes = slot.duration_minutes + rounded_minutes = slot.duration_minutes, + project_teamspace_entry_id = $10 FROM recurring_billing_slots slot WHERE s.recurring_billing_id = $1 AND s.user_id = $2 @@ -13300,7 +16200,8 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => payload.activity, payload.workType, payload.validFrom, - payload.validUntil + payload.validUntil, + projectTeamspaceEntryId ] ); @@ -13415,21 +16316,11 @@ app.post("/api/timers", requireUser, async (req, res) => { const projectName = optionalNullableString(req.body.projectName); const ticketLookupDone = optionalBoolean(req.body.ticketLookupDone, false); const activityHtml = optionalZammadArticleHtml(req.body.zammadArticleBody); + const workSegments = workSegmentsForNewTimer(Boolean(user.session_work_segments_enabled), startedAt, pausedAt); const timers = await withTransaction(async (client) => { if (phase === "running") { - await client.query( - ` - UPDATE active_timers - SET phase = 'paused', - paused_at = COALESCE(paused_at, now()), - updated_at = now() - WHERE user_id = $1 - AND ticket_number <> $2 - AND phase = 'running'; - `, - [user.id, ticketNumber] - ); + await pauseOtherRunningTimers(client, user.id, new Date(), { exceptTicketNumber: ticketNumber }, Boolean(user.session_work_segments_enabled)); } await client.query( @@ -13448,9 +16339,10 @@ app.post("/api/timers", requireUser, async (req, res) => { started_at, paused_total_ms, paused_at, + work_segments, phase ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, $15) ON CONFLICT (user_id, ticket_number) DO UPDATE SET organization_name = EXCLUDED.organization_name, @@ -13464,6 +16356,7 @@ app.post("/api/timers", requireUser, async (req, res) => { started_at = EXCLUDED.started_at, paused_total_ms = EXCLUDED.paused_total_ms, paused_at = EXCLUDED.paused_at, + work_segments = EXCLUDED.work_segments, phase = EXCLUDED.phase, updated_at = now(); `, @@ -13481,6 +16374,7 @@ app.post("/api/timers", requireUser, async (req, res) => { startedAt.toISOString(), pausedTotalMs, pausedAt?.toISOString() ?? null, + JSON.stringify(workSegments), phase ] ); @@ -13525,6 +16419,25 @@ app.patch("/api/timers/:timerId", requireUser, async (req, res) => { ? currentPausedAt ?? now : now : null; + const currentWorkSegments = normalizeWorkSegments(current.work_segments); + const nextWorkSegments = (() => { + if (!user.session_work_segments_enabled) { + return []; + } + + if (nextPhase === "paused") { + const segments = currentWorkSegments.length > 0 + ? currentWorkSegments + : [{ startedAt: new Date(current.started_at).toISOString(), endedAt: null }]; + return current.phase === "running" ? closeOpenWorkSegment(segments, now) : segments; + } + + if (current.phase === "paused" || !currentWorkSegments.some((segment) => segment.endedAt === null)) { + return appendOpenWorkSegment(currentWorkSegments, current.phase === "running" ? new Date(current.started_at) : now); + } + + return currentWorkSegments; + })(); const activity = req.body.activity === undefined ? current.activity : typeof req.body.activity === "string" @@ -13539,18 +16452,7 @@ app.patch("/api/timers/:timerId", requireUser, async (req, res) => { const ticketLookupDone = req.body.ticketLookupDone === undefined ? current.ticket_lookup_done : optionalBoolean(req.body.ticketLookupDone, current.ticket_lookup_done); if (nextPhase === "running") { - await client.query( - ` - UPDATE active_timers - SET phase = 'paused', - paused_at = COALESCE(paused_at, $3), - updated_at = now() - WHERE user_id = $1 - AND id <> $2 - AND phase = 'running'; - `, - [user.id, timerId, now.toISOString()] - ); + await pauseOtherRunningTimers(client, user.id, now, { exceptId: timerId }, Boolean(user.session_work_segments_enabled)); } await client.query( @@ -13566,7 +16468,8 @@ app.patch("/api/timers/:timerId", requireUser, async (req, res) => { billing_status = $10, paused_total_ms = $11, paused_at = $12, - phase = $13, + work_segments = $13::jsonb, + phase = $14, updated_at = now() WHERE id = $1 AND user_id = $2; @@ -13584,6 +16487,7 @@ app.patch("/api/timers/:timerId", requireUser, async (req, res) => { billingStatus, nextPausedTotalMs, nextPausedAt?.toISOString() ?? null, + JSON.stringify(nextWorkSegments), nextPhase ] ); @@ -13623,10 +16527,12 @@ app.post("/api/sessions", requireUser, async (req, res) => { const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); const billingStatus = req.body.billingStatus === undefined ? null : parseBillingStatus(req.body.billingStatus); const createZammadArticle = optionalBoolean(req.body.createZammadArticle, false); + const zammadArticleDateHeader = optionalBoolean(req.body.zammadArticleDateHeader, false) && Boolean(user.zammad_session_note_date_header_enabled); const zammadArticleTypeId = parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id); const startedAt = parseIsoDate(req.body.startedAt, "startedAt"); const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds"); + const workSegments = user.session_work_segments_enabled ? workSegmentsForSession(req.body.workSegments, startedAt, endedAt) : []; const created = await createSessionForUser({ userId: user.id, @@ -13639,7 +16545,9 @@ app.post("/api/sessions", requireUser, async (req, res) => { requestedProjectTeamspaceEntryId, billingStatus, createZammadArticle, + zammadArticleDateHeader, zammadArticleTypeId, + workSegments, startedAt, endedAt, durationSeconds @@ -14114,7 +17022,7 @@ async function runExistingZammadSync( } if (!dryRun) { - const status = await withTransaction(async (client) => applyZammadTicketSync(client, localTicket, zammadTicket, userId, fields)); + const status = await withTransaction(async (client) => applyZammadTicketSyncWithAssignment(client, localTicket, zammadTicket, userId, fields)); if (status === "organization-missing" || status === "not-owner") { const item = { @@ -14287,7 +17195,7 @@ async function runExistingZammadSyncFromPreview( continue; } - const status = await withTransaction(async (client) => applyZammadTicketSync(client, localTicket, zammadTicket, userId, fields)); + const status = await withTransaction(async (client) => applyZammadTicketSyncWithAssignment(client, localTicket, zammadTicket, userId, fields)); if (status === "organization-missing" || status === "not-owner") { updateZammadSyncResultFromItem(result, { @@ -14527,7 +17435,9 @@ async function runZammadMailSessionImport(userId: string, daysBack: number, perP requestedProjectTeamspaceEntryId: null, billingStatus: "billable", createZammadArticle: false, + zammadArticleDateHeader: false, zammadArticleTypeId: 6, + workSegments: [], startedAt, endedAt, durationSeconds: article.durationSeconds, @@ -14631,7 +17541,9 @@ async function runZammadMailSessionImportFromPreview(userId: string, previewResu requestedProjectTeamspaceEntryId: null, billingStatus: "billable", createZammadArticle: false, + zammadArticleDateHeader: false, zammadArticleTypeId: 6, + workSegments: [], startedAt, endedAt, durationSeconds: article.durationSeconds, @@ -15450,6 +18362,9 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { }); app.post("/api/projects/:projectId/teamspace-subentries/sync", requireUser, async (req, res) => { + res.status(410).json({ error: "Teamspace-Unterpositionen werden nicht mehr in TicketTracker gespeichert. Zeiten werden dem Hauptprojekt zugeordnet." }); + return; + /* Legacy implementation retained temporarily for migration reference. const user = currentUser(req); const projectId = parsePositiveInteger(req.params.projectId, "projectId"); @@ -15576,10 +18491,13 @@ app.post("/api/projects/:projectId/teamspace-subentries/sync", requireUser, asyn teamspaceTimeBillableMinutes }; - res.json({ result }); + res.json({ result }); */ }); app.post("/api/teamspace/subentries/sync", requireUser, async (req, res) => { + res.status(410).json({ error: "Teamspace-Unterpositionen werden nicht mehr in TicketTracker gespeichert. Zeiten werden dem Hauptprojekt zugeordnet." }); + return; + /* Legacy implementation retained temporarily for migration reference. const user = currentUser(req); const entriesResult = await query( @@ -15651,10 +18569,13 @@ app.post("/api/teamspace/subentries/sync", requireUser, async (req, res) => { subentriesRemoved: syncResult.removed }; - res.json({ result }); + res.json({ result }); */ }); app.patch("/api/project-teamspace-subentries/:subentryId", requireUser, async (req, res) => { + res.status(410).json({ error: "Teamspace-Unterpositionen werden nicht mehr in TicketTracker gespeichert." }); + return; + /* Legacy implementation retained temporarily for migration reference. const userId = currentUser(req).id; const subentryId = parsePositiveInteger(req.params.subentryId, "subentryId"); const alias = optionalString(req.body.alias) ?? ""; @@ -15731,7 +18652,7 @@ app.patch("/api/project-teamspace-subentries/:subentryId", requireUser, async (r return; } - res.json({ subentry: result }); + res.json({ subentry: result }); */ }); app.post("/api/projects", requireUser, async (req, res) => { @@ -15848,6 +18769,10 @@ app.post("/api/projects", requireUser, async (req, res) => { return "not-owner" as const; } + if (existingTicket.rows[0] && await ticketHasRecurringBilling(client, existingTicket.rows[0].ticket_number, userId)) { + return "recurring-managed-ticket" as const; + } + if ( existingTicket.rows[0]?.zammad_ticket_id && ( @@ -16057,6 +18982,11 @@ app.post("/api/projects", requireUser, async (req, res) => { return; } + if (created === "recurring-managed-ticket") { + res.status(409).json({ error: "Projektdaten einer Fixabrechnung können nur in der Definition der fixen Abrechnung geändert werden." }); + return; + } + res.status(201).json({ project: created }); } catch (error: any) { if (error?.code === "23505") { @@ -16144,6 +19074,11 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { } const projectTicket = projectResult.rows[0]; + + if (await ticketHasRecurringBilling(client, projectTicket.ticket_number, userId)) { + return "recurring-managed-ticket" as const; + } + const zammadManagedTicket = Boolean(projectTicket.zammad_ticket_id); if ( @@ -16404,6 +19339,11 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { return; } + if (updated === "recurring-managed-ticket") { + res.status(409).json({ error: "Projektdaten einer Fixabrechnung können nur in der Definition der fixen Abrechnung geändert werden." }); + return; + } + if (!updated) { res.status(404).json({ error: "Projekt nicht gefunden" }); return; @@ -16426,11 +19366,12 @@ app.patch("/api/projects/:projectId/review", requireUser, async (req, res) => { const acknowledged = optionalBoolean(req.body.acknowledged, true); const result = 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 }>( ` - SELECT id::text, ticket_id::text - FROM projects - WHERE id = $1 + SELECT p.id::text, p.ticket_id::text, t.ticket_number + FROM projects p + JOIN tickets t ON t.id = p.ticket_id + WHERE p.id = $1 FOR UPDATE; `, [projectId] @@ -16444,6 +19385,10 @@ app.patch("/api/projects/:projectId/review", requireUser, async (req, res) => { return "not-owner" as const; } + if (await ticketHasRecurringBilling(client, projectResult.rows[0].ticket_number, userId)) { + return "recurring-managed-ticket" as const; + } + const updateResult = await client.query<{ id: string; ticket_id: string; close_review_required: boolean }>( ` UPDATE projects @@ -16468,6 +19413,11 @@ app.patch("/api/projects/:projectId/review", requireUser, async (req, res) => { return; } + if (result === "recurring-managed-ticket") { + res.status(409).json({ error: "Prüfstatus einer Fixabrechnung kann nicht in den Ticketdetails geändert werden." }); + return; + } + res.json({ project: result }); }); @@ -16477,11 +19427,12 @@ app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { const closed = optionalBoolean(req.body.closed, false); const result = 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 }>( ` - SELECT id::text, ticket_id::text - FROM projects - WHERE id = $1 + SELECT p.id::text, p.ticket_id::text, t.ticket_number + FROM projects p + JOIN tickets t ON t.id = p.ticket_id + WHERE p.id = $1 FOR UPDATE; `, [projectId] @@ -16495,6 +19446,10 @@ app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { return "not-owner" as const; } + if (await ticketHasRecurringBilling(client, projectResult.rows[0].ticket_number, userId)) { + return "recurring-managed-ticket" as const; + } + const updateResult = await client.query<{ id: string; ticket_id: string; closed_at: string | null }>( ` UPDATE projects @@ -16520,6 +19475,11 @@ app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { return; } + if (result === "recurring-managed-ticket") { + res.status(409).json({ error: "Der Projektstatus einer Fixabrechnung wird über die Fixabrechnung verwaltet." }); + return; + } + res.json({ project: result }); }); @@ -16590,12 +19550,13 @@ app.delete("/api/projects/:projectId", requireUser, async (req, res) => { const userId = currentUser(req).id; const result = 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 }>( ` - SELECT id::text, ticket_id::text - FROM projects - WHERE id = $1 - AND user_id = $2 + SELECT p.id::text, p.ticket_id::text, t.ticket_number + FROM projects p + JOIN tickets t ON t.id = p.ticket_id + WHERE p.id = $1 + AND p.user_id = $2 FOR UPDATE; `, [projectId, userId] @@ -16609,6 +19570,10 @@ app.delete("/api/projects/:projectId", requireUser, async (req, res) => { return "not-owner" as const; } + if (await ticketHasRecurringBilling(client, projectResult.rows[0].ticket_number, userId)) { + return "recurring-managed-ticket" as const; + } + const otherUserSessionResult = await client.query<{ exists: boolean }>( ` SELECT EXISTS ( @@ -16651,6 +19616,11 @@ app.delete("/api/projects/:projectId", requireUser, async (req, res) => { return; } + if (result === "recurring-managed-ticket") { + res.status(409).json({ error: "Das Projekt einer Fixabrechnung kann nur über die Fixabrechnung verwaltet werden." }); + return; + } + res.json({ deleted: result }); }); @@ -16895,6 +19865,75 @@ app.post("/api/tickets/:ticketId/zammad-mail-sessions", requireUser, async (req, res.json(await runTicketZammadMailSessionImport(currentUser(req).id, ticketId, daysBack, false, selectedArticleIds)); }); +app.patch("/api/tickets/:ticketId/atoss-support", requireUser, async (req, res) => { + const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); + const atossSupport = optionalBoolean(req.body.atossSupport, false); + const userId = currentUser(req).id; + const result = await withTransaction(async (client) => { + if (!await userCanManageTicket(client, ticketId, userId)) { + return "not-owner" as const; + } + + const ticketResult = await client.query<{ work_type: "support" | "consulting" | null }>( + ` + SELECT work_type + FROM tickets + WHERE id = $1 + FOR UPDATE; + `, + [ticketId] + ); + const ticket = ticketResult.rows[0]; + + if (!ticket) { + return null; + } + + const ticketNumberResult = await client.query<{ ticket_number: string }>("SELECT ticket_number FROM tickets WHERE id = $1;", [ticketId]); + if (ticketNumberResult.rows[0] && await ticketHasRecurringBilling(client, ticketNumberResult.rows[0].ticket_number, userId)) { + return "recurring-managed-ticket" as const; + } + + if (ticket.work_type !== "support" && atossSupport) { + return "support-only" as const; + } + + await client.query( + ` + UPDATE tickets + SET atoss_support = $2 + WHERE id = $1; + `, + [ticketId, atossSupport] + ); + await refreshSyncedTeamspaceAssignment(client, String(ticketId)); + + return { atoss_support: atossSupport }; + }); + + if (result === "not-owner") { + res.status(403).json({ error: "Nur der Hauptuser kann den Atoss-Support-Fallback ändern" }); + return; + } + + if (result === "support-only") { + res.status(409).json({ error: "Atoss Support kann nur für Support-Tickets aktiviert werden" }); + return; + } + + if (result === "recurring-managed-ticket") { + res.status(409).json({ error: "Die TS-Zuordnung einer Fixabrechnung kann nur in der Definition der fixen Abrechnung geändert werden." }); + return; + } + + if (!result) { + res.status(404).json({ error: "Ticket not found" }); + return; + } + + res.json({ ticket: result }); +}); + app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); @@ -16916,9 +19955,9 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { return "not-owner" as const; } - const currentTicketResult = await client.query<{ zammad_ticket_id: string | null }>( + const currentTicketResult = await client.query<{ zammad_ticket_id: string | null; ticket_number: string }>( ` - SELECT zammad_ticket_id::text AS zammad_ticket_id + SELECT zammad_ticket_id::text AS zammad_ticket_id, ticket_number FROM tickets WHERE id = $1 FOR UPDATE; @@ -16934,6 +19973,10 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { return "zammad-managed-ticket" as const; } + if (await ticketHasRecurringBilling(client, currentTicketResult.rows[0].ticket_number, userId)) { + return "recurring-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 @@ -16961,7 +20004,8 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { title = $2, organization_id = $3, customer_name = $4, - work_type = $5 + work_type = $5, + atoss_support = CASE WHEN $5 = 'support' THEN atoss_support ELSE false END WHERE id = $6 RETURNING id, ticket_number, title, organization_id, customer_name, work_type; `, @@ -16991,6 +20035,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { `, [organization.id, organization.name, workType, ticketId, userId] ); + await refreshSyncedTeamspaceAssignment(client, String(ticketId)); return ticketResult.rows[0]; }); @@ -17020,6 +20065,11 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { return; } + if (result === "recurring-managed-ticket") { + res.status(409).json({ error: "Ticketnummer, Art und Organisation kommen aus der fixen Abrechnung und können nur dort geändert werden." }); + return; + } + if (!result) { res.status(404).json({ error: "Ticket not found" }); return; @@ -17443,6 +20493,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { ticket_id: string; started_at: string; work_type: "support" | "consulting"; + work_segments: unknown; recurring_billing_id: string | null; recurring_work_type: "support" | "consulting" | null; project_id: string | null; @@ -17454,6 +20505,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { s.ticket_id, s.started_at, s.work_type, + s.work_segments, s.recurring_billing_id, rb.work_type AS recurring_work_type, p.id AS project_id, @@ -17512,18 +20564,27 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { await ensureProjectForTicket(client, current.ticket_id, ownerUserId); } - const teamspaceAssignment = await resolveProjectTeamspaceAssignmentForSession( - client, - current.ticket_id, - userId, - effectiveWorkType, - requestedProjectTeamspaceEntryId - ); - projectTeamspaceEntryId = teamspaceAssignment.projectTeamspaceEntryId; + projectTeamspaceEntryId = requestedProjectTeamspaceEntryId === null + ? await resolveSyncedTeamspaceEntry(client, current.ticket_id, userId) + : null; + + if (projectTeamspaceEntryId === null) { + const teamspaceAssignment = await resolveProjectTeamspaceAssignmentForSession( + client, + current.ticket_id, + userId, + effectiveWorkType, + requestedProjectTeamspaceEntryId + ); + projectTeamspaceEntryId = teamspaceAssignment.projectTeamspaceEntryId; + } + } else { + projectTeamspaceEntryId = await resolveSyncedTeamspaceEntry(client, current.ticket_id, userId); } await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId); await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId); + const workSegments = workSegmentsForSession(current.work_segments, startedAt, endedAt); const result = await client.query( ` @@ -17537,9 +20598,10 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { ended_at = $7, duration_seconds = $8, rounded_minutes = $9, - project_teamspace_entry_id = $10 - WHERE id = $11 - AND user_id = $12 + work_segments = $10::jsonb, + project_teamspace_entry_id = $11 + WHERE id = $12 + AND user_id = $13 RETURNING *; `, [ @@ -17552,6 +20614,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { endedAt.toISOString(), durationSeconds, roundedMinutes, + JSON.stringify(workSegments), projectTeamspaceEntryId, sessionId, userId diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index bd91128..47f2ed3 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -118,8 +118,10 @@ export async function migrate() { await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_user_id BIGINT;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_email_session_webhook_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS session_billing_prompt_enabled BOOLEAN NOT NULL DEFAULT true;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS session_work_segments_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_time_tracking_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_date_header_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;"); @@ -129,6 +131,10 @@ export async function migrate() { await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_weeks INTEGER NOT NULL DEFAULT 2;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_complete_on_session BOOLEAN NOT NULL DEFAULT true;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_synced_at TIMESTAMPTZ;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_synced_by BIGINT REFERENCES users(id) ON DELETE SET NULL;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_unlocked_at TIMESTAMPTZ;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_unlocked_by BIGINT REFERENCES users(id) ON DELETE SET NULL;"); await query("ALTER TABLE users DROP COLUMN IF EXISTS teamspace_subproject_assignment_enabled;"); await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;"); @@ -182,21 +188,79 @@ export async function migrate() { ); `); + await query(` + CREATE TABLE IF NOT EXISTS organization_attribute_options ( + id BIGSERIAL PRIMARY KEY, + zammad_attribute_id BIGINT NOT NULL, + attribute_name TEXT NOT NULL, + option_key TEXT NOT NULL, + display_name TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT organization_attribute_options_attribute_valid + CHECK (attribute_name IN ('customer_owner', 'hotline_contract')), + CONSTRAINT organization_attribute_options_key_not_blank CHECK (btrim(option_key) <> ''), + CONSTRAINT organization_attribute_options_display_not_blank CHECK (btrim(display_name) <> ''), + UNIQUE (attribute_name, option_key) + ); + `); + await query("CREATE INDEX IF NOT EXISTS idx_organization_attribute_options_attribute_active ON organization_attribute_options(attribute_name, active, display_name);"); + await query(` CREATE TABLE IF NOT EXISTS teamspace_project_exclusions ( teamspace_project TEXT PRIMARY KEY, + teamspace_api_project_id BIGINT, + teamspace_api_project_ids BIGINT[] NOT NULL DEFAULT '{}'::bigint[], teamspace_name TEXT, customer_name TEXT, + atoss_support_default BOOLEAN NOT NULL DEFAULT false, + atoss_support_valid_from DATE, + atoss_support_valid_to DATE, + customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL, + hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL, note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT teamspace_project_exclusions_project_not_blank CHECK (btrim(teamspace_project) <> '') ); `); + await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_api_project_id BIGINT;"); + await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_api_project_ids BIGINT[] NOT NULL DEFAULT '{}'::bigint[];"); await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_name TEXT;"); await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS customer_name TEXT;"); + await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS atoss_support_default BOOLEAN NOT NULL DEFAULT false;"); + await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS atoss_support_valid_from DATE;"); + await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS atoss_support_valid_to DATE;"); + await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;"); + await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_teamspace_project_exclusions_lower ON teamspace_project_exclusions (lower(btrim(teamspace_project)));"); + await query(` + CREATE TABLE IF NOT EXISTS teamspace_assignment_rules ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + priority INTEGER NOT NULL DEFAULT 100, + customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL, + hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL, + support_enabled BOOLEAN NOT NULL DEFAULT true, + consulting_enabled BOOLEAN NOT NULL DEFAULT false, + teamspace_project TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT teamspace_assignment_rules_name_not_blank CHECK (btrim(name) <> ''), + CONSTRAINT teamspace_assignment_rules_project_not_blank CHECK (btrim(teamspace_project) <> ''), + CONSTRAINT teamspace_assignment_rules_priority_valid CHECK (priority BETWEEN 0 AND 100000), + CONSTRAINT teamspace_assignment_rules_condition_required CHECK ( + customer_owner_option_id IS NOT NULL OR hotline_contract_option_id IS NOT NULL + ), + CONSTRAINT teamspace_assignment_rules_work_type_required CHECK (support_enabled OR consulting_enabled) + ); + `); + await query("CREATE INDEX IF NOT EXISTS idx_teamspace_assignment_rules_matching ON teamspace_assignment_rules(active, priority, id);"); + await query(` CREATE TABLE IF NOT EXISTS teamspace_project_resources ( id BIGSERIAL PRIMARY KEY, @@ -234,6 +298,8 @@ export async function migrate() { id BIGSERIAL PRIMARY KEY, zammad_id BIGINT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE, + customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL, + hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL, synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -246,6 +312,58 @@ export async function migrate() { await query("ALTER TABLE organizations DROP COLUMN IF EXISTS domain;"); await query("ALTER TABLE organizations DROP COLUMN IF EXISTS note;"); await query("ALTER TABLE organizations DROP COLUMN IF EXISTS zammad_updated_at;"); + await query("ALTER TABLE organizations ADD COLUMN IF NOT EXISTS customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;"); + await query("ALTER TABLE organizations ADD COLUMN IF NOT EXISTS hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;"); + await query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'organizations' + AND column_name = 'customer_owner' + ) THEN + INSERT INTO organization_attribute_options (zammad_attribute_id, attribute_name, option_key, display_name, active) + SELECT 0, 'customer_owner', btrim(customer_owner), btrim(customer_owner), false + FROM organizations + WHERE customer_owner IS NOT NULL AND btrim(customer_owner) <> '' + ON CONFLICT (attribute_name, option_key) DO NOTHING; + + UPDATE organizations organization + SET customer_owner_option_id = option.id + FROM organization_attribute_options option + WHERE option.attribute_name = 'customer_owner' + AND option.option_key = btrim(organization.customer_owner) + AND organization.customer_owner_option_id IS NULL; + + ALTER TABLE organizations DROP COLUMN customer_owner; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'organizations' + AND column_name = 'hotline_contract' + ) THEN + INSERT INTO organization_attribute_options (zammad_attribute_id, attribute_name, option_key, display_name, active) + SELECT 0, 'hotline_contract', btrim(hotline_contract), btrim(hotline_contract), false + FROM organizations + WHERE hotline_contract IS NOT NULL AND btrim(hotline_contract) <> '' + ON CONFLICT (attribute_name, option_key) DO NOTHING; + + UPDATE organizations organization + SET hotline_contract_option_id = option.id + FROM organization_attribute_options option + WHERE option.attribute_name = 'hotline_contract' + AND option.option_key = btrim(organization.hotline_contract) + AND organization.hotline_contract_option_id IS NULL; + + ALTER TABLE organizations DROP COLUMN hotline_contract; + END IF; + END $$; + `); await query("CREATE INDEX IF NOT EXISTS idx_organizations_name_lower ON organizations(lower(name));"); await query("DROP INDEX IF EXISTS idx_organizations_active_name;"); @@ -257,6 +375,10 @@ export async function migrate() { organization_id BIGINT REFERENCES organizations(zammad_id), customer_name TEXT, work_type TEXT, + atoss_support BOOLEAN NOT NULL DEFAULT false, + teamspace_assignment_rule_id BIGINT REFERENCES teamspace_assignment_rules(id) ON DELETE SET NULL, + teamspace_assignment_project TEXT, + teamspace_assignment_synced_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'), CONSTRAINT ticket_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')) @@ -267,6 +389,7 @@ 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 atoss_support BOOLEAN NOT NULL DEFAULT false;"); 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;"); @@ -277,6 +400,9 @@ export async function migrate() { await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_number TEXT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS teamspace_assignment_rule_id BIGINT REFERENCES teamspace_assignment_rules(id) ON DELETE SET NULL;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS teamspace_assignment_project TEXT;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS teamspace_assignment_synced_at TIMESTAMPTZ;"); await query(` ALTER TABLE tickets DROP CONSTRAINT IF EXISTS ticket_number_format, @@ -315,31 +441,6 @@ export async function migrate() { 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);"); @@ -375,6 +476,7 @@ export async function migrate() { 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;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS activity_html TEXT;"); + await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS work_segments JSONB NOT NULL DEFAULT '[]'::jsonb;"); await query(` CREATE TABLE IF NOT EXISTS active_timers ( id BIGSERIAL PRIMARY KEY, @@ -416,6 +518,7 @@ export async function migrate() { await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_total_ms BIGINT NOT NULL DEFAULT 0;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS phase TEXT NOT NULL DEFAULT 'running';"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS work_segments JSONB NOT NULL DEFAULT '[]'::jsonb;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now();"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();"); await query(` @@ -877,6 +980,32 @@ export async function migrate() { END $$; `); + 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 TABLE IF NOT EXISTS project_teamspace_entries ( id BIGSERIAL PRIMARY KEY, @@ -1120,6 +1249,37 @@ export async function migrate() { await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_entry_order ON project_teamspace_subentries(project_teamspace_entry_id, display_order, id);"); await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_order ON project_teamspace_subentries(user_id, project_teamspace_entry_id, display_order, id);"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_api ON project_teamspace_subentries(user_id, project_teamspace_entry_id, teamspace_api_project_id);"); + await query(` + WITH exclusion_api_ids AS ( + SELECT + tpe.teamspace_project, + MIN(api_id) AS main_api_id, + array_agg(DISTINCT api_id ORDER BY api_id) AS api_ids + FROM teamspace_project_exclusions tpe + CROSS JOIN LATERAL ( + SELECT pte.teamspace_api_project_id AS api_id + FROM project_teamspace_entries pte + WHERE lower(btrim(pte.teamspace_project)) = lower(btrim(tpe.teamspace_project)) + AND pte.teamspace_api_project_id IS NOT NULL + + UNION + + SELECT pts.teamspace_api_project_id AS api_id + FROM project_teamspace_entries pte + JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte.id + WHERE lower(btrim(pte.teamspace_project)) = lower(btrim(tpe.teamspace_project)) + ) api_source + GROUP BY tpe.teamspace_project + ) + UPDATE teamspace_project_exclusions tpe + SET teamspace_api_project_id = COALESCE(tpe.teamspace_api_project_id, exclusion_api_ids.main_api_id), + teamspace_api_project_ids = CASE + WHEN cardinality(tpe.teamspace_api_project_ids) = 0 THEN exclusion_api_ids.api_ids + ELSE tpe.teamspace_api_project_ids + END + FROM exclusion_api_ids + WHERE tpe.teamspace_project = exclusion_api_ids.teamspace_project; + `); await query(` WITH duplicate_keys AS ( SELECT lower(btrim(teamspace_project)) AS teamspace_key @@ -1561,7 +1721,7 @@ export async function migrate() { AND column_name = 'project_teamspace_subentry_id' ) THEN UPDATE sessions s - SET project_teamspace_entry_id = COALESCE(s.project_teamspace_entry_id, pts.project_teamspace_entry_id) + SET project_teamspace_entry_id = pts.project_teamspace_entry_id FROM project_teamspace_subentries pts WHERE s.project_teamspace_subentry_id = pts.id; END IF; @@ -1685,7 +1845,15 @@ export async function migrate() { 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; + AND ( + s.project_teamspace_entry_id IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM project_teamspace_entries current_pte + WHERE current_pte.id = s.project_teamspace_entry_id + AND current_pte.project_id = sep.project_id + ) + ); `); await query(` @@ -1703,7 +1871,44 @@ export async function migrate() { 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 ( + s.project_teamspace_entry_id IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM project_teamspace_entries current_pte + JOIN projects current_project ON current_project.id = current_pte.project_id + WHERE current_pte.id = s.project_teamspace_entry_id + AND current_project.ticket_id = single_ticket.ticket_id + ) + ); + `); + + await query(` + WITH default_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 pte.is_default + GROUP BY p.ticket_id + HAVING COUNT(pte.id) = 1 + ) + UPDATE sessions s + SET project_teamspace_entry_id = default_ticket.project_teamspace_entry_id + FROM default_entry_tickets default_ticket + WHERE s.ticket_id = default_ticket.ticket_id + AND s.work_type = 'consulting' + AND ( + s.project_teamspace_entry_id IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM project_teamspace_entries current_pte + JOIN projects current_project ON current_project.id = current_pte.project_id + WHERE current_pte.id = s.project_teamspace_entry_id + AND current_project.ticket_id = default_ticket.ticket_id + ) + ); `); await query(` @@ -1832,6 +2037,63 @@ export async function migrate() { await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);"); await query("UPDATE recurring_billings SET active = true WHERE active = false;"); + await query("ALTER TABLE recurring_billings ADD COLUMN IF NOT EXISTS teamspace_project TEXT;"); + await query("ALTER TABLE recurring_billings ADD COLUMN IF NOT EXISTS hour_budget_minutes INTEGER;"); + await query("ALTER TABLE recurring_billings ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;"); + await query(` + WITH existing_configuration AS ( + SELECT DISTINCT ON (rb.id) + rb.id AS recurring_billing_id, + COALESCE(NULLIF(btrim(pte.teamspace_project), ''), NULLIF(btrim(p.teamspace_project), '')) AS teamspace_project, + COALESCE(pte.hour_budget_minutes, p.hour_budget_minutes) AS hour_budget_minutes, + GREATEST( + COALESCE(pte.manual_consumption_minutes, 0), + COALESCE(p.manual_consumption_minutes, 0) + )::int AS manual_consumption_minutes + FROM recurring_billings rb + JOIN tickets t ON t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id) + JOIN projects p + ON p.ticket_id = t.id + AND p.user_id = rb.user_id + LEFT JOIN project_teamspace_entries pte + ON pte.project_id = p.id + AND pte.is_default + ORDER BY rb.id, p.id ASC, pte.id ASC NULLS LAST + ) + UPDATE recurring_billings rb + SET teamspace_project = COALESCE(rb.teamspace_project, existing.teamspace_project), + hour_budget_minutes = COALESCE(rb.hour_budget_minutes, existing.hour_budget_minutes), + manual_consumption_minutes = GREATEST( + rb.manual_consumption_minutes, + existing.manual_consumption_minutes + ), + updated_at = CASE + WHEN rb.teamspace_project IS NULL AND existing.teamspace_project IS NOT NULL + OR rb.hour_budget_minutes IS NULL AND existing.hour_budget_minutes IS NOT NULL + OR existing.manual_consumption_minutes > rb.manual_consumption_minutes + THEN now() + ELSE rb.updated_at + END + FROM existing_configuration existing + WHERE existing.recurring_billing_id = rb.id; + `); + await query(` + ALTER TABLE recurring_billings + DROP CONSTRAINT IF EXISTS recurring_billings_budget_non_negative, + ADD CONSTRAINT recurring_billings_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0), + DROP CONSTRAINT IF EXISTS recurring_billings_manual_consumption_non_negative, + ADD CONSTRAINT recurring_billings_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0); + `); + await query(` + UPDATE tickets t + SET organization_id = rb.organization_id, + customer_name = o.name, + work_type = rb.work_type + FROM recurring_billings rb + JOIN organizations o ON o.zammad_id = rb.organization_id + WHERE t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id) + AND (t.user_id IS NULL OR t.user_id = rb.user_id); + `); await query(` WITH deleted_orphan_sessions AS ( DELETE FROM sessions s @@ -1889,6 +2151,7 @@ export async function migrate() { await query(` DELETE FROM tickets t WHERE t.ticket_number ~ '^Fix#[0-9]+$' + AND t.ticket_number <> 'Fix#0' AND NOT EXISTS ( SELECT 1 FROM sessions s @@ -1916,4 +2179,49 @@ export async function migrate() { WHERE t.id = latest.ticket_id AND (t.customer_name IS NULL OR t.work_type IS NULL); `); + + // Preserve an Atoss fallback that was already applied while the choice still + // lived on individual sessions. Rule-based assignments use the same + // technical Fix#0 container, so only sessions without a stored rule marker + // and on the configured, date-valid Atoss target qualify for this backfill. + await query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM app_settings + WHERE key = 'migration.atoss_support_ticket_backfill_v1' + ) THEN + UPDATE tickets ticket + SET atoss_support = true + WHERE ticket.work_type = 'support' + AND ticket.atoss_support = false + AND ticket.teamspace_assignment_rule_id IS NULL + AND EXISTS ( + SELECT 1 + FROM sessions session + JOIN project_teamspace_entries entry ON entry.id = session.project_teamspace_entry_id + JOIN projects target_project ON target_project.id = entry.project_id + JOIN tickets target_ticket ON target_ticket.id = target_project.ticket_id + JOIN teamspace_project_exclusions exclusion + ON lower(btrim(exclusion.teamspace_project)) = lower(btrim(entry.teamspace_project)) + WHERE session.ticket_id = ticket.id + AND target_ticket.ticket_number = 'Fix#0' + AND exclusion.atoss_support_default = true + AND ( + exclusion.atoss_support_valid_from IS NULL + OR exclusion.atoss_support_valid_from <= (session.started_at AT TIME ZONE 'Europe/Berlin')::date + ) + AND ( + exclusion.atoss_support_valid_to IS NULL + OR exclusion.atoss_support_valid_to >= (session.started_at AT TIME ZONE 'Europe/Berlin')::date + ) + ); + + INSERT INTO app_settings (key, value, updated_at) + VALUES ('migration.atoss_support_ticket_backfill_v1', 'done', now()); + END IF; + END $$; + `); + } diff --git a/backend/src/zammadOrganizationAttributes.ts b/backend/src/zammadOrganizationAttributes.ts new file mode 100644 index 0000000..44b73dc --- /dev/null +++ b/backend/src/zammadOrganizationAttributes.ts @@ -0,0 +1,71 @@ +export const organizationAttributeNames = ["customer_owner", "hotline_contract"] as const; + +export type OrganizationAttributeName = (typeof organizationAttributeNames)[number]; + +export type ZammadOrganizationAttributeOption = { + zammadAttributeId: number; + attributeName: OrganizationAttributeName; + optionKey: string; + displayName: string; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isOrganizationAttributeName(value: unknown): value is OrganizationAttributeName { + return typeof value === "string" && organizationAttributeNames.includes(value as OrganizationAttributeName); +} + +export function parseZammadOrganizationAttributeOptions(payload: unknown): ZammadOrganizationAttributeOption[] { + if (!Array.isArray(payload)) { + throw new Error("Zammad response must be an object-manager attribute array"); + } + + const options: ZammadOrganizationAttributeOption[] = []; + const foundAttributes = new Set(); + + for (const item of payload) { + if (!isRecord(item) || !isOrganizationAttributeName(item.name)) { + continue; + } + + const zammadAttributeId = Number(item.id); + const dataOption = isRecord(item.data_option) + ? item.data_option + : isRecord(item.data_options) + ? item.data_options + : null; + const rawOptions = dataOption && isRecord(dataOption.options) ? dataOption.options : null; + + if (!Number.isInteger(zammadAttributeId) || zammadAttributeId <= 0 || !rawOptions) { + throw new Error(`Zammad object-manager attribute ${item.name} is invalid`); + } + + foundAttributes.add(item.name); + + for (const [rawKey, rawDisplayName] of Object.entries(rawOptions)) { + const optionKey = rawKey.trim(); + const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : ""; + + if (!optionKey || !displayName) { + continue; + } + + options.push({ + zammadAttributeId, + attributeName: item.name, + optionKey, + displayName + }); + } + } + + const missingAttributes = organizationAttributeNames.filter((name) => !foundAttributes.has(name)); + + if (missingAttributes.length > 0) { + throw new Error(`Zammad object-manager attributes missing: ${missingAttributes.join(", ")}`); + } + + return options; +} diff --git a/backend/test/databaseExport.test.ts b/backend/test/databaseExport.test.ts index b7778ba..06a7cda 100644 --- a/backend/test/databaseExport.test.ts +++ b/backend/test/databaseExport.test.ts @@ -1,9 +1,17 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { nextSequenceIdAfterRestore, restoreSqlText, sequenceSetvalSql, sqlLiteral, type QueryExecutor } from "../src/databaseExport.ts"; -import { exportTableSql } from "../src/databaseExport.ts"; +import { + databaseExportTables, + exportTableSql, + nextSequenceIdAfterRestore, + restoreSqlText, + sequenceSetvalSql, + sqlLiteral, + type QueryExecutor +} from "../src/databaseExport.ts"; const jsonbColumn = { dataType: "jsonb", udtName: "jsonb" }; +const bigintArrayColumn = { dataType: "ARRAY", udtName: "_int8" }; test("sqlLiteral serializes primitive values", () => { assert.equal(sqlLiteral(null), "NULL"); @@ -36,6 +44,20 @@ test("sqlLiteral serializes JSON columns as valid JSON values", () => { assert.equal(sqlLiteral(true, jsonbColumn), "'true'"); }); +test("sqlLiteral serializes PostgreSQL arrays with an explicit element type", () => { + assert.equal(sqlLiteral([], bigintArrayColumn), "ARRAY[]::bigint[]"); + assert.equal(sqlLiteral(["32349965", "32349982"], bigintArrayColumn), "ARRAY['32349965', '32349982']::bigint[]"); + assert.throws( + () => sqlLiteral(["value"], { dataType: "ARRAY", udtName: "_unsupported" }), + /Unsupported PostgreSQL array type/ + ); +}); + +test("database export inserts assignment rules before tickets that reference them", () => { + assert.ok(databaseExportTables.indexOf("organization_attribute_options") < databaseExportTables.indexOf("teamspace_assignment_rules")); + assert.ok(databaseExportTables.indexOf("teamspace_assignment_rules") < databaseExportTables.indexOf("tickets")); +}); + test("generated table export never contains implicit object stringification", async () => { const client: QueryExecutor = { async query>(text: string): Promise<{ rows: T[]; rowCount: number | null }> { diff --git a/backend/test/zammadOrganizationAttributes.test.ts b/backend/test/zammadOrganizationAttributes.test.ts new file mode 100644 index 0000000..6110b10 --- /dev/null +++ b/backend/test/zammadOrganizationAttributes.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseZammadOrganizationAttributeOptions } from "../src/zammadOrganizationAttributes.ts"; + +test("parses the two organization select attributes and ignores unrelated attributes", () => { + const options = parseZammadOrganizationAttributeOptions([ + { + id: 85, + name: "customer_owner", + data_option: { options: { ATOSS: "ATOSS", sdw: "SD Worx" } } + }, + { + id: 12, + name: "unrelated", + data_option: { options: { no: "No" } } + }, + { + id: 86, + name: "hotline_contract", + data_option: { options: { none: "(kein Hotlinevertrag)" } } + } + ]); + + assert.deepEqual(options, [ + { zammadAttributeId: 85, attributeName: "customer_owner", optionKey: "ATOSS", displayName: "ATOSS" }, + { zammadAttributeId: 85, attributeName: "customer_owner", optionKey: "sdw", displayName: "SD Worx" }, + { zammadAttributeId: 86, attributeName: "hotline_contract", optionKey: "none", displayName: "(kein Hotlinevertrag)" } + ]); +}); + +test("rejects responses without both required attributes", () => { + assert.throws( + () => parseZammadOrganizationAttributeOptions([ + { id: 85, name: "customer_owner", data_option: { options: { ATOSS: "ATOSS" } } } + ]), + /hotline_contract/ + ); +}); diff --git a/docs/confluence/01-zeiterfassung-sessions-monatsabschluss.md b/docs/confluence/01-zeiterfassung-sessions-monatsabschluss.md index bae9381..3140b7b 100644 --- a/docs/confluence/01-zeiterfassung-sessions-monatsabschluss.md +++ b/docs/confluence/01-zeiterfassung-sessions-monatsabschluss.md @@ -73,12 +73,16 @@ Der Monatsabschluss ist die zentrale Kontrollseite für einen Benutzer und Monat Sie zeigt unter anderem: -- offene Bewertungen -- Sessions mit Zwischenstatus **Abrechenbar** -- fehlende oder abweichende Teamspace-Werte, wenn Teamspace-Erfassung in TT aktiv ist +- eine nach Ticket zusammengefuehrte Pruefliste +- offene Bewertungen und Sessions mit Zwischenstatus **Abrechenbar** +- fehlende oder abweichende Teamspace-Werte als farbige, spaltenweise ausgerichtete Badges; die Tagesdetails stehen am jeweiligen Tag im Ticketdetail - Prüfpunkte vor dem Abschluss - Möglichkeit, den Monat zu schließen oder wieder zu öffnen +Jedes Ticket erscheint in der Pruefliste nur einmal. Monatsabweichungen, offene Bewertungen und betroffene Tage werden innerhalb dieses Eintrags gebuendelt; Hinweise zum gleichen Tag stehen in einer gemeinsamen Tageszeile. + +Ein TT-Teamspace-Tageswert ist nur konsistent, wenn fuer dasselbe Ticket und denselben Tag mindestens eine Session den Status **Abgerechnet** hat. Bei ausschliesslich abrechenbaren, nicht abrechenbaren oder unbewerteten Sessions wird der hinterlegte Wert als Fehler ausgewiesen; ein zusaetzlicher Unterschied zur TS-API bleibt ebenfalls sichtbar. + Ein abgeschlossener Monat ist gegen versehentliche Änderungen geschützt. Wird eine Session in einem abgeschlossenen Zeitraum geändert, muss der Zeitraum vorher wieder geöffnet werden. ## Regeln bei deaktivierter Teamspace-Erfassung diff --git a/docs/user-guide.md b/docs/user-guide.md index ed8f609..70710c4 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -400,13 +400,14 @@ Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefi Die Seite `Monatsabschluss` enthaelt: - Checkliste -- Offene Bewertungen -- Tage ohne Teamspace-Wert +- eine gemeinsame Pruefliste nach Ticket - Button zum Monat abschliessen oder wieder oeffnen -Offene Bewertungen werden nach Ticket gruppiert. Mit `Ticket oeffnen` gelangt man direkt zur Bewertung. +Die Pruefliste fuehrt offene Bewertungen, fehlende Teamspace-Tageswerte sowie Monats- und ticketgenaue Tagesabweichungen zusammen. Jedes Ticket erscheint nur einmal. Farbige Badges stehen in festen Spalten fuer die jeweilige Pruefart. Die zugehoerigen Tagesdetails mit TT-Teamspace-Wert, TS-API-Wert und Differenz werden im Ticketdetail am betroffenen Tag angezeigt. Mit `Ticket oeffnen` gelangt man direkt dorthin. -Tage ohne Teamspace-Wert sind eine Pruefliste. Wenn ein Tag bewusst keinen Teamspace-Wert bekommen soll, kann der Eintrag mit `Zur Kenntnis` abgehakt werden. +Wenn ein Tag bewusst keinen Teamspace-Wert bekommen soll, kann er innerhalb des Ticket-Eintrags mit `Zur Kenntnis` abgehakt werden. Trifft fuer denselben Tag zugleich eine TT/TS-Abweichung zu, werden beide Hinweise in einer gemeinsamen Tageszeile dargestellt. + +Ein hinterlegter TT-Teamspace-Wert setzt mindestens eine als `Abgerechnet` bewertete Session desselben Tickets und Tages voraus. Gibt es nur `Abrechenbar`, `Nicht abrechenbar` oder unbewertete Sessions, erscheint der Tageswert als Fehler. Der TT-Teamspace-Wert und eine eventuelle Abweichung zur TS-API werden trotzdem angezeigt. Zur Kenntnis genommene Eintraege koennen ueber die Checkbox `Zur Kenntnis anzeigen` wieder eingeblendet werden. Diese Anzeigeeinstellung bleibt im Browser gespeichert. Ein abgehakter Eintrag kann mit `Zuruecknehmen` wieder offen gemacht werden. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 92769dc..afc2f66 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { CalendarDays, + Building2, CheckCircle2, ClipboardCheck, Command, @@ -87,8 +88,10 @@ import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./rec import { activeElapsedMs, mergeTimerActivityDrafts, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers"; import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos"; import { AdminUsersPage } from "./views/AdminUsersPage"; +import { AdminOrganizationsPage } from "./views/AdminOrganizationsPage"; import { AnalysisPage } from "./views/AnalysisPage"; import { DashboardPage } from "./views/DashboardPage"; +import { DashboardTimersPage } from "./views/DashboardTimersPage"; import { FaqPage } from "./views/FaqPage"; import { LoginPage } from "./views/LoginPage"; import { MonthlyClosePage } from "./views/MonthlyClosePage"; @@ -225,6 +228,10 @@ function routeFromPath(pathWithSearch: string) { } if (pathname.startsWith("/dashboard")) { + if (pathname.startsWith("/dashboard/timers")) { + return { page: "dashboard-timers" as const }; + } + return { page: "dashboard" as const }; } @@ -260,6 +267,10 @@ function routeFromPath(pathWithSearch: string) { return { page: "admin-users" as const }; } + if (pathname.startsWith("/admin/organizations")) { + return { page: "admin-organizations" as const }; + } + if (pathname.startsWith("/profile")) { return { page: "profile" as const }; } @@ -827,6 +838,7 @@ export function App() { const [timersLoaded, setTimersLoaded] = useState(false); const [timerOwnerId, setTimerOwnerId] = useState(null); const [selectedTimerId, setSelectedTimerId] = useState(null); + const [finishRequestTimerId, setFinishRequestTimerId] = useState(null); const [tick, setTick] = useState(Date.now()); const [openTickets, setOpenTickets] = useState([]); const [ticketReturnPaths, setTicketReturnPaths] = useState>({}); @@ -1160,7 +1172,7 @@ export function App() { return; } - if (route.page === "admin-users" || route.page === "profile" || route.page === "faq") { + if (route.page === "admin-users" || route.page === "admin-organizations" || route.page === "profile" || route.page === "faq") { return; } @@ -1390,13 +1402,15 @@ export function App() { ...(currentUser.role === "admin" ? [ { type: "item" as const, href: "/admin/users", label: "Administration", icon: Shield, active: path.startsWith("/admin/users") }, + { type: "item" as const, href: "/admin/organizations", label: "Organisationen", icon: Building2, active: path.startsWith("/admin/organizations") }, { type: "separator" as const, key: "before-profile" }, { type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, { type: "separator" as const, key: "before-faq" }, { type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") } ] : [ - { type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path.startsWith("/dashboard") }, + { type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path === "/dashboard" }, + { type: "item" as const, href: "/dashboard/timers", label: "Timer-Übersicht", icon: Timer, active: path.startsWith("/dashboard/timers") }, { type: "separator" as const, key: "after-dashboard" }, { type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" }, { type: "item" as const, href: "/analysis", label: "Ticket-Sessions", icon: Ticket, active: path.startsWith("/analysis") }, @@ -1567,7 +1581,7 @@ export function App() { - v1.1.0-rc1 + v1.2.0-rc1 @@ -1662,9 +1676,27 @@ export function App() { setSelectedTimerId={setSelectedTimerId} tick={tick} currentUser={currentUser} + finishRequestId={finishRequestTimerId} + onFinishRequestHandled={() => setFinishRequestTimerId(null)} /> ) : null} {!isAdmin && route.page === "dashboard" ? : null} + {!isAdmin && route.page === "dashboard-timers" ? ( + { + setSelectedTimerId(timerId); + setFinishRequestTimerId(timerId); + navigate("/timer"); + }} + onOpenActiveTimer={() => navigate("/timer")} + /> + ) : null} {!isAdmin && route.page === "analysis" ? : null} {!isAdmin && route.page === "monthly-close" ? : null} {!isAdmin && route.page === "statistics" ? : null} @@ -1675,6 +1707,7 @@ export function App() { {route.page === "faq" ? : null} {route.page === "profile" ? : null} {route.page === "admin-users" && isAdmin ? : null} + {route.page === "admin-organizations" && isAdmin ? : null} {route.page === "admin-users" && !isAdmin ? ( ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"} /> ) : null} - {isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? : null} + {isAdmin && route.page !== "admin-users" && route.page !== "admin-organizations" && route.page !== "profile" && route.page !== "faq" ? : null} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 774bf4d..07c2240 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -19,6 +19,7 @@ import type { TodoPriority, TodoStatus, WorkType, + WorkSegment, ZammadSessionArticleQueueItem } from "./types"; import type { TimerEntry, TimerPhase } from "./timers"; @@ -32,10 +33,12 @@ type CreateSessionPayload = { startedAt: string; endedAt: string; durationSeconds: number; + workSegments?: WorkSegment[]; zammadLookup?: boolean; projectTeamspaceEntryId?: string | null; billingStatus?: BillingStatus; createZammadArticle?: boolean; + zammadArticleDateHeader?: boolean; zammadArticleTypeId?: number; }; @@ -330,8 +333,10 @@ export function updateCurrentUser(payload: { teamspaceDevicePassword?: string; zammadEmailSessionWebhookEnabled?: boolean; sessionBillingPromptEnabled?: boolean; + sessionWorkSegmentsEnabled?: boolean; teamspaceTimeTrackingEnabled?: boolean; zammadSessionNoteDefaultEnabled?: boolean; + zammadSessionNoteDateHeaderEnabled?: boolean; zammadTimeUnitRoundingEnabled?: boolean; zammadTimeUnitRoundingMinutes?: number; zammadAtossEmailSyncEnabled?: boolean; @@ -436,6 +441,37 @@ export function updateAdminUser( }); } +export type TeamspaceInitialActualsSyncResult = { + userId: string; + fromMonth: string; + toMonth: string; + checkedEntries: number; + matchedEntries: number; + updatedProjectIds: number; + missingEntries: number; + ambiguousEntries: number; + monthsChecked: number; + monthsSkippedClosed: number; + teamspaceTimeEntriesScanned: number; + updatedManualEntries: number; + importedManualMinutes: number; + existingDayBillingMinutes: number; +}; + +export function syncAdminUserTeamspaceInitialActuals(userId: string, payload: { fromMonth?: string; toMonth?: string }) { + return request<{ result: TeamspaceInitialActualsSyncResult }>(`/api/admin/users/${userId}/teamspace-initial-actuals/sync`, { + method: "POST", + timeoutMs: 10 * 60 * 1000, + body: JSON.stringify(payload) + }); +} + +export function unlockAdminUserTeamspaceInitialActuals(userId: string) { + return request<{ user: AdminUser }>(`/api/admin/users/${userId}/teamspace-initial-actuals/unlock`, { + method: "POST" + }); +} + export async function downloadDatabaseExport() { const response = await fetch("/api/admin/export/database", { credentials: "same-origin" @@ -502,6 +538,33 @@ export function getOrganizations(search = "") { return request<{ organizations: Organization[] }>(`/api/organizations${params.size ? `?${params}` : ""}`); } +export function getAdminOrganizations(options: { + search?: string; + page?: number; + pageSize?: number | "all"; + missingAssignment?: boolean; +} = {}) { + const params = new URLSearchParams(); + + if (options.search?.trim()) { + params.set("search", options.search.trim()); + } + + if (options.page) { + params.set("page", String(options.page)); + } + + if (options.pageSize) { + params.set("pageSize", String(options.pageSize)); + } + + if (options.missingAssignment) { + params.set("missingAssignment", "true"); + } + + return request<{ organizations: Organization[]; total: number; page: number; pageSize: number | "all" }>(`/api/admin/organizations${params.size ? `?${params}` : ""}`); +} + export function getZammadSettings() { return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings"); } @@ -526,16 +589,61 @@ export function saveTeamspaceSettings(payload: { baseUrl: string; credentialsUse export type TeamspaceProjectExclusion = { teamspace_project: string; + teamspace_api_project_id: string | null; + teamspace_api_project_ids: string[] | null; teamspace_name: string | null; customer_name: string | null; + atoss_support_default: boolean; + atoss_support_valid_from: string | null; + atoss_support_valid_to: string | null; + customer_owner_option_id: string | null; + hotline_contract_option_id: string | null; note: string; }; +export type OrganizationAttributeOption = { + id: string; + zammad_attribute_id: string; + attribute_name: "customer_owner" | "hotline_contract"; + option_key: string; + display_name: string; + active: boolean; +}; + +export type TeamspaceAssignmentRule = { + id?: string; + name: string; + active: boolean; + priority: number; + customer_owner_option_id: string | null; + hotline_contract_option_id: string | null; + support_enabled: boolean; + consulting_enabled: boolean; + teamspace_project: string; + created_at?: string; + updated_at?: string; +}; + +export function getOrganizationAttributeOptions() { + return request<{ options: OrganizationAttributeOption[] }>("/api/admin/zammad/organization-attribute-options"); +} + +export function getTeamspaceAssignmentRules() { + return request<{ rules: TeamspaceAssignmentRule[] }>("/api/admin/teamspace/assignment-rules"); +} + +export function saveTeamspaceAssignmentRules(rules: TeamspaceAssignmentRule[]) { + return request<{ rules: TeamspaceAssignmentRule[] }>("/api/admin/teamspace/assignment-rules", { + method: "PUT", + body: JSON.stringify({ rules }) + }); +} + export function getTeamspaceProjectExclusions() { return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions"); } -export function saveTeamspaceProjectExclusions(projects: string[]) { +export function saveTeamspaceProjectExclusions(projects: Array>) { return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions", { method: "PUT", body: JSON.stringify({ projects }) @@ -543,7 +651,7 @@ export function saveTeamspaceProjectExclusions(projects: string[]) { } export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) { - return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", { + return request<{ synced: number; attributeOptions: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", { method: "POST", body: JSON.stringify(payload) }); @@ -712,6 +820,9 @@ export function createRecurringBilling(payload: { organizationId: string; activity: string; workType: WorkType; + teamspaceProject?: string | null; + hourBudgetMinutes?: number | null; + manualConsumptionMinutes?: number | null; recurrenceType: "weekly" | "every_n_weeks"; intervalValue: number; validFrom: string; @@ -737,6 +848,9 @@ export function updateRecurringBilling( organizationId: string; activity: string; workType: WorkType; + teamspaceProject?: string | null; + hourBudgetMinutes?: number | null; + manualConsumptionMinutes?: number | null; recurrenceType: "weekly" | "every_n_weeks"; intervalValue: number; validFrom: string; @@ -1008,6 +1122,13 @@ export function updateTicket( }); } +export function updateTicketAtossSupport(ticketId: string, atossSupport: boolean) { + return request<{ ticket: { atoss_support: boolean } }>(`/api/tickets/${ticketId}/atoss-support`, { + method: "PATCH", + body: JSON.stringify({ atossSupport }) + }); +} + export function updateSessionDetails( sessionId: string, payload: { diff --git a/frontend/src/components/TodoPanel.tsx b/frontend/src/components/TodoPanel.tsx index 78cf9da..1f30925 100644 --- a/frontend/src/components/TodoPanel.tsx +++ b/frontend/src/components/TodoPanel.tsx @@ -1,5 +1,5 @@ import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react"; -import { FormEvent, useState } from "react"; +import { type FormEvent, type ReactNode, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; @@ -40,9 +40,11 @@ type TodoPanelProps = { onNavigate?: (to: string) => void; reorderEnabled?: boolean; onReorder?: (draggedTodoId: string, targetTodoId: string) => void; + toolbar?: ReactNode; + compact?: boolean; }; -export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder }: TodoPanelProps) { +export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder, toolbar, compact = false }: TodoPanelProps) { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [priority, setPriority] = useState("medium"); @@ -156,97 +158,100 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT } return ( -
- {showCreate && ticketId ? ( -
- - - - - - - ToDo anlegen - Neue Aufgabe zu diesem Ticket oder Projekt erfassen. - -
-
- - setTitle(event.currentTarget.value)} autoFocus required /> -
-
- - -
-
+
+ {toolbar || (showCreate && ticketId) ? ( +
+
{toolbar}
+ {showCreate && ticketId ? ( + + + + + + + ToDo anlegen + Neue Aufgabe zu diesem Ticket oder Projekt erfassen. + +
-