diff --git a/.env.docker-external.example b/.env.docker-external.example index cc371ec..e2f3007 100644 --- a/.env.docker-external.example +++ b/.env.docker-external.example @@ -3,6 +3,7 @@ APP_PORT=3910 DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key +ZAMMAD_EMAIL_WEBHOOK_TOKEN=change-me-zammad-webhook-token DB_POOL_SIZE=10 COOKIE_SECURE=false diff --git a/.env.docker-postgres.example b/.env.docker-postgres.example index 64165be..deafde5 100644 --- a/.env.docker-postgres.example +++ b/.env.docker-postgres.example @@ -5,6 +5,7 @@ POSTGRES_DB=tickettracker POSTGRES_USER=tickettracker POSTGRES_PASSWORD=change-me-db-password SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key +ZAMMAD_EMAIL_WEBHOOK_TOKEN=change-me-zammad-webhook-token DB_POOL_SIZE=10 COOKIE_SECURE=false diff --git a/.env.example b/.env.example index 54ae97d..16470b6 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key +ZAMMAD_EMAIL_WEBHOOK_TOKEN=change-me-zammad-webhook-token APP_PORT=3910 TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest COOKIE_SECURE=false diff --git a/.env_dev b/.env_dev index b03b95d..e8e43e0 100644 --- a/.env_dev +++ b/.env_dev @@ -4,3 +4,4 @@ APP_PORT=3911 TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest COOKIE_SECURE=false DB_POOL_SIZE=10 +ZAMMAD_EMAIL_WEBHOOK_TOKEN='sPXJc87R@ySW3uTFKjGFMrti8BcDkNv8mN2g71hRkdg=' diff --git a/backend/src/auth.ts b/backend/src/auth.ts index d70e63a..d820384 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -17,6 +17,7 @@ export type AuthUser = { active: boolean; zammad_user_id: string | null; has_zammad_api_token: boolean; + zammad_email_session_webhook_enabled: boolean; }; declare global { @@ -77,7 +78,8 @@ function publicUser(user: AuthUser) { role: user.role, active: user.active, zammad_user_id: user.zammad_user_id ?? null, - has_zammad_api_token: Boolean(user.has_zammad_api_token) + has_zammad_api_token: Boolean(user.has_zammad_api_token), + zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled) }; } @@ -134,7 +136,8 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio u.role, u.active, u.zammad_user_id::text AS zammad_user_id, - (u.zammad_api_token IS NOT NULL) AS has_zammad_api_token + (u.zammad_api_token IS NOT NULL) AS has_zammad_api_token, + u.zammad_email_session_webhook_enabled FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 diff --git a/backend/src/index.ts b/backend/src/index.ts index 5f8d074..3a22c04 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import cors from "cors"; import express from "express"; -import { randomUUID } from "node:crypto"; +import { randomUUID, timingSafeEqual } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type pg from "pg"; @@ -27,7 +27,8 @@ import { parseTrackableTicketNumber, parseUsername, parseWorkType, - requireString + requireString, + ticketPattern } from "./validation.js"; const app = express(); @@ -36,6 +37,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const frontendDist = path.resolve(__dirname, "../../frontend/dist"); const serveFrontend = process.env.SERVE_FRONTEND !== "false"; const requestBodyLimit = process.env.REQUEST_BODY_LIMIT ?? "250mb"; +const zammadEmailWebhookToken = process.env.ZAMMAD_EMAIL_WEBHOOK_TOKEN ?? process.env.ZAMMAD_WEBHOOK_TOKEN ?? ""; app.use(cors()); app.use(express.text({ type: ["text/plain", "text/sql"], limit: requestBodyLimit })); @@ -789,6 +791,636 @@ async function fetchZammadTicketByNumber(userId: string, ticketNumber: string): return null; } +type SessionCreationInput = { + userId: string; + ticketNumber: string; + activity: string; + requestedOrganizationId: number | null; + requestedWorkType: "support" | "consulting" | null; + useZammadLookup: boolean; + requestedProjectTeamspaceEntryId: number | null; + startedAt: Date; + endedAt: Date; + durationSeconds: number; + zammadTicket?: ZammadTicketImport | null; + sourceEvent?: { + zammadArticleId: number | null; + deliveryId: string | null; + }; +}; + +async function createSessionForUser(input: SessionCreationInput) { + const { + userId, + ticketNumber, + activity, + requestedOrganizationId, + requestedWorkType, + useZammadLookup, + requestedProjectTeamspaceEntryId, + startedAt, + endedAt, + durationSeconds, + zammadTicket: providedZammadTicket, + sourceEvent + } = input; + + if (endedAt < startedAt) { + throw badRequest("endedAt must be after startedAt"); + } + + if (ticketNumber.startsWith("Fix#") || (await ticketHasRecurringBilling({ query }, ticketNumber, userId))) { + throw Object.assign(new Error("Für fixe Abrechnungen dürfen keine manuellen Sessions erstellt werden. Bitte die fixe Abrechnung bearbeiten."), { status: 409 }); + } + + const monthClosureResult = await query<{ closed_at: string }>( + ` + SELECT closed_at + FROM month_closures + WHERE month = date_trunc('month', $1::timestamptz AT TIME ZONE 'Europe/Berlin')::date + AND user_id = $2; + `, + [startedAt.toISOString(), userId] + ); + + if (monthClosureResult.rows.length > 0) { + throw Object.assign(new Error("Monat ist abgeschlossen"), { status: 409 }); + } + + const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); + const existingLocalTicket = useZammadLookup ? await ticketExists(ticketNumber) : false; + const zammadTicket = providedZammadTicket !== undefined + ? providedZammadTicket + : useZammadLookup && !existingLocalTicket + ? await fetchZammadTicketByNumber(userId, ticketNumber) + : null; + + if (useZammadLookup && !existingLocalTicket && !zammadTicket) { + throw Object.assign(new Error("Ticket wurde in Zammad nicht gefunden"), { status: 404 }); + } + + if (sourceEvent && (sourceEvent.zammadArticleId || sourceEvent.deliveryId)) { + await query( + ` + DELETE FROM zammad_email_session_events + WHERE user_id = $1 + AND session_id IS NULL + AND ( + ($2::bigint IS NOT NULL AND zammad_article_id = $2) + OR ($3::text IS NOT NULL AND delivery_id = $3) + ); + `, + [userId, sourceEvent.zammadArticleId, sourceEvent.deliveryId] + ); + } + + return withTransaction(async (client) => { + let eventId: string | null = null; + + if (sourceEvent && (sourceEvent.zammadArticleId || sourceEvent.deliveryId)) { + const existingEventResult = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM zammad_email_session_events + WHERE user_id = $1 + AND session_id IS NOT NULL + AND ( + ($2::bigint IS NOT NULL AND zammad_article_id = $2) + OR ($3::text IS NOT NULL AND delivery_id = $3) + ) + LIMIT 1; + `, + [userId, sourceEvent.zammadArticleId, sourceEvent.deliveryId] + ); + + if (existingEventResult.rows.length > 0) { + return { + duplicate: true as const + }; + } + + const eventResult = await client.query<{ id: string }>( + ` + INSERT INTO zammad_email_session_events (user_id, zammad_article_id, delivery_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + RETURNING id::text AS id; + `, + [userId, sourceEvent.zammadArticleId, sourceEvent.deliveryId] + ); + + if (eventResult.rows.length === 0) { + return { + duplicate: true as const + }; + } + + eventId = eventResult.rows[0].id; + } + + if (zammadTicket) { + await importZammadTicketForUser(client, zammadTicket, userId); + } + + const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null; project_id: string | null }>( + ` + SELECT t.id, t.organization_id, t.customer_name, t.work_type, p.id AS project_id + FROM tickets t + LEFT JOIN projects p ON p.ticket_id = t.id + WHERE t.ticket_number = $1 + ORDER BY (p.user_id = $2::bigint) DESC, p.id ASC + LIMIT 1 + FOR UPDATE OF t; + `, + [ticketNumber, userId] + ); + const existingTicket = existingTicketResult.rows[0] ?? null; + const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId; + + if (!effectiveOrganizationId) { + throw badRequest("organizationId is required"); + } + + const organization = await getOrganizationByZammadId(client, effectiveOrganizationId); + + if (!organization) { + throw badRequest("organizationId must reference an existing organization"); + } + + const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>( + ` + INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type) + VALUES ($1, $2, $3, $4) + ON CONFLICT (ticket_number) + DO UPDATE SET + organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), + customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), + work_type = CASE + WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' + WHEN EXISTS ( + SELECT 1 FROM projects p WHERE p.ticket_id = tickets.id AND p.user_id = $5 + ) THEN 'consulting' + ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) + END + RETURNING id, ticket_number, organization_id, customer_name, work_type; + `, + [ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId] + ); + + const ticket = ticketResult.rows[0]; + const workType = existingTicket?.project_id ? "consulting" : ticket.work_type ?? requestedWorkType; + + if (!workType) { + throw badRequest("workType must be support or consulting"); + } + + let projectTeamspaceEntryId: number | null = null; + + if (workType === "consulting") { + const projectOwnerUserId = await projectOwnerUserIdForZammadOwner( + client, + zammadTicket?.zammadOwnerId ?? null, + userId + ); + await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, { + teamspaceProject: zammadTicket?.teamspaceProject ?? null, + teamspaceProjects: zammadTicket?.teamspaceProjects ?? [], + hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null, + closeReviewRequired: zammadTicket?.zammadStateId === 4 + }); + projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( + client, + ticket.id, + userId, + workType, + requestedProjectTeamspaceEntryId, + zammadTicket?.zammadOwnerId ?? null + ); + } + + const sessionResult = await client.query( + ` + INSERT INTO sessions ( + ticket_id, + organization_id, + customer_name, + activity, + work_type, + user_id, + started_at, + ended_at, + duration_seconds, + rounded_minutes, + project_teamspace_entry_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *; + `, + [ + ticket.id, + organization.id, + organization.name, + activity, + workType, + userId, + startedAt.toISOString(), + endedAt.toISOString(), + durationSeconds, + roundedMinutes, + projectTeamspaceEntryId + ] + ); + + await reopenPeriodsForSession(client, ticket.id, startedAt, userId); + if (workType === "consulting") { + await reopenProjectsForTicket(client, ticket.id); + await showProjectsForUserTicket(client, ticket.id, userId); + } + + if (eventId) { + await client.query( + ` + UPDATE zammad_email_session_events + SET session_id = $1 + WHERE id = $2; + `, + [sessionResult.rows[0].id, eventId] + ); + } + + return { + ticket, + session: sessionResult.rows[0] + }; + }); +} + +function timingSafeStringEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +function verifyZammadEmailWebhookAuth(req: express.Request) { + if (!zammadEmailWebhookToken) { + return false; + } + + const authorization = req.header("authorization") ?? ""; + const bearerPrefix = "Bearer "; + const token = authorization.startsWith(bearerPrefix) + ? authorization.slice(bearerPrefix.length).trim() + : optionalString(req.header("x-tickettracker-webhook-token")); + + return Boolean(token && timingSafeStringEqual(token, zammadEmailWebhookToken)); +} + +function stringValue(value: unknown) { + if (typeof value === "string") { + const trimmed = value.trim(); + return /^#\{.+\/\s*no such (?:method|object)\}$/i.test(trimmed) ? "" : trimmed; + } + + if (typeof value === "number") { + return String(value); + } + + return ""; +} + +function lowerString(value: unknown) { + return stringValue(value).toLowerCase(); +} + +function zammadWebhookObject(payload: unknown, key: string) { + const value = isPlainObject(payload) ? payload[key] : null; + return isPlainObject(value) ? value : {}; +} + +function normalizeZammadWebhookTicketNumber(value: unknown) { + const raw = stringValue(value); + + if (!raw) { + return null; + } + + if (ticketPattern.test(raw)) { + return raw; + } + + const digits = raw.replace(/^Ticket#/i, "").replace(/\D/g, ""); + return digits ? `Ticket#${digits.padStart(6, "0")}` : null; +} + +function parseZammadWebhookTicketNumber(payload: unknown) { + const ticket = zammadWebhookObject(payload, "ticket"); + const candidates = [ + isPlainObject(payload) ? payload.ticketNumber : null, + isPlainObject(payload) ? payload.ticket_number : null, + ticket.number, + ticket.ticket_number + ]; + + for (const candidate of candidates) { + const ticketNumber = normalizeZammadWebhookTicketNumber(candidate); + + if (ticketNumber && ticketPattern.test(ticketNumber)) { + return ticketNumber; + } + } + + return null; +} + +function parseZammadWebhookDate(payload: unknown) { + const ticket = zammadWebhookObject(payload, "ticket"); + const article = zammadWebhookObject(payload, "article"); + const candidates = [ + isPlainObject(payload) ? payload.updatedAt : null, + isPlainObject(payload) ? payload.updated_at : null, + ticket.updated_at, + article.updated_at, + article.created_at + ]; + + for (const candidate of candidates) { + const raw = stringValue(candidate); + const date = raw ? new Date(raw) : null; + + if (date && !Number.isNaN(date.getTime())) { + return date; + } + } + + return null; +} + +function parsePositiveNumber(value: unknown) { + if (value === null || value === undefined || value === "") { + return null; + } + + const number = Number(typeof value === "string" ? value.trim().replace(",", ".") : value); + return Number.isFinite(number) && number > 0 ? number : null; +} + +function parseZammadWebhookDurationSeconds(payload: unknown) { + const ticket = zammadWebhookObject(payload, "ticket"); + const article = zammadWebhookObject(payload, "article"); + const durationSeconds = parsePositiveNumber(isPlainObject(payload) ? payload.durationSeconds ?? payload.duration_seconds : null); + + if (durationSeconds) { + return Math.round(durationSeconds); + } + + const accountedTime = parsePositiveNumber(isPlainObject(payload) ? payload.accounted_time : null) ?? parsePositiveNumber(article.accounted_time); + + if (accountedTime) { + return Math.round(accountedTime * 60 * 60); + } + + const minuteCandidates = [ + isPlainObject(payload) ? payload.durationMinutes : null, + isPlainObject(payload) ? payload.duration_minutes : null, + article.time_unit, + ticket.time_unit + ]; + + for (const candidate of minuteCandidates) { + const minutes = parsePositiveNumber(candidate); + + if (minutes) { + return Math.round(minutes * 60); + } + } + + return null; +} + +function extractRecipientAddress(value: unknown) { + const raw = Array.isArray(value) ? value.map(stringValue).filter(Boolean).join(", ") : stringValue(value); + + if (!raw) { + return null; + } + + const match = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); + return match?.[0] ?? raw; +} + +function zammadWebhookRecipient(payload: unknown) { + const ticket = zammadWebhookObject(payload, "ticket"); + const article = zammadWebhookObject(payload, "article"); + const customer = isPlainObject(ticket.customer) ? ticket.customer : {}; + + return extractRecipientAddress( + isPlainObject(payload) ? payload.to ?? payload.recipient ?? payload.email : null + ) ?? extractRecipientAddress(article.to) ?? extractRecipientAddress(customer.email); +} + +function parseZammadWebhookAgentId(payload: unknown) { + const ticket = zammadWebhookObject(payload, "ticket"); + const article = zammadWebhookObject(payload, "article"); + const user = zammadWebhookObject(payload, "user"); + const createdBy = isPlainObject(article.created_by) ? article.created_by : {}; + const updatedBy = isPlainObject(article.updated_by) ? article.updated_by : {}; + const candidates = [ + isPlainObject(payload) ? payload.zammadUserId : null, + isPlainObject(payload) ? payload.zammad_user_id : null, + isPlainObject(payload) ? payload.user_id : null, + isPlainObject(payload) ? payload.created_by_id : null, + isPlainObject(payload) ? payload.updatedById : null, + isPlainObject(payload) ? payload.updated_by_id : null, + user.id, + article.created_by_id, + article.updated_by_id, + createdBy.id, + updatedBy.id, + ticket.updated_by_id + ]; + + for (const candidate of candidates) { + const id = numberOrNull(candidate); + + if (id) { + return id; + } + } + + return null; +} + +function parseZammadWebhookArticleId(payload: unknown) { + const article = zammadWebhookObject(payload, "article"); + return numberOrNull(isPlainObject(payload) ? payload.article_id : null) ?? numberOrNull(article.id); +} + +function parseZammadWebhookGroupId(payload: unknown) { + const ticket = zammadWebhookObject(payload, "ticket"); + return numberOrNull(isPlainObject(payload) ? payload.group_id : null) ?? numberOrNull(ticket.group_id); +} + +function zammadTicketImportFromWebhookPayload(payload: unknown, fallbackTicketNumber: string) { + const ticket = zammadWebhookObject(payload, "ticket"); + const group = isPlainObject(ticket.group) ? ticket.group : {}; + const organization = isPlainObject(ticket.organization) ? ticket.organization : {}; + const owner = isPlainObject(ticket.owner) ? ticket.owner : {}; + const priority = isPlainObject(ticket.priority) ? ticket.priority : {}; + const rawTicket = { + ...ticket, + id: ticket.id, + number: ticket.number, + group_id: ticket.group_id ?? group.id, + organization_id: ticket.organization_id ?? organization.id, + owner_id: ticket.owner_id ?? owner.id, + priority_id: ticket.priority_id ?? priority.id, + state_id: ticket.state_id, + title: ticket.title, + preferences: isPlainObject(ticket.preferences) ? ticket.preferences : {} + }; + + return zammadTicketImportFromRaw(rawTicket, fallbackTicketNumber); +} + +function zammadWebhookIsOutgoingEmail(payload: unknown) { + const article = zammadWebhookObject(payload, "article"); + const type = lowerString(isPlainObject(payload) ? payload.article_type ?? payload.type : null) || lowerString(article.type); + const sender = lowerString(isPlainObject(payload) ? payload.sender : null) || lowerString(article.sender); + + if (type && type !== "email") { + return false; + } + + if (sender && sender !== "agent") { + return false; + } + + return true; +} + +function zammadWebhookPayloadKeys(payload: unknown) { + return isPlainObject(payload) ? Object.keys(payload).slice(0, 50) : []; +} + +function zammadWebhookDebugSummary(payload: unknown) { + const article = zammadWebhookObject(payload, "article"); + + return { + payloadKeys: zammadWebhookPayloadKeys(payload), + ticketNumber: parseZammadWebhookTicketNumber(payload), + zammadUserId: parseZammadWebhookAgentId(payload), + articleId: parseZammadWebhookArticleId(payload), + groupId: parseZammadWebhookGroupId(payload), + recipient: zammadWebhookRecipient(payload), + endedAt: parseZammadWebhookDate(payload)?.toISOString() ?? null, + accountedTime: isPlainObject(payload) ? payload.accounted_time ?? article.accounted_time ?? null : article.accounted_time ?? null, + durationSeconds: parseZammadWebhookDurationSeconds(payload), + outgoingEmail: zammadWebhookIsOutgoingEmail(payload) + }; +} + +async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId: string | null) { + if (!zammadWebhookIsOutgoingEmail(payload)) { + return { created: false, skipped: true, reason: "not-outgoing-email" }; + } + + const zammadUserId = parseZammadWebhookAgentId(payload); + + if (!zammadUserId) { + return { created: false, skipped: true, reason: "missing-zammad-user-id" }; + } + + const userResult = await query<{ id: string; enabled: boolean; has_zammad_api_token: boolean }>( + ` + SELECT + id::text AS id, + zammad_email_session_webhook_enabled AS enabled, + (zammad_api_token IS NOT NULL) AS has_zammad_api_token + FROM users + WHERE role = 'user' + AND active + AND zammad_user_id = $1 + ORDER BY zammad_email_session_webhook_enabled DESC, id ASC; + `, + [zammadUserId] + ); + const user = userResult.rows[0] ?? null; + + if (!user) { + return { created: false, skipped: true, reason: "unknown-user" }; + } + + if (!user.enabled) { + return { created: false, skipped: true, reason: "disabled" }; + } + + if (!user.has_zammad_api_token) { + return { created: false, skipped: true, reason: "missing-zammad-token" }; + } + + const ticketNumber = parseZammadWebhookTicketNumber(payload); + const endedAt = parseZammadWebhookDate(payload); + const durationSeconds = parseZammadWebhookDurationSeconds(payload); + const recipient = zammadWebhookRecipient(payload); + + if (!ticketNumber) { + return { created: false, skipped: true, reason: "missing-ticket-number" }; + } + + if (!endedAt) { + return { created: false, skipped: true, reason: "missing-updated-at" }; + } + + if (!durationSeconds) { + return { created: false, skipped: true, reason: "missing-duration" }; + } + + if (!recipient) { + return { created: false, skipped: true, reason: "missing-recipient" }; + } + + const payloadGroupId = parseZammadWebhookGroupId(payload); + if (payloadGroupId && !isSupportedZammadGroupId(payloadGroupId)) { + return { created: false, skipped: true, reason: "unsupported-zammad-type" }; + } + + const existingLocalTicket = await ticketExists(ticketNumber); + const payloadZammadTicket = zammadTicketImportFromWebhookPayload(payload, ticketNumber); + const zammadTicket = existingLocalTicket + ? null + : payloadZammadTicket ?? await fetchZammadTicketByNumber(user.id, ticketNumber); + + if (!existingLocalTicket && !zammadTicket) { + return { created: false, skipped: true, reason: "unsupported-or-missing-zammad-ticket" }; + } + + const startedAt = new Date(endedAt.getTime() - durationSeconds * 1000); + const result = await createSessionForUser({ + userId: user.id, + ticketNumber, + activity: `eMail an ${recipient} gesendet`, + requestedOrganizationId: null, + requestedWorkType: null, + useZammadLookup: true, + requestedProjectTeamspaceEntryId: null, + startedAt, + endedAt, + durationSeconds, + zammadTicket, + sourceEvent: { + zammadArticleId: parseZammadWebhookArticleId(payload), + deliveryId + } + }); + + if ("duplicate" in result) { + return { created: false, skipped: true, reason: "duplicate" }; + } + + return { created: true, ticket: result.ticket, session: result.session }; +} + async function fetchZammadConsultingTicketsForOwner(userId: string): Promise { const userResult = await query<{ zammad_user_id: string | null }>("SELECT zammad_user_id::text AS zammad_user_id FROM users WHERE id = $1;", [userId]); const zammadUserId = userResult.rows[0]?.zammad_user_id; @@ -3025,6 +3657,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { COALESCE(so.name, ot.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name, s.activity, s.work_type, + COALESCE(pte.teamspace_project, p.teamspace_project) AS project_teamspace_project, s.started_at, (s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day, s.rounded_minutes, @@ -3034,6 +3667,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { JOIN tickets t ON t.id = s.ticket_id LEFT JOIN organizations so ON so.zammad_id = s.organization_id LEFT JOIN organizations ot ON ot.zammad_id = t.organization_id + LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id + LEFT JOIN projects p ON p.ticket_id = s.ticket_id AND p.user_id = s.user_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 @@ -3110,6 +3745,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { missingCrmResult, acknowledgedMissingCrmResult, mismatchResult, + teamspaceDayResult, closureResult ] = await Promise.all([ query( @@ -3327,6 +3963,31 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { `, [period.startIso, period.endIso, userId] ), + query( + ` + ${baseCte} + SELECT + tdb.ticket_id::text AS ticket_id, + tdb.ticket_number, + MIN(sb.organization_name) AS organization_name, + tdb.day::text AS day, + tdb.tracked_minutes, + tdb.crm_billed_minutes, + (tdb.crm_billed_minutes - tdb.tracked_minutes)::int AS delta_minutes, + COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS session_count, + NULLIF( + string_agg(DISTINCT NULLIF(sb.project_teamspace_project, ''), ' + ' ORDER BY NULLIF(sb.project_teamspace_project, '')) + FILTER (WHERE sb.billing_status = 'billed'), + '' + ) AS project_teamspace_project + FROM ticket_day_billing tdb + JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day + WHERE tdb.has_crm_value + GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes + ORDER BY tdb.day ASC, tdb.ticket_number ASC; + `, + [period.startIso, period.endIso, userId] + ), query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId]) ]); @@ -3391,7 +4052,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { missingCrmDays: missingCrmResult.rows, acknowledgedMissingCrmDays: acknowledgedMissingCrmResult.rows, crmMismatches: mismatchResult.rows - } + }, + teamspaceDays: teamspaceDayResult.rows }; } @@ -3882,11 +4544,12 @@ app.post("/api/setup/admin", async (req, res) => { active: boolean; zammad_user_id: string | null; has_zammad_api_token: boolean; + zammad_email_session_webhook_enabled: boolean; }>( ` 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; + 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, zammad_email_session_webhook_enabled; `, [username, displayName, await hashPassword(password)] ); @@ -3942,6 +4605,7 @@ app.post("/api/auth/login", async (req, res) => { password_hash: string; zammad_user_id: string | null; has_zammad_api_token: boolean; + zammad_email_session_webhook_enabled: boolean; }>( ` SELECT @@ -3952,7 +4616,8 @@ app.post("/api/auth/login", async (req, res) => { active, password_hash, zammad_user_id::text AS zammad_user_id, - (zammad_api_token IS NOT NULL) AS has_zammad_api_token + (zammad_api_token IS NOT NULL) AS has_zammad_api_token, + zammad_email_session_webhook_enabled FROM users WHERE lower(username) = lower($1); `, @@ -3997,11 +4662,44 @@ app.post("/api/auth/login", async (req, res) => { role: user.role, active: user.active, zammad_user_id: user.zammad_user_id, - has_zammad_api_token: user.has_zammad_api_token + has_zammad_api_token: user.has_zammad_api_token, + zammad_email_session_webhook_enabled: user.zammad_email_session_webhook_enabled } }); }); +app.post("/api/zammad/email-session-webhook", async (req, res) => { + const deliveryId = optionalString(req.header("x-zammad-delivery")); + + logDebug("zammad-email-session-webhook-received", { + deliveryId, + trigger: optionalString(req.header("x-zammad-trigger")), + contentType: optionalString(req.header("content-type")), + authorizationPresent: Boolean(req.header("authorization")), + ...zammadWebhookDebugSummary(req.body) + }); + + if (!verifyZammadEmailWebhookAuth(req)) { + logDebug("zammad-email-session-webhook-auth-failed", { + deliveryId, + configured: Boolean(zammadEmailWebhookToken), + authorizationPresent: Boolean(req.header("authorization")) + }); + res.status(zammadEmailWebhookToken ? 401 : 503).json({ error: zammadEmailWebhookToken ? "Webhook-Token ist ungültig" : "Zammad Webhook-Token ist nicht konfiguriert" }); + return; + } + + const payload = req.body; + const result = await createZammadEmailSessionFromWebhook(payload, deliveryId); + logDebug("zammad-email-session-webhook", { + deliveryId, + created: result.created, + skipped: "skipped" in result ? result.skipped : false, + reason: "reason" in result ? result.reason : null + }); + res.status(200).json(result); +}); + app.use("/api", requireAuth); app.get("/api/auth/me", (req, res) => { @@ -4168,6 +4866,9 @@ app.patch("/api/auth/me", async (req, res) => { const updatesZammadToken = Object.prototype.hasOwnProperty.call(req.body, "zammadApiToken") && user.role === "user"; const zammadApiToken = updatesZammadToken ? optionalString(req.body.zammadApiToken) : null; const zammadUserId = zammadApiToken ? await fetchZammadUserIdForToken(zammadApiToken) : null; + const zammadEmailSessionWebhookEnabled = user.role === "user" + ? optionalBoolean(req.body.zammadEmailSessionWebhookEnabled, user.zammad_email_session_webhook_enabled) + : false; if (newPassword && newPassword.length < 6) { throw badRequest("newPassword must be at least 6 characters"); @@ -4195,9 +4896,10 @@ app.patch("/api/auth/me", async (req, res) => { password_hash = COALESCE($3, password_hash), zammad_api_token = CASE WHEN $5::boolean THEN $6 ELSE zammad_api_token END, zammad_user_id = CASE WHEN $5::boolean THEN $7 ELSE zammad_user_id END, + zammad_email_session_webhook_enabled = CASE WHEN $8::boolean THEN $9 ELSE zammad_email_session_webhook_enabled 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; + 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, zammad_email_session_webhook_enabled; `, [ username, @@ -4206,7 +4908,9 @@ app.patch("/api/auth/me", async (req, res) => { user.id, updatesZammadToken, zammadApiToken ? encryptSecret(zammadApiToken) : null, - zammadUserId + zammadUserId, + user.role === "user", + zammadEmailSessionWebhookEnabled ] ); @@ -4234,7 +4938,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, created_at, updated_at + 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, zammad_email_session_webhook_enabled, created_at, updated_at FROM users ORDER BY username ASC; ` @@ -4258,7 +4962,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, created_at, updated_at; + 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, zammad_email_session_webhook_enabled, created_at, updated_at; `, [username, displayName, await hashPassword(password), "user", active] ); @@ -4319,7 +5023,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, created_at, updated_at; + 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, zammad_email_session_webhook_enabled, created_at, updated_at; `, [username, displayName, active, password ? await hashPassword(password) : null, userId] ); @@ -5293,163 +5997,17 @@ app.post("/api/sessions", requireUser, async (req, res) => { const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds"); - if (endedAt < startedAt) { - throw badRequest("endedAt must be after startedAt"); - } - - if (ticketNumber.startsWith("Fix#") || (await ticketHasRecurringBilling({ query }, ticketNumber, user.id))) { - res.status(409).json({ error: "Für fixe Abrechnungen dürfen keine manuellen Sessions erstellt werden. Bitte die fixe Abrechnung bearbeiten." }); - return; - } - - const monthClosureResult = await query<{ closed_at: string }>( - ` - SELECT closed_at - FROM month_closures - WHERE month = date_trunc('month', $1::timestamptz AT TIME ZONE 'Europe/Berlin')::date - AND user_id = $2; - `, - [startedAt.toISOString(), user.id] - ); - - if (monthClosureResult.rows.length > 0) { - res.status(409).json({ error: "Monat ist abgeschlossen" }); - return; - } - - const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); - const existingLocalTicket = useZammadLookup ? await ticketExists(ticketNumber) : false; - const zammadTicket = useZammadLookup && !existingLocalTicket ? await fetchZammadTicketByNumber(user.id, ticketNumber) : null; - - if (useZammadLookup && !existingLocalTicket && !zammadTicket) { - res.status(404).json({ error: "Ticket wurde in Zammad nicht gefunden" }); - return; - } - - const created = await withTransaction(async (client) => { - if (zammadTicket) { - await importZammadTicketForUser(client, zammadTicket, user.id); - } - - const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null; project_id: string | null }>( - ` - SELECT t.id, t.organization_id, t.customer_name, t.work_type, p.id AS project_id - FROM tickets t - LEFT JOIN projects p ON p.ticket_id = t.id - WHERE t.ticket_number = $1 - ORDER BY (p.user_id = $2::bigint) DESC, p.id ASC - LIMIT 1 - FOR UPDATE OF t; - `, - [ticketNumber, user.id] - ); - const existingTicket = existingTicketResult.rows[0] ?? null; - const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId; - - if (!effectiveOrganizationId) { - throw badRequest("organizationId is required"); - } - - const organization = await getOrganizationByZammadId(client, effectiveOrganizationId); - - if (!organization) { - throw badRequest("organizationId must reference an existing organization"); - } - - const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>( - ` - INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type) - VALUES ($1, $2, $3, $4) - ON CONFLICT (ticket_number) - DO UPDATE SET - organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), - customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), - work_type = CASE - WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' - WHEN EXISTS ( - SELECT 1 FROM projects p WHERE p.ticket_id = tickets.id AND p.user_id = $5 - ) THEN 'consulting' - ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) - END - RETURNING id, ticket_number, organization_id, customer_name, work_type; - `, - [ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, user.id] - ); - - const ticket = ticketResult.rows[0]; - const workType = existingTicket?.project_id ? "consulting" : ticket.work_type ?? requestedWorkType; - - if (!workType) { - throw badRequest("workType must be support or consulting"); - } - - let projectTeamspaceEntryId: number | null = null; - - if (workType === "consulting") { - const projectOwnerUserId = await projectOwnerUserIdForZammadOwner( - client, - zammadTicket?.zammadOwnerId ?? null, - user.id - ); - await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, { - teamspaceProject: zammadTicket?.teamspaceProject ?? null, - teamspaceProjects: zammadTicket?.teamspaceProjects ?? [], - hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null, - closeReviewRequired: zammadTicket?.zammadStateId === 4 - }); - projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( - client, - ticket.id, - user.id, - workType, - requestedProjectTeamspaceEntryId, - zammadTicket?.zammadOwnerId ?? null - ); - } - - const sessionResult = await client.query( - ` - INSERT INTO sessions ( - ticket_id, - organization_id, - customer_name, - activity, - work_type, - user_id, - started_at, - ended_at, - duration_seconds, - rounded_minutes, - project_teamspace_entry_id - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING *; - `, - [ - ticket.id, - organization.id, - organization.name, - activity, - workType, - user.id, - startedAt.toISOString(), - endedAt.toISOString(), - durationSeconds, - roundedMinutes, - projectTeamspaceEntryId - ] - ); - - await reopenPeriodsForSession(client, ticket.id, startedAt, user.id); - if (workType === "consulting") { - await reopenProjectsForTicket(client, ticket.id); - await showProjectsForUserTicket(client, ticket.id, user.id); - } - - return { - ticket, - session: sessionResult.rows[0] - }; + const created = await createSessionForUser({ + userId: user.id, + ticketNumber, + activity, + requestedOrganizationId, + requestedWorkType, + useZammadLookup, + requestedProjectTeamspaceEntryId, + startedAt, + endedAt, + durationSeconds }); res.status(201).json(created); diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 0c0fff5..10afad8 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -86,6 +86,7 @@ export async function migrate() { `); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_api_token TEXT;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_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(` CREATE TABLE IF NOT EXISTS auth_sessions ( @@ -220,6 +221,27 @@ export async function migrate() { await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;"); + await query(` + CREATE TABLE IF NOT EXISTS zammad_email_session_events ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + zammad_article_id BIGINT, + delivery_id TEXT, + session_id BIGINT REFERENCES sessions(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT zammad_email_session_event_source CHECK (zammad_article_id IS NOT NULL OR delivery_id IS NOT NULL) + ); + `); + await query(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_email_session_events_user_article + ON zammad_email_session_events(user_id, zammad_article_id) + WHERE zammad_article_id IS NOT NULL; + `); + await query(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_email_session_events_delivery + ON zammad_email_session_events(delivery_id) + WHERE delivery_id IS NOT NULL; + `); if (adminId) { await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]); } diff --git a/compose.postgres.yml b/compose.postgres.yml index 5276bbd..e7b534f 100644 --- a/compose.postgres.yml +++ b/compose.postgres.yml @@ -27,6 +27,7 @@ services: environment: DATABASE_URL: postgresql://${POSTGRES_USER:-tickettracker}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-tickettracker} SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:-} + ZAMMAD_EMAIL_WEBHOOK_TOKEN: ${ZAMMAD_EMAIL_WEBHOOK_TOKEN:-} PORT: 3000 BACKEND_PORT: 3001 API_PROXY_TARGET: http://127.0.0.1:3001 diff --git a/compose.yml b/compose.yml index 7921b0a..dec524b 100644 --- a/compose.yml +++ b/compose.yml @@ -8,6 +8,7 @@ services: environment: DATABASE_URL: ${DATABASE_URL} SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:-} + ZAMMAD_EMAIL_WEBHOOK_TOKEN: ${ZAMMAD_EMAIL_WEBHOOK_TOKEN:-} PORT: 3000 BACKEND_PORT: 3001 API_PROXY_TARGET: http://127.0.0.1:3001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd76add..42e282b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -528,9 +528,9 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, {timer.ticketNumber} {timer.organizationName ? ( - - {timer.isProject ? "Projekt · " : ""} - {timer.projectName || timer.organizationName} + + {timer.isProject ? : null} + {timer.organizationName} ) : null} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b482cfb..ed6fae5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -197,6 +197,7 @@ export function updateCurrentUser(payload: { currentPassword?: string; newPassword?: string; zammadApiToken?: string; + zammadEmailSessionWebhookEnabled?: boolean; }) { return request<{ user: AuthUser }>("/api/auth/me", { method: "PATCH", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8762c01..b0f4cd8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -12,6 +12,7 @@ export type AuthUser = { active: boolean; zammad_user_id: string | null; has_zammad_api_token: boolean; + zammad_email_session_webhook_enabled: boolean; }; export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes"; @@ -425,6 +426,18 @@ export type StatisticsCrmDay = { acknowledged_at?: string | null; }; +export type StatisticsTeamspaceDay = { + ticket_id: string; + ticket_number: string; + organization_name: string; + day: string; + tracked_minutes: number; + crm_billed_minutes: number; + delta_minutes: number; + session_count: number; + project_teamspace_project: string | null; +}; + export type StatisticsOverview = { periodType: "month"; period: string; @@ -459,4 +472,5 @@ export type StatisticsOverview = { acknowledgedMissingCrmDays: StatisticsCrmDay[]; crmMismatches: StatisticsCrmDay[]; }; + teamspaceDays: StatisticsTeamspaceDay[]; }; diff --git a/frontend/src/views/MonthlyClosePage.tsx b/frontend/src/views/MonthlyClosePage.tsx index 2a2ecd8..54b792f 100644 --- a/frontend/src/views/MonthlyClosePage.tsx +++ b/frontend/src/views/MonthlyClosePage.tsx @@ -22,7 +22,7 @@ import { CopyTicketButton } from "@/components/CopyTicketButton"; import { HelpLink } from "@/components/HelpLink"; import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api"; import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format"; -import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview } from "../types"; +import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types"; type MonthlyClosePageProps = { onNavigate: (to: string) => void; @@ -156,6 +156,36 @@ function CrmDayItem({ ); } +function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay; onNavigate: (to: string) => void }) { + const deltaVariant = item.delta_minutes === 0 ? "outline" : item.delta_minutes > 0 ? "success" : "destructive"; + + return ( +
+
+ {formatDate(`${item.day}T00:00:00`)} + {item.session_count} Session(s) +
+
+
+ {item.ticket_number} + + {item.project_teamspace_project ? {item.project_teamspace_project} : null} +
+

{item.organization_name}

+
+
+ Abgerechnet {formatMinutes(item.tracked_minutes)} + Teamspace {formatMinutes(item.crm_billed_minutes)} + Diff. {formatTeamspaceDelta(item.delta_minutes)} +
+ +
+ ); +} + export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { const [month, setMonth] = useState(currentMonth()); const [stats, setStats] = useState(null); @@ -274,6 +304,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { const openSessions = stats?.attention.openSessions ?? []; const missingCrmDays = stats?.attention.missingCrmDays ?? []; const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? []; + const teamspaceDays = stats?.teamspaceDays ?? []; const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]); const visibleMissingCrmDays = useMemo(() => { const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays]; @@ -379,6 +410,29 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { + + +
+
+ Vergleich abgerechnete Zeiten Teamspace + Teamspace-Tageswerte je Ticket mit Datum der abgerechneten Session(s) und Projekt, sofern verfügbar. +
+ {teamspaceDays.length} Eintrag(e) +
+
+ + {teamspaceDays.length > 0 ? ( +
+ {teamspaceDays.map((item) => ( + + ))} +
+ ) : ( +

Für diesen Monat sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.

+ )} +
+
+
diff --git a/frontend/src/views/ProfilePage.tsx b/frontend/src/views/ProfilePage.tsx index ddcb591..a88c8e2 100644 --- a/frontend/src/views/ProfilePage.tsx +++ b/frontend/src/views/ProfilePage.tsx @@ -1,9 +1,10 @@ import { FormEvent, useEffect, useState } from "react"; -import { KeyRound, Save, Unplug, UserCircle, X } from "lucide-react"; +import { KeyRound, MailPlus, Save, Unplug, UserCircle, X } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { HelpLink } from "@/components/HelpLink"; import { updateCurrentUser } from "../api"; import type { AuthUser } from "../types"; @@ -21,6 +22,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { const [confirmPassword, setConfirmPassword] = useState(""); const [zammadApiToken, setZammadApiToken] = useState(""); const [zammadTokenDirty, setZammadTokenDirty] = useState(false); + const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled); const [saving, setSaving] = useState(false); useEffect(() => { @@ -28,6 +30,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { setDisplayName(currentUser.display_name); setZammadApiToken(""); setZammadTokenDirty(false); + setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled); }, [currentUser]); async function submit(event: FormEvent) { @@ -54,7 +57,8 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { displayName, currentPassword: wantsPasswordChange ? currentPassword : undefined, newPassword: wantsPasswordChange ? newPassword : undefined, - ...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}) + ...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}), + ...(currentUser.role === "user" ? { zammadEmailSessionWebhookEnabled } : {}) }; try { @@ -206,6 +210,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { ) : null}
+
+
+ +
+ +

Ausgehende Zammad-eMails automatisch als Session übernehmen.

+
+
+ +
) : null} diff --git a/frontend/src/views/TimerPage.tsx b/frontend/src/views/TimerPage.tsx index 59be5ac..d08d4bc 100644 --- a/frontend/src/views/TimerPage.tsx +++ b/frontend/src/views/TimerPage.tsx @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useMemo, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; -import { Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react"; +import { FolderGit, Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -610,7 +610,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer {selectedTimer ? selectedTimer.organizationName - ? `${selectedTimer.isProject ? "Projekt · " : ""}${selectedTimer.projectName || selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}` + ? ( + + {selectedTimer.isProject ? : null} + {selectedTimer.organizationName} + {selectedTimer.workType ? · {selectedTimer.workType === "support" ? "Support" : "Consulting"} : null} + + ) : "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert." : "Starte einen Timer, um eine Session zu erfassen."} diff --git a/protokoll.txt b/protokoll.txt new file mode 100644 index 0000000..cc1a91c --- /dev/null +++ b/protokoll.txt @@ -0,0 +1,679 @@ +Richtung out +URL +http://192.168.42.169:3911/api/zammad/email-session-webhook +Methode +POST +Status +0 +Anfrage +accept-encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3 +accept: */* +user-agent: Zammad User Agent +host: 192.168.42.169:3911 +x-zammad-trigger: Tickettracker Test +x-zammad-delivery: ef364583-36b3-40f3-bacb-967a09e7ddae +content-type: application/json; charset=utf-8 +Authorization: Bearer [FILTERED]@ySW3uTFKjGFMrti8BcDkNv8mN2g71...[TRUNCATED] + +{ + "ticket": { + "abgerechnet": "", + "ai_agent_running": false, + "ai_stored_result_ids": [], + "ai_stored_results": [], + "article_count": 13, + "article_ids": [ + 66138, + 66151, + 66152, + 66139, + 66141, + 66145, + 66144, + 66146, + 66130, + 66149, + 66131, + 66150, + 66133 + ], + "atc_version": "-", + "bemerkung": "", + "bestellnummer": "", + "checklist_id": null, + "close_at": null, + "close_diff_in_min": null, + "close_escalation_at": null, + "close_in_min": null, + "create_article_sender": "Customer", + "create_article_sender_id": 2, + "create_article_type": "phone", + "create_article_type_id": 5, + "created_at": "2026-09-22T14:56:23.929Z", + "created_by": { + "active": true, + "address": "", + "city": "", + "country": "", + "created_at": "2025-03-31T08:14:45.514Z", + "created_by": "marco.christener@aikava.de", + "created_by_id": 3, + "department": "Senior Software Consultant", + "email": "michael.boehmlaender@aikava.de", + "fax": "", + "firstname": "Michael", + "id": 1764, + "image": "facb8844175ddeb0a0fab286b9b68fbd", + "image_source": null, + "lastname": "Böhmländer", + "login": "michael.boehmlaender@aikava.de", + "mobile": "", + "mobile_2": "", + "note": "Geplante Abwesenheiten:", + "organization": "AIKAVA GmbH", + "organization_id": 2, + "organization_ids": [], + "organizations": [], + "out_of_office": true, + "out_of_office_end_at": "2026-08-28", + "out_of_office_replacement": "marco.dirscherl@aikava.de", + "out_of_office_replacement_id": 77, + "out_of_office_start_at": "2026-08-17", + "overview_sorting_ids": [ + 301, + 302, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 310, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320 + ], + "phone": "", + "role_ids": [ + 2, + 1 + ], + "roles": [ + "Admin", + "Agent" + ], + "source": null, + "street": "", + "two_factor_preference_ids": [], + "two_factor_preferences": [], + "updated_at": "2026-09-21T05:56:12.741Z", + "updated_by": "michael.boehmlaender@aikava.de", + "updated_by_id": 1764, + "verified": false, + "vip": false, + "web": "", + "zip": "" + }, + "created_by_id": 1764, + "customer": { + "active": true, + "address": "testadresse", + "city": "", + "country": "", + "created_at": "2023-01-10T13:07:40.366Z", + "created_by": "thomas.roider@aikava.de", + "created_by_id": 4, + "department": "", + "email": "test@test.de", + "fax": "", + "firstname": "Testkundenbesitzer", + "id": 6, + "image": null, + "image_source": null, + "lastname": "Testnachname", + "login": "test@test.de", + "mobile": "", + "mobile_2": null, + "note": "testkunde", + "organization": "AIKAVA GmbH", + "organization_id": 2, + "organization_ids": [], + "organizations": [], + "out_of_office": false, + "out_of_office_end_at": null, + "out_of_office_replacement_id": null, + "out_of_office_start_at": null, + "overview_sorting_ids": [], + "overview_sortings": [], + "phone": "+49123456789", + "role_ids": [], + "roles": [], + "source": null, + "street": "", + "two_factor_preference_ids": [], + "two_factor_preferences": [], + "updated_at": "2026-09-22T14:56:23.989Z", + "updated_by": "thomas.roider@aikava.de", + "updated_by_id": 4, + "verified": false, + "vip": false, + "web": "", + "zip": "" + }, + "customer_id": 6, + "daily_event_lock_ids": [], + "daily_event_locks": [], + "escalation_at": null, + "first_response_at": "2026-09-22T15:00:35.799Z", + "first_response_diff_in_min": null, + "first_response_escalation_at": null, + "first_response_in_min": null, + "gesamtbudget": "", + "group": { + "active": true, + "assignment_timeout": null, + "created_at": "2023-01-10T12:33:22.977Z", + "created_by": "thomas.roider@aikava.de", + "created_by_id": 4, + "email_address": "Zammad Helpdesk Support", + "email_address_id": 6, + "follow_up_assignment": true, + "follow_up_possible": "yes", + "id": 3, + "name": "Support", + "name_last": "Support", + "note": "Support AIKAVA", + "parent_id": null, + "reopen_time_in_days": null, + "shared_drafts": true, + "signature": "AIKAVA - Abteilung als Berufsbezeichnung (Gruppe Support)", + "signature_id": 2, + "summary_generation": "global_default", + "updated_at": "2026-07-30T09:34:01.249Z", + "updated_by": "thomas.roider@aikava.de", + "updated_by_id": 4, + "user_ids": [ + 6, + 2308, + 2309, + 251, + 77, + 4, + 3 + ], + "users": [ + "test@test.de", + "apiuser@aikava.de", + "hung.nguyen@aikava.de", + "corinna.breu@aikava.de", + "marco.dirscherl@aikava.de", + "thomas.roider@aikava.de", + "marco.christener@aikava.de" + ] + }, + "group_id": 3, + "id": 7035, + "last_close_at": null, + "last_contact_agent_at": "2026-09-23T06:47:38.553Z", + "last_contact_at": "2026-09-23T06:47:38.553Z", + "last_contact_customer_at": "2026-09-22T14:56:24.008Z", + "last_owner_update_at": "2026-09-23T06:47:38.566Z", + "netzwerkpfad": "", + "note": null, + "number": "947021", + "organization": { + "active": true, + "country": "", + "created_at": "2023-01-06T11:06:22.976Z", + "created_by": "-", + "created_by_id": 1, + "customer_owner": null, + "dauerticket_bestellnummer": "", + "dauerticket_url": "", + "domain": "aikava.de", + "domain_assignment": false, + "hardware": null, + "hosting_adress": null, + "hostingkunde": false, + "hotline_contract": null, + "id": 2, + "member_ids": [ + 297, + 295, + 251, + 301, + 6, + 2309, + 982, + 479, + 78, + 1764, + 77, + 3, + 4 + ], + "members": [ + "auto-feb98421-3344-4f54-a650-dc54b010ba90", + "auto-b4d24e63-db3e-44c2-9fb9-b8f3b6a1a675", + "corinna.breu@aikava.de", + "auto-27ce025f-f458-4a90-b320-df18812c150b", + "test@test.de", + "hung.nguyen@aikava.de", + "marcel.reisch@aikava.de", + "cristian.dimcia@aikava.de", + "heidi.schmaderer@aikava.de", + "michael.boehmlaender@aikava.de", + "marco.dirscherl@aikava.de", + "marco.christener@aikava.de", + "thomas.roider@aikava.de" + ], + "name": "AIKAVA GmbH", + "note": "", + "ort": "Cham", + "plz": "93413", + "rechnungsadresse": "", + "secondary_member_ids": [], + "shared": true, + "stock_customer": false, + "strasse": "Galgenfeld 7", + "support_project": null, + "updated_at": "2026-09-22T14:56:23.997Z", + "updated_by": "corinna.breu@aikava.de", + "updated_by_id": 251, + "vertrieb_atoss": [], + "vip": false + }, + "organization_id": 2, + "owner": { + "active": true, + "address": "", + "city": "", + "country": "", + "created_at": "2025-03-31T08:14:45.514Z", + "created_by": "marco.christener@aikava.de", + "created_by_id": 3, + "department": "Senior Software Consultant", + "email": "michael.boehmlaender@aikava.de", + "fax": "", + "firstname": "Michael", + "id": 1764, + "image": "facb8844175ddeb0a0fab286b9b68fbd", + "image_source": null, + "lastname": "Böhmländer", + "login": "michael.boehmlaender@aikava.de", + "mobile": "", + "mobile_2": "", + "note": "Geplante Abwesenheiten:", + "organization": "AIKAVA GmbH", + "organization_id": 2, + "organization_ids": [], + "organizations": [], + "out_of_office": true, + "out_of_office_end_at": "2026-08-28", + "out_of_office_replacement": "marco.dirscherl@aikava.de", + "out_of_office_replacement_id": 77, + "out_of_office_start_at": "2026-08-17", + "overview_sorting_ids": [ + 301, + 302, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 310, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320 + ], + "phone": "", + "role_ids": [ + 2, + 1 + ], + "roles": [ + "Admin", + "Agent" + ], + "source": null, + "street": "", + "two_factor_preference_ids": [], + "two_factor_preferences": [], + "updated_at": "2026-09-21T05:56:12.741Z", + "updated_by": "michael.boehmlaender@aikava.de", + "updated_by_id": 1764, + "verified": false, + "vip": false, + "web": "", + "zip": "" + }, + "owner_id": 1764, + "pending_time": null, + "preferences": {}, + "priority": { + "active": true, + "created_at": "2023-01-06T10:59:49.163Z", + "created_by": "-", + "created_by_id": 1, + "default_create": true, + "id": 2, + "name": "2 normal", + "note": null, + "ui_color": null, + "ui_icon": null, + "updated_at": "2023-01-06T10:59:49.163Z", + "updated_by": "-", + "updated_by_id": 1 + }, + "priority_id": 2, + "project": "", + "referencing_checklist_ids": [], + "referencing_checklists": [], + "state": "open", + "state_id": 2, + "ticket_time_accounting_ids": [ + 16133, + 16135, + 16137, + 16138, + 16139, + 16140, + 16141, + 16142, + 16144, + 16145, + 16146, + 16147 + ], + "time_unit": "2.75", + "title": "Bla", + "type": null, + "update_diff_in_min": null, + "update_escalation_at": null, + "update_in_min": null, + "updated_at": "2026-09-23T06:47:42.937Z", + "updated_by": { + "active": true, + "address": "", + "city": "", + "country": "", + "created_at": "2025-03-31T08:14:45.514Z", + "created_by": "marco.christener@aikava.de", + "created_by_id": 3, + "department": "Senior Software Consultant", + "email": "michael.boehmlaender@aikava.de", + "fax": "", + "firstname": "Michael", + "id": 1764, + "image": "facb8844175ddeb0a0fab286b9b68fbd", + "image_source": null, + "lastname": "Böhmländer", + "login": "michael.boehmlaender@aikava.de", + "mobile": "", + "mobile_2": "", + "note": "Geplante Abwesenheiten:", + "organization": "AIKAVA GmbH", + "organization_id": 2, + "organization_ids": [], + "organizations": [], + "out_of_office": true, + "out_of_office_end_at": "2026-08-28", + "out_of_office_replacement": "marco.dirscherl@aikava.de", + "out_of_office_replacement_id": 77, + "out_of_office_start_at": "2026-08-17", + "overview_sorting_ids": [ + 301, + 302, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 310, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320 + ], + "phone": "", + "role_ids": [ + 2, + 1 + ], + "roles": [ + "Admin", + "Agent" + ], + "source": null, + "street": "", + "two_factor_preference_ids": [], + "two_factor_preferences": [], + "updated_at": "2026-09-21T05:56:12.741Z", + "updated_by": "michael.boehmlaender@aikava.de", + "updated_by_id": 1764, + "verified": false, + "vip": false, + "web": "", + "zip": "" + }, + "updated_by_id": 1764 + }, + "article": { + "ai_stored_result_ids": [], + "ai_stored_results": [], + "attachments": [], + "body": "test

+
Michael Böhmländer
+
Senior Software Consultant
Geplante Abwesenheiten:
+

AIKAVA GmbH
Galgenfeld 7 | 93413 Cham

T: +49 9971 88999 40

Amtsgericht Regensburg HRB 19554

Geschäftsführung Marco Christener

This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.
", + "body_rendering_error": false, + "cc": "", + "content_type": "text/html", + "created_at": "2026-09-23T06:47:38.553Z", + "created_by": { + "active": true, + "address": "", + "city": "", + "country": "", + "created_at": "2025-03-31T08:14:45.514Z", + "created_by": "marco.christener@aikava.de", + "created_by_id": 3, + "department": "Senior Software Consultant", + "email": "michael.boehmlaender@aikava.de", + "fax": "", + "firstname": "Michael", + "id": 1764, + "image": "facb8844175ddeb0a0fab286b9b68fbd", + "image_source": null, + "lastname": "Böhmländer", + "login": "michael.boehmlaender@aikava.de", + "mobile": "", + "mobile_2": "", + "note": "Geplante Abwesenheiten:", + "organization": "AIKAVA GmbH", + "organization_id": 2, + "organization_ids": [], + "organizations": [], + "out_of_office": true, + "out_of_office_end_at": "2026-08-28", + "out_of_office_replacement": "marco.dirscherl@aikava.de", + "out_of_office_replacement_id": 77, + "out_of_office_start_at": "2026-08-17", + "overview_sorting_ids": [ + 301, + 302, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 310, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320 + ], + "phone": "", + "role_ids": [ + 2, + 1 + ], + "roles": [ + "Admin", + "Agent" + ], + "source": null, + "street": "", + "two_factor_preference_ids": [], + "two_factor_preferences": [], + "updated_at": "2026-09-21T05:56:12.741Z", + "updated_by": "michael.boehmlaender@aikava.de", + "updated_by_id": 1764, + "verified": false, + "vip": false, + "web": "", + "zip": "" + }, + "created_by_id": 1764, + "detected_language": null, + "from": "\"Michael Böhmländer via Zammad Helpdesk Support\" ", + "id": 66152, + "in_reply_to": "", + "internal": false, + "message_id": "<20260923064738.7035.8a97ff10-62fc-4d7a-9d91-2d968ea53002@aikava.zammad.com>", + "message_id_md5": "4d6a39f57fc20c1834cc2e7b8c6e94cd", + "origin_by_id": null, + "preferences": { + "email_address_id": 6, + "delivery_retry": 1, + "delivery_channel_id": 23, + "delivery_status_message": null, + "delivery_status": "success", + "delivery_status_date": "2026-09-23T06:47:42.903Z" + }, + "reply_to": null, + "sender": "Agent", + "sender_id": 1, + "subject": "test", + "ticket_id": 7035, + "time_unit": "0.0", + "to": "michael.boehmlaender@aikava.de", + "type": "email", + "type_id": 1, + "updated_at": "2026-09-23T06:47:42.905Z", + "updated_by": { + "active": true, + "address": "", + "city": "", + "country": "", + "created_at": "2025-03-31T08:14:45.514Z", + "created_by": "marco.christener@aikava.de", + "created_by_id": 3, + "department": "Senior Software Consultant", + "email": "michael.boehmlaender@aikava.de", + "fax": "", + "firstname": "Michael", + "id": 1764, + "image": "facb8844175ddeb0a0fab286b9b68fbd", + "image_source": null, + "lastname": "Böhmländer", + "login": "michael.boehmlaender@aikava.de", + "mobile": "", + "mobile_2": "", + "note": "Geplante Abwesenheiten:", + "organization": "AIKAVA GmbH", + "organization_id": 2, + "organization_ids": [], + "organizations": [], + "out_of_office": true, + "out_of_office_end_at": "2026-08-28", + "out_of_office_replacement": "marco.dirscherl@aikava.de", + "out_of_office_replacement_id": 77, + "out_of_office_start_at": "2026-08-17", + "overview_sorting_ids": [ + 301, + 302, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 310, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320 + ], + "phone": "", + "role_ids": [ + 2, + 1 + ], + "roles": [ + "Admin", + "Agent" + ], + "source": null, + "street": "", + "two_factor_preference_ids": [], + "two_factor_preferences": [], + "updated_at": "2026-09-21T05:56:12.741Z", + "updated_by": "michael.boehmlaender@aikava.de", + "updated_by_id": 1764, + "verified": false, + "vip": false, + "web": "", + "zip": "" + }, + "updated_by_id": 1764, + "accounted_time": 0 + } +} +Antwort + +Erstellt am 2026-09-23 08:48:13 \ No newline at end of file