From 07a48e856b1fef0e4766eec0216c3488b3f75aa3 Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 02:53:48 +0200
Subject: [PATCH 01/11] feat: add Zammad ticket integration
---
.gitignore | 1 +
docs/zammad-integration.md | 94 +
frontend/src/lib/api/index.js | 6 +
frontend/src/lib/api/integrations.js | 44 +
frontend/src/lib/api/integrations.spec.js | 114 ++
.../features/items/ItemDetailSidebar.svelte | 5 +
.../lib/features/items/ZammadItemPanel.svelte | 543 ++++++
frontend/src/lib/locales/ar/index.js | 2 +
frontend/src/lib/locales/ar/zammad.js | 23 +
frontend/src/lib/locales/de/index.js | 2 +
frontend/src/lib/locales/de/zammad.js | 101 +
frontend/src/lib/locales/en/index.js | 2 +
frontend/src/lib/locales/en/zammad.js | 101 +
frontend/src/lib/locales/es/index.js | 2 +
frontend/src/lib/locales/es/zammad.js | 29 +
frontend/src/lib/locales/pt-BR/index.js | 2 +
frontend/src/lib/locales/pt-BR/zammad.js | 29 +
frontend/src/lib/locales/zh-CN/index.js | 2 +
frontend/src/lib/locales/zh-CN/zammad.js | 23 +
.../lib/settings/IntegrationsManager.svelte | 13 +-
.../settings/ZammadConnectionManager.svelte | 479 +++++
internal/database/migrations.go | 224 +++
internal/database/schema/integrations.sql | 107 +
.../database/schema/integrations_postgres.sql | 91 +
internal/database/zammad_schema_test.go | 241 +++
internal/handlers/integration_item_links.go | 13 +-
internal/handlers/integration_oauth.go | 3 +-
internal/handlers/zammad.go | 418 ++++
internal/handlers/zammad_test.go | 219 +++
internal/integrations/zammad/client.go | 357 ++++
internal/integrations/zammad/client_test.go | 178 ++
internal/integrations/zammad/oauth.go | 62 +
internal/logger/audit.go | 12 +
internal/models/zammad.go | 209 ++
.../action_credential_repository.go | 35 +
.../integration_provider_repository.go | 9 +-
.../repository/zammad_oauth_repository.go | 260 +++
internal/repository/zammad_repository.go | 650 +++++++
internal/routes/integrations.go | 21 +
internal/scheduler/zammad_sync_scheduler.go | 65 +
internal/server/server.go | 34 +-
.../services/action_credential_service.go | 249 ++-
.../action_credential_service_test.go | 44 +
internal/services/action_service.go | 39 +-
internal/services/zammad_service.go | 1633 ++++++++++++++++
internal/services/zammad_service_test.go | 1719 +++++++++++++++++
internal/services/zammad_transport.go | 69 +
47 files changed, 8548 insertions(+), 30 deletions(-)
create mode 100644 docs/zammad-integration.md
create mode 100644 frontend/src/lib/api/integrations.spec.js
create mode 100644 frontend/src/lib/features/items/ZammadItemPanel.svelte
create mode 100644 frontend/src/lib/locales/ar/zammad.js
create mode 100644 frontend/src/lib/locales/de/zammad.js
create mode 100644 frontend/src/lib/locales/en/zammad.js
create mode 100644 frontend/src/lib/locales/es/zammad.js
create mode 100644 frontend/src/lib/locales/pt-BR/zammad.js
create mode 100644 frontend/src/lib/locales/zh-CN/zammad.js
create mode 100644 frontend/src/lib/settings/ZammadConnectionManager.svelte
create mode 100644 internal/database/zammad_schema_test.go
create mode 100644 internal/handlers/zammad.go
create mode 100644 internal/handlers/zammad_test.go
create mode 100644 internal/integrations/zammad/client.go
create mode 100644 internal/integrations/zammad/client_test.go
create mode 100644 internal/integrations/zammad/oauth.go
create mode 100644 internal/models/zammad.go
create mode 100644 internal/repository/zammad_oauth_repository.go
create mode 100644 internal/repository/zammad_repository.go
create mode 100644 internal/scheduler/zammad_sync_scheduler.go
create mode 100644 internal/services/action_credential_service_test.go
create mode 100644 internal/services/zammad_service.go
create mode 100644 internal/services/zammad_service_test.go
create mode 100644 internal/services/zammad_transport.go
diff --git a/.gitignore b/.gitignore
index b3d0ad77e..1a101667a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@ cx
# Public regression coverage for the locale runtime ships with the implementation.
!frontend/src/lib/stores/i18n-utils.spec.js
+!frontend/src/lib/api/integrations.spec.js
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
diff --git a/docs/zammad-integration.md b/docs/zammad-integration.md
new file mode 100644
index 000000000..707aaa752
--- /dev/null
+++ b/docs/zammad-integration.md
@@ -0,0 +1,94 @@
+# Zammad integration
+
+Windshift can create a Zammad ticket from an item and keep the remote ticket state visible on the item.
+An administrator owns each connection and explicitly grants it to selected workspaces or to all workspaces.
+
+## API and authentication
+
+The routes in this integration use the application API prefix `/api` and the same authenticated session or bearer-token authentication as the rest of the application.
+Administration routes require a system administrator.
+Workspace and item routes require the caller to have view or edit permission for the referenced workspace or item.
+
+Admin connection endpoints are `GET|POST /api/admin/zammad-connections`, `GET|PUT|DELETE /api/admin/zammad-connections/{id}`, `POST /api/admin/zammad-connections/{id}/test`, and `POST /api/admin/zammad-connections/{id}/oauth/start`.
+They list, create, inspect, update, delete, test, and authorize connections without returning any token or client secret.
+The OAuth callback is `GET /api/integrations/zammad/oauth/callback`.
+The explicit administrator retry is `POST /api/admin/zammad-ticket-links/{linkId}/retry-create`.
+
+Workspace and item routes are `GET /api/workspaces/{workspaceId}/zammad-connections`, `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/metadata`, and `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/owners?group_id={groupId}`.
+Ticket routes are `GET /api/items/{id}/zammad-links`, `POST /api/items/{id}/zammad-tickets`, `POST /api/items/{id}/zammad-ticket-links`, `PUT /api/zammad-ticket-links/{linkId}`, `DELETE /api/zammad-ticket-links/{linkId}`, and `POST /api/zammad-ticket-links/{linkId}/refresh`.
+
+Create-ticket requests contain `connection_id` and may contain `group_id`.
+Link-existing requests contain `connection_id` and `ticket_number`.
+Link updates accept optional `state_id`, `group_id`, and `owner_id`, with at least one required.
+The create and link responses contain the link ID, ticket identity and URL when known, group and owner snapshot, synchronization state, last remote status, timestamps, and a sanitized error when applicable.
+
+## OAuth callback setup
+
+Register this exact public callback URL in the Zammad OAuth application, replacing only the hostname with the public HTTPS hostname of the Windshift installation:
+
+`https:///api/integrations/zammad/oauth/callback`
+
+Do not append a slash, query string, or fragment.
+The URL configured in Zammad must exactly match the URL generated by Windshift, including any configured public path prefix.
+Create the connection with `auth_method: "oauth"`, the OAuth client ID, client secret, and a Zammad base URL without `/api/v1`.
+Start OAuth from the admin endpoint and complete authorization in Zammad.
+The callback intentionally does not require a session because its one-time, short-lived state is bound to the initiating administrator and atomically consumed.
+Access and refresh tokens are stored encrypted and never returned by the API.
+
+If the callback fails, the administration UI receives an error result and the connection remains unavailable until authorization succeeds.
+Changing OAuth client configuration invalidates an in-flight attempt.
+
+## Zammad service account and permissions
+
+Use a dedicated active Zammad user with the `ticket.agent` role for the integration, rather than a human administrator account.
+Give it `read`, `create`, and `change` group access only for the allowed group(s).
+This lets Windshift read the required ticket metadata and owners, create tickets, and change state, group, and owner within that boundary.
+Do not grant administration, user-management, group-management, delete-ticket, or unrestricted access to other groups.
+Zammad role and group permissions must enforce the same boundary as `allowed_group_ids` in the Windshift connection.
+The default group must be active and included in that allowed set.
+
+Create an active text-compatible ticket object attribute named `windshift_item_key`, or configure another valid correlation field name.
+Windshift writes a deterministic correlation key to this field and uses it to find an already-created ticket safely.
+The field is the idempotency boundary for create retries and must be readable by the service agent.
+
+## Create, link, update, and unlink behavior
+
+Creating a ticket first persists a pending link and searches Zammad by the correlation key.
+If the ticket already exists, Windshift links it instead of creating a duplicate.
+The item title and description are sent to Zammad, with the initial article created as an internal note.
+Ticket titles are limited to 200 characters and descriptions to 20,000 characters before transmission.
+After a successful create, the link becomes `linked` and the remote ticket snapshot is stored.
+
+If a create request times out, returns a rate-limit or server error, or its response is otherwise ambiguous, the link becomes `creation_uncertain`.
+Automatic retries are search-only and never issue another create request while the outcome is unknown.
+An administrator must verify in Zammad whether the ticket exists and call the admin retry endpoint explicitly only if it does not.
+That retry searches by correlation key before permitting a new create.
+
+Linking an existing ticket verifies that it exists, belongs to an allowed active group, and is not already associated with another correlation key.
+Updating a link sends only the requested state, group, and owner changes to Zammad.
+The selected group and state must be active and allowed, and the selected owner must be able to change tickets in the resulting group.
+Changing the group without selecting an owner resets the ticket to Zammad's unassigned owner.
+Unlinking clears the remote correlation field only when it still equals this Windshift link's exact key, removes the local association and visible item link, and never deletes the remote ticket.
+If the remote result is ambiguous, the local link remains so the operation can be retried safely.
+Deleting a connection is rejected while ticket links still exist, so each link must be safely unlinked before its correlation context can be removed.
+
+## Refresh, backoff, and recovery
+
+`POST /api/zammad-ticket-links/{linkId}/refresh` performs an immediate remote read and updates the stored snapshot.
+A background scheduler also polls linked tickets approximately every two minutes.
+Failed synchronization records a sanitized error and schedules another attempt after a retry delay.
+Fair ordering by the most recent attempt prevents repeatedly failing links from starving healthy links.
+Remote response bodies and credentials are never exposed to clients.
+If a ticket is moved outside the connection's allowed groups in Zammad, synchronization records an error and does not update the local snapshot or apply a completion transition.
+
+An OAuth `invalid_grant` response while refreshing an expired access token marks the connection as requiring reauthorization.
+The connection is not ready while `reauthorization_required` is true.
+Start OAuth again from the admin endpoint after correcting the Zammad OAuth application or grant.
+For API-token connections, rotate the token through the admin update endpoint and rerun the connection test.
+
+Disabling a connection stops scheduled synchronization.
+A missing or unauthorized remote ticket does not mutate the Windshift item.
+Optional closed-state mapping can transition the Windshift item through its normal workflow, subject to the original administrator's item-edit permission.
+
+The outbound client requires HTTPS, rejects credentials/query/fragment components in the base URL, enforces the shared public-address SSRF policy, checks redirects, limits responses, and applies a 30-second timeout.
+The integration does not synchronize attachments, comments, or arbitrary ticket fields.
diff --git a/frontend/src/lib/api/index.js b/frontend/src/lib/api/index.js
index 9ce5f9e7f..854f89b68 100644
--- a/frontend/src/lib/api/index.js
+++ b/frontend/src/lib/api/index.js
@@ -57,6 +57,8 @@ import {
itemIntegrationLinks,
todoistSync,
userIntegrations,
+ zammadConnections,
+ zammadTickets,
} from './integrations.js';
// Domain imports
import { items } from './items.js';
@@ -409,6 +411,10 @@ export const api = {
// Item Integration Links
itemIntegrationLinks,
+ // Zammad ticket integration
+ zammadConnections,
+ zammadTickets,
+
// OAuth Clients (admin only) — third-party apps registered against the
// generic OAuth 2.0 server (/api/oauth/authorize + /api/oauth/token)
oauthClients,
diff --git a/frontend/src/lib/api/integrations.js b/frontend/src/lib/api/integrations.js
index 9dd1192a9..5c2007f66 100644
--- a/frontend/src/lib/api/integrations.js
+++ b/frontend/src/lib/api/integrations.js
@@ -49,3 +49,47 @@ export const itemIntegrationLinks = {
`/items/${itemId}/integration-search?q=${encodeURIComponent(query)}&provider_id=${providerId}`
),
};
+
+// Zammad system connections and item ticket links
+export const zammadConnections = {
+ getAll: () => fetchAPI('/admin/zammad-connections'),
+ get: (id) => fetchAPI(`/admin/zammad-connections/${id}`),
+ create: (data) =>
+ fetchAPI('/admin/zammad-connections', { method: 'POST', body: JSON.stringify(data) }),
+ update: (id, data) =>
+ fetchAPI(`/admin/zammad-connections/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+ delete: (id) => fetchAPI(`/admin/zammad-connections/${id}`, { method: 'DELETE' }),
+ test: (id) => fetchAPI(`/admin/zammad-connections/${id}/test`, { method: 'POST' }),
+ startOAuth: (id) => fetchAPI(`/admin/zammad-connections/${id}/oauth/start`, { method: 'POST' }),
+ forWorkspace: (workspaceId) => fetchAPI(`/workspaces/${workspaceId}/zammad-connections`),
+ metadata: (workspaceId, id) =>
+ fetchAPI(`/workspaces/${workspaceId}/zammad-connections/${id}/metadata`),
+ owners: (workspaceId, id, groupId) =>
+ fetchAPI(
+ `/workspaces/${workspaceId}/zammad-connections/${id}/owners?group_id=${encodeURIComponent(groupId)}`
+ ),
+};
+
+export const zammadTickets = {
+ forItem: (itemId) => fetchAPI(`/items/${itemId}/zammad-links`),
+ create: (itemId, data) =>
+ fetchAPI(`/items/${itemId}/zammad-tickets`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ link: (itemId, data) =>
+ fetchAPI(`/items/${itemId}/zammad-ticket-links`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ update: (linkId, data) =>
+ fetchAPI(`/zammad-ticket-links/${linkId}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+ delete: (linkId) => fetchAPI(`/zammad-ticket-links/${linkId}`, { method: 'DELETE' }),
+ refresh: (linkId) => fetchAPI(`/zammad-ticket-links/${linkId}/refresh`, { method: 'POST' }),
+};
diff --git a/frontend/src/lib/api/integrations.spec.js b/frontend/src/lib/api/integrations.spec.js
new file mode 100644
index 000000000..e6d70ab18
--- /dev/null
+++ b/frontend/src/lib/api/integrations.spec.js
@@ -0,0 +1,114 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { zammadConnections, zammadTickets } from './integrations.js';
+
+describe('zammadConnections.startOAuth', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('starts the system connection OAuth flow with an empty POST body', async () => {
+ const authURL = 'https://zammad.example.test/oauth/authorize?state=state-value';
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ auth_url: authURL }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(zammadConnections.startOAuth('connection-id')).resolves.toEqual({
+ auth_url: authURL,
+ });
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, options] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/admin/zammad-connections/connection-id/oauth/start');
+ expect(options.method).toBe('POST');
+ expect(options.body).toBeUndefined();
+ });
+});
+
+describe('Zammad ticket-link API', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('loads assignable owners for the selected group', async () => {
+ const owners = [
+ { id: 1, name: 'Not assigned' },
+ { id: 7, name: 'Ada Lovelace' },
+ ];
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(owners), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(zammadConnections.owners(23, 'connection-id', 42)).resolves.toEqual(owners);
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ expect(fetchMock.mock.calls[0][0]).toBe(
+ '/api/workspaces/23/zammad-connections/connection-id/owners?group_id=42'
+ );
+ });
+
+ it('links an existing ticket by number', async () => {
+ const response = { id: 'link-id', ticket_number: '12345', sync_state: 'linked' };
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(response), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(
+ zammadTickets.link(99, { connection_id: 'connection-id', ticket_number: '12345' })
+ ).resolves.toEqual(response);
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, options] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/items/99/zammad-ticket-links');
+ expect(options).toMatchObject({
+ method: 'POST',
+ body: JSON.stringify({ connection_id: 'connection-id', ticket_number: '12345' }),
+ });
+ });
+
+ it('updates only the selected ticket fields', async () => {
+ const response = { id: 'link-id', group_id: 42, owner_id: 7 };
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(response), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(zammadTickets.update('link-id', { group_id: 42, owner_id: 7 })).resolves.toEqual(
+ response
+ );
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, options] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/zammad-ticket-links/link-id');
+ expect(options).toMatchObject({
+ method: 'PUT',
+ body: JSON.stringify({ group_id: 42, owner_id: 7 }),
+ });
+ });
+
+ it('removes only the Zammad ticket assignment', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(zammadTickets.delete('link-id')).resolves.toBeNull();
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, options] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/zammad-ticket-links/link-id');
+ expect(options.method).toBe('DELETE');
+ });
+});
diff --git a/frontend/src/lib/features/items/ItemDetailSidebar.svelte b/frontend/src/lib/features/items/ItemDetailSidebar.svelte
index 58c3d6031..8afa9076a 100644
--- a/frontend/src/lib/features/items/ItemDetailSidebar.svelte
+++ b/frontend/src/lib/features/items/ItemDetailSidebar.svelte
@@ -19,6 +19,7 @@
import { errorToast } from '../../stores/toasts.svelte.js';
import ItemSCMLinks from './ItemSCMLinks.svelte';
import ItemIntegrationLinks from './ItemIntegrationLinks.svelte';
+ import ZammadItemPanel from './ZammadItemPanel.svelte';
import AddSCMLinkModal from '../../dialogs/AddSCMLinkModal.svelte';
import AddIntegrationLinkModal from '../../dialogs/AddIntegrationLinkModal.svelte';
import CreateBranchModal from '../../dialogs/CreateBranchModal.svelte';
@@ -1501,6 +1502,10 @@
{/if}
+ {#if item?.id && item?.workspace_id}
+
+ {/if}
+
{#if item?.id}
+ import { onMount } from 'svelte';
+ import {
+ TicketCheck,
+ Plus,
+ RefreshCw,
+ Loader2,
+ ExternalLink,
+ AlertTriangle,
+ Edit2,
+ Trash2,
+ } from '@lucide/svelte';
+ import { api } from '../../api.js';
+ import Button from '../../components/Button.svelte';
+ import Text from '../../components/Text.svelte';
+ import Modal from '../../dialogs/Modal.svelte';
+ import ModalHeader from '../../dialogs/ModalHeader.svelte';
+ import NativeSelect from '../../components/NativeSelect.svelte';
+ import FormField from '../../components/FormField.svelte';
+ import Input from '../../components/Input.svelte';
+ import { t } from '../../stores/i18n.svelte.js';
+ import { successToast, errorToast } from '../../stores/toasts.svelte.js';
+ import { confirm } from '../../composables/useConfirm.js';
+ import { safeHref } from '../../utils/sanitize';
+
+ let { itemId, workspaceId, canEdit = false } = $props();
+
+ let connections = $state([]);
+ let links = $state([]);
+ let metadata = $state({ groups: [], states: [] });
+ let editMetadata = $state({ groups: [], states: [] });
+ let editOwners = $state([]);
+ let loading = $state(true);
+ let loadingMetadata = $state(false);
+ let loadingEditMetadata = $state(false);
+ let loadingEditOwners = $state(false);
+ let creating = $state(false);
+ let linking = $state(false);
+ let savingEdit = $state(false);
+ let refreshingId = $state(null);
+ let removingId = $state(null);
+ let showCreate = $state(false);
+ let showEdit = $state(false);
+ let dialogMode = $state('create');
+ let selectedConnectionId = $state('');
+ let selectedGroupId = $state('');
+ let ticketNumber = $state('');
+ let editingLink = $state(null);
+ let selectedEditStateId = $state('');
+ let selectedEditGroupId = $state('');
+ let selectedEditOwnerId = $state('1');
+ let initialEdit = $state({ stateId: '', groupId: '', ownerId: '1' });
+ let error = $state('');
+ let formError = $state('');
+ let editError = $state('');
+
+ let usableConnections = $derived(connections.filter(isConnectionUsable));
+ let unavailableConnections = $derived(connections.filter((connection) => !isConnectionUsable(connection)));
+ let editGroups = $derived(editMetadata.groups.filter((group) => group.active !== false));
+ let editStates = $derived(editMetadata.states.filter((state) => state.active !== false));
+ let editOwnerOptions = $derived([
+ { value: '1', label: t('zammad.unassignedOwner') },
+ ...editOwners
+ .filter((owner) => Number(owner.id) !== 1)
+ .map((owner) => ({ value: String(owner.id), label: owner.name })),
+ ]);
+ let editPayloadAvailable = $derived(Object.keys(editPayload()).length > 0);
+
+ onMount(load);
+
+ function isConnectionUsable(connection) {
+ if (connection.ready === false || connection.reauthorization_required === true) return false;
+ return connection.auth_method !== 'oauth' || connection.oauth_connected === true;
+ }
+
+ function usableGroups(connection, loadedMetadata) {
+ const allowedIds = connection?.allowed_group_ids || [];
+ return loadedMetadata.groups.filter((group) => {
+ if (group.active === false) return false;
+ if (allowedIds.length > 0) return allowedIds.includes(group.id);
+ return group.id === connection?.default_group_id || group.name === connection?.default_group_name;
+ });
+ }
+
+ function selectedConnection() {
+ return usableConnections.find((connection) => connection.id === selectedConnectionId);
+ }
+
+ function replaceLink(updated) {
+ links = [updated, ...links.filter((entry) => entry.id !== updated.id)];
+ }
+
+ function selectedEditConnection() {
+ return usableConnections.find((connection) => connection.id === editingLink?.connection_id);
+ }
+
+ function syncActionHint(link) {
+ if (link.ticket_id) return '';
+ if (link.sync_state === 'sync_failed') return t('zammad.syncFailedNoTicket');
+ if (link.sync_state === 'creation_uncertain') return t('zammad.creationUncertainNoTicket');
+ return t('zammad.ticketCreationInProgress');
+ }
+
+ async function load() {
+ if (!itemId || !workspaceId) return;
+ loading = true;
+ error = '';
+ try {
+ [connections, links] = await Promise.all([
+ api.zammadConnections.forWorkspace(workspaceId),
+ api.zammadTickets.forItem(itemId),
+ ]);
+ } catch (err) {
+ console.error('Failed to load Zammad links:', err);
+ error = t('zammad.loadLinksFailed');
+ } finally {
+ loading = false;
+ }
+ }
+
+ async function openCreateDialog() {
+ dialogMode = 'create';
+ formError = '';
+ ticketNumber = '';
+ selectedConnectionId = usableConnections[0]?.id || '';
+ selectedGroupId = '';
+ showCreate = true;
+ await loadMetadata();
+ }
+
+ function openLinkDialog() {
+ dialogMode = 'link';
+ formError = '';
+ ticketNumber = '';
+ selectedConnectionId = usableConnections[0]?.id || '';
+ selectedGroupId = '';
+ showCreate = true;
+ }
+
+ function closeCreateDialog() {
+ if (creating || linking) return;
+ showCreate = false;
+ formError = '';
+ }
+
+ async function handleCreateConnectionChange() {
+ formError = '';
+ if (dialogMode === 'create') await loadMetadata();
+ }
+
+ async function loadMetadata() {
+ const connection = selectedConnection();
+ if (!connection) return;
+ loadingMetadata = true;
+ try {
+ const loadedMetadata = await api.zammadConnections.metadata(workspaceId, connection.id);
+ const groups = usableGroups(connection, loadedMetadata);
+ metadata = { ...loadedMetadata, groups };
+ const defaultGroup = metadata.groups.find(
+ (group) => group.id === connection.default_group_id || group.name === connection.default_group_name,
+ );
+ selectedGroupId = String(defaultGroup?.id || metadata.groups[0]?.id || '');
+ } catch (err) {
+ console.error('Failed to load Zammad metadata:', err);
+ formError = t('zammad.loadMetadataFailed');
+ metadata = { groups: [], states: [] };
+ selectedGroupId = '';
+ } finally {
+ loadingMetadata = false;
+ }
+ }
+
+ async function createTicket() {
+ const group = metadata.groups.find((entry) => entry.id === Number(selectedGroupId));
+ if (!selectedConnectionId || !group) return;
+ creating = true;
+ formError = '';
+ try {
+ const link = await api.zammadTickets.create(itemId, {
+ connection_id: selectedConnectionId,
+ group_id: group.id,
+ });
+ replaceLink(link);
+ showCreate = false;
+ successToast(link.sync_state === 'linked' ? t('zammad.ticketCreated') : t('zammad.ticketCreationStarted'));
+ } catch (err) {
+ console.error('Failed to create Zammad ticket:', err);
+ formError = err.message || t('zammad.ticketCreateFailed');
+ errorToast(t('zammad.ticketCreateFailed'));
+ await load();
+ } finally {
+ creating = false;
+ }
+ }
+
+ async function linkExistingTicket() {
+ const trimmedTicketNumber = ticketNumber.trim();
+ if (!selectedConnectionId || !trimmedTicketNumber) return;
+ linking = true;
+ formError = '';
+ try {
+ const link = await api.zammadTickets.link(itemId, {
+ connection_id: selectedConnectionId,
+ ticket_number: trimmedTicketNumber,
+ });
+ replaceLink(link);
+ showCreate = false;
+ successToast(t('zammad.ticketLinked'));
+ } catch (err) {
+ console.error('Failed to link existing Zammad ticket:', err);
+ formError = err.message || t('zammad.ticketLinkFailed');
+ errorToast(t('zammad.ticketLinkFailed'));
+ } finally {
+ linking = false;
+ }
+ }
+
+ async function refresh(link) {
+ if (!link.ticket_id) return;
+ refreshingId = link.id;
+ try {
+ const updated = await api.zammadTickets.refresh(link.id);
+ replaceLink(updated);
+ successToast(t('zammad.ticketRefreshed'));
+ } catch (err) {
+ console.error('Failed to refresh Zammad ticket:', err);
+ errorToast(t('zammad.ticketRefreshFailed'));
+ await load();
+ } finally {
+ refreshingId = null;
+ }
+ }
+
+ async function openEditDialog(link) {
+ const connection = usableConnections.find((entry) => entry.id === link.connection_id);
+ if (!connection) {
+ errorToast(t('zammad.connectionUnavailable'));
+ return;
+ }
+
+ editingLink = link;
+ editError = '';
+ editMetadata = { groups: [], states: [] };
+ editOwners = [];
+ selectedEditStateId = String(link.last_status_id || '');
+ selectedEditGroupId = String(link.group_id || connection.default_group_id || '');
+ selectedEditOwnerId = String(link.owner_id || 1);
+ showEdit = true;
+ loadingEditMetadata = true;
+ try {
+ const loadedMetadata = await api.zammadConnections.metadata(workspaceId, connection.id);
+ editMetadata = { ...loadedMetadata, groups: usableGroups(connection, loadedMetadata) };
+ if (!editGroups.some((group) => String(group.id) === selectedEditGroupId)) {
+ selectedEditGroupId = String(editGroups[0]?.id || '');
+ }
+ await loadEditOwners();
+ initialEdit = {
+ stateId: selectedEditStateId,
+ groupId: selectedEditGroupId,
+ ownerId: selectedEditOwnerId,
+ };
+ } catch (err) {
+ console.error('Failed to load Zammad ticket metadata:', err);
+ editError = t('zammad.loadMetadataFailed');
+ } finally {
+ loadingEditMetadata = false;
+ }
+ }
+
+ function closeEditDialog() {
+ if (savingEdit) return;
+ showEdit = false;
+ editError = '';
+ editingLink = null;
+ }
+
+ async function loadEditOwners() {
+ const connection = selectedEditConnection();
+ if (!connection || !selectedEditGroupId) return;
+ loadingEditOwners = true;
+ try {
+ editOwners = await api.zammadConnections.owners(
+ workspaceId,
+ connection.id,
+ Number(selectedEditGroupId),
+ );
+ if (!editOwners.some((owner) => String(owner.id) === selectedEditOwnerId)) {
+ selectedEditOwnerId = '1';
+ }
+ } catch (err) {
+ console.error('Failed to load Zammad owners:', err);
+ editError = t('zammad.loadOwnersFailed');
+ editOwners = [];
+ selectedEditOwnerId = '1';
+ } finally {
+ loadingEditOwners = false;
+ }
+ }
+
+ async function changeEditGroup() {
+ editError = '';
+ selectedEditOwnerId = '1';
+ await loadEditOwners();
+ }
+
+ function editPayload() {
+ const payload = {};
+ if (selectedEditStateId && selectedEditStateId !== initialEdit.stateId) {
+ payload.state_id = Number(selectedEditStateId);
+ }
+ if (selectedEditGroupId && selectedEditGroupId !== initialEdit.groupId) {
+ payload.group_id = Number(selectedEditGroupId);
+ }
+ if (selectedEditOwnerId && selectedEditOwnerId !== initialEdit.ownerId) {
+ payload.owner_id = Number(selectedEditOwnerId);
+ }
+ return payload;
+ }
+
+ async function saveEdit() {
+ const payload = editPayload();
+ if (!editingLink || Object.keys(payload).length === 0) return;
+ savingEdit = true;
+ editError = '';
+ try {
+ const updated = await api.zammadTickets.update(editingLink.id, payload);
+ replaceLink(updated);
+ showEdit = false;
+ successToast(t('zammad.ticketUpdated'));
+ } catch (err) {
+ console.error('Failed to update Zammad ticket:', err);
+ editError = err.message || t('zammad.ticketUpdateFailed');
+ errorToast(t('zammad.ticketUpdateFailed'));
+ } finally {
+ savingEdit = false;
+ }
+ }
+
+ async function removeLink(link) {
+ const accepted = await confirm({
+ title: t('zammad.removeTicketLink'),
+ message: t('zammad.removeTicketLinkConfirm', { number: link.ticket_number }),
+ confirmText: t('zammad.removeTicketLink'),
+ cancelText: t('common.cancel'),
+ variant: 'danger',
+ });
+ if (!accepted) return;
+
+ removingId = link.id;
+ try {
+ await api.zammadTickets.delete(link.id);
+ successToast(t('zammad.ticketLinkRemoved'));
+ await load();
+ } catch (err) {
+ console.error('Failed to remove Zammad ticket link:', err);
+ errorToast(t('zammad.ticketLinkRemoveFailed'));
+ await load();
+ } finally {
+ removingId = null;
+ }
+ }
+
+
+{#if loading || connections.length > 0 || links.length > 0}
+
+
+
+
+
+ {t('zammad.tickets')}
+
+ {#if usableConnections.length > 0 && canEdit}
+
+
+
+
+
+ {/if}
+
+
+ {#if loading}
+
+ {:else if error}
+
{error}
+ {:else}
+ {#if unavailableConnections.length > 0}
+
{t('zammad.connectionUnavailable')}
+ {/if}
+ {#if links.length === 0}
+
{t('zammad.noTickets')}
+ {:else}
+
+ {#each links as link}
+ {@const linkConnectionUsable = usableConnections.some((connection) => connection.id === link.connection_id)}
+
+
+
+ {#if link.ticket_url}
+
+ {t('zammad.ticketNumber', { number: link.ticket_number })}
+
+ {:else if link.ticket_number}
+
{t('zammad.ticketNumber', { number: link.ticket_number })}
+ {:else}
+
{t(`zammad.syncState.${link.sync_state}`)}
+ {/if}
+
+
{link.connection_name}
+
{t('zammad.status')}: {link.last_status_name || t('zammad.unknown')}
+
{t('zammad.group')}: {link.group_name || t('zammad.unknown')}
+
{t('zammad.owner')}: {link.owner_name || t('zammad.unassignedOwner')}
+
{link.last_synced_at ? t('zammad.lastSynced', { time: new Date(link.last_synced_at).toLocaleString() }) : t('zammad.notSynced')}
+
+
+ {#if canEdit && linkConnectionUsable}
+
+ {#if link.ticket_id}
+
+
+ {/if}
+
+
+ {/if}
+
+ {#if !link.ticket_id && link.sync_state !== 'linked'}
+
{syncActionHint(link)}
+ {/if}
+ {#if link.last_error}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+{/if}
+
+
+
+
+
+ {dialogMode === 'create' ? t('zammad.createTicketConfirm') : t('zammad.linkExistingTicketConfirm')}
+
+ {#if formError}
+
{formError}
+ {/if}
+
+ ({ value: connection.id, label: connection.name }))}
+ onchange={handleCreateConnectionChange}
+ />
+
+ {#if dialogMode === 'create'}
+
+ {#if loadingMetadata}
+
+ {:else}
+ ({ value: String(group.id), label: group.name }))}
+ disabled={metadata.groups.length === 0}
+ />
+ {/if}
+
+ {:else}
+
+
+
+ {/if}
+
+
+ {#if dialogMode === 'create'}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+ {#if editError}
+
{editError}
+ {/if}
+ {#if loadingEditMetadata}
+
+ {:else}
+
+ ({ value: String(state.id), label: state.name }))}
+ placeholder={t('zammad.leaveUnchanged')}
+ disabled={savingEdit || editStates.length === 0}
+ />
+
+
+ ({ value: String(group.id), label: group.name }))}
+ disabled={savingEdit || editGroups.length === 0}
+ onchange={changeEditGroup}
+ />
+
+
+ {#if loadingEditOwners}
+
+ {:else}
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+
diff --git a/frontend/src/lib/locales/ar/index.js b/frontend/src/lib/locales/ar/index.js
index 1c7c2014b..5e5d49015 100644
--- a/frontend/src/lib/locales/ar/index.js
+++ b/frontend/src/lib/locales/ar/index.js
@@ -23,6 +23,7 @@ import teams from './teams.js';
import supplemental from './supplemental.js';
import adminOperations from './adminOperations.js';
import dashboard from './dashboard.js';
+import zammad from './zammad.js';
export default createLocale({
common,
@@ -44,4 +45,5 @@ export default createLocale({
supplemental,
adminOperations,
dashboard,
+ zammad,
});
diff --git a/frontend/src/lib/locales/ar/zammad.js b/frontend/src/lib/locales/ar/zammad.js
new file mode 100644
index 000000000..d0660b8f8
--- /dev/null
+++ b/frontend/src/lib/locales/ar/zammad.js
@@ -0,0 +1,23 @@
+export default { zammad: {
+ tab: 'Zammad', connections: 'اتصالات Zammad', connectionsDescription: 'إعداد اتصالات تذاكر Zammad آمنة ومقيّدة بمساحات العمل.',
+ addConnection: 'إضافة اتصال', editConnection: 'تعديل الاتصال', noConnections: 'لا توجد اتصالات Zammad مضبوطة', loadConnectionsFailed: 'تعذر تحميل اتصالات Zammad',
+ connectionCreated: 'تم إنشاء اتصال Zammad', connectionUpdated: 'تم تحديث اتصال Zammad', saveConnectionFailed: 'تعذر حفظ اتصال Zammad',
+ connectionDeleted: 'تم حذف اتصال Zammad', deleteConnection: 'حذف اتصال Zammad', deleteConnectionConfirm: 'هل تريد حذف «{name}»؟ يجب أولًا إلغاء ربط التذاكر الحالية.',
+ deleteConnectionFailed: 'تعذر حذف اتصال Zammad', testConnection: 'اختبار الاتصال', connectionTestSucceeded: 'نجح اتصال Zammad', connectionTestFailed: 'فشل اتصال Zammad',
+ metadataSummary: 'تتوفر {groups} مجموعات و{states} حالات تذاكر', slug: 'المعرّف المختصر', baseUrl: 'الرابط الأساسي', apiToken: 'رمز API',
+ apiTokenDescription: 'استخدم رمز API من Zammad لهذا الاتصال على مستوى النظام.', apiTokenAvailable: 'رمز API متاح', apiTokenMissing: 'رمز API مفقود',
+ authMethod: 'طريقة المصادقة', oauth: 'OAuth', oauthDescription: 'فوّض هذا الاتصال باستخدام OAuth من Zammad.', oauthClientId: 'معرّف عميل OAuth', oauthClientSecret: 'سر عميل OAuth',
+ secretStored: 'مخزّن بأمان - اتركه فارغًا للاحتفاظ به', oauthPending: 'تفويض OAuth قيد الانتظار', oauthConnectedStatus: 'OAuth متصل', oauthReauthorizationRequired: 'إعادة تفويض OAuth مطلوبة',
+ connectOAuth: 'الاتصال بـ Zammad', reauthorizeOAuth: 'التفويض مرة أخرى', oauthConnected: 'تم اتصال OAuth مع Zammad', oauthConnectionFailed: 'فشل تفويض OAuth لـ Zammad', oauthAuthorizationFailed: 'تعذر بدء تفويض OAuth لـ Zammad',
+ correlationFieldNotVerified: 'تمنع أذونات OAuth المقيّدة التحقق التلقائي من حقل الربط. تأكد من وجوده في Zammad قبل إنشاء التذاكر.',
+ defaultCustomer: 'عميل التذكرة الافتراضي', defaultGroup: 'المجموعة الافتراضية', allowedGroups: 'مجموعات التذاكر المسموح بها', allowedGroupIds: 'معرفات مجموعات Zammad المسموح بها', correlationField: 'حقل الربط',
+ correlationFieldHint: 'أنشئ هذا الحقل النصي في تذاكر Zammad حتى تعثر المحاولات المتكررة على التذكرة المنشأة.', completionStatus: 'حالة الإكمال في Windshift',
+ noAutomaticCompletion: 'عدم الإكمال تلقائيا', closedStates: 'حالات Zammad المغلقة', closedStateIds: 'معرّفات حالات Zammad المغلقة',
+ allWorkspaces: 'السماح بكل مساحات العمل', allowedWorkspaces: 'مساحات العمل المسموحة', tickets: 'تذاكر Zammad', noTickets: 'لا توجد تذكرة Zammad مرتبطة بعد',
+ createTicket: 'إنشاء تذكرة Zammad', createTicketConfirm: 'سيتم إرسال عنوان العنصر ووصفه إلى اتصال Zammad هذا كملاحظة داخلية. أكد المجموعة الهدف.', connection: 'الاتصال', group: 'المجموعة', status: 'الحالة', owner: 'المسؤول', unassignedOwner: 'غير مكلّف', unknown: 'غير معروف', notSynced: 'لم تتم المزامنة بعد', leaveUnchanged: 'عدم التغيير',
+ connectionUnavailable: 'يتطلب اتصال Zammad واحد أو أكثر تفويضًا، ولا يمكن استخدامه الآن لإجراءات التذاكر.',
+ loadLinksFailed: 'تعذر تحميل روابط تذاكر Zammad', loadMetadataFailed: 'تعذر تحميل مجموعات Zammad وحالاتها', loadOwnersFailed: 'تعذر تحميل مسؤولي Zammad', ticketCreated: 'تم إنشاء تذكرة Zammad',
+ ticketCreationStarted: 'إنشاء تذكرة Zammad قيد التنفيذ بالفعل', ticketCreationInProgress: 'يجري إنشاء التذكرة. تصبح التحديثات متاحة بعد ربط التذكرة.', ticketCreateFailed: 'تعذر إنشاء تذكرة Zammad', linkExistingTicket: 'ربط تذكرة موجودة', linkExistingTicketConfirm: 'اربط تذكرة Zammad موجودة باستخدام رقم التذكرة.', ticketNumberLabel: 'رقم التذكرة', ticketNumberPlaceholder: 'مثال: 12345', ticketLinked: 'تم ربط تذكرة Zammad', ticketLinkFailed: 'تعذر ربط تذكرة Zammad', editTicket: 'تعديل تذكرة Zammad', ticketUpdated: 'تم تحديث تذكرة Zammad', ticketUpdateFailed: 'تعذر تحديث تذكرة Zammad', ticketRefreshed: 'تم تحديث حالة تذكرة Zammad',
+ ticketRefreshFailed: 'تعذر تحديث تذكرة Zammad', retrySync: 'إعادة محاولة المزامنة', refreshTicket: 'تحديث حالة التذكرة', syncFailedNoTicket: 'فشلت المزامنة. تصبح التحديثات متاحة بعد ربط تذكرة Zammad.', creationUncertainNoTicket: 'نتيجة إنشاء التذكرة غير مؤكدة. تحقق من Zammad واربط التذكرة الموجودة بالرقم إذا لزم الأمر.', removeTicketLink: 'إزالة التعيين', removeTicketLinkConfirm: 'ستتم إزالة التعيين إلى Zammad رقم {number} وحقل الربط في Windshift. ستبقى تذكرة Zammad دون تغيير.', ticketLinkRemoved: 'تمت إزالة تعيين تذكرة Zammad', ticketLinkRemoveFailed: 'تعذر إزالة تعيين تذكرة Zammad', ticketNumber: 'Zammad رقم {number}', lastSynced: 'تمت المزامنة: {time}',
+ syncState: { pending: 'إنشاء التذكرة معلق', creating: 'جار إنشاء التذكرة', linked: 'التذكرة مرتبطة', sync_failed: 'فشلت المزامنة', creation_uncertain: 'نتيجة الإنشاء غير مؤكدة؛ المحاولات الجديدة تبحث فقط عن التذكرة الموجودة' },
+} };
diff --git a/frontend/src/lib/locales/de/index.js b/frontend/src/lib/locales/de/index.js
index 42e03a734..168b07341 100644
--- a/frontend/src/lib/locales/de/index.js
+++ b/frontend/src/lib/locales/de/index.js
@@ -25,6 +25,7 @@ import quality from './quality.js';
import review from './review.js';
import adminOperations from './adminOperations.js';
import dashboard from './dashboard.js';
+import zammad from './zammad.js';
export default createLocale({
admin,
@@ -48,4 +49,5 @@ export default createLocale({
review,
adminOperations,
dashboard,
+ zammad,
});
diff --git a/frontend/src/lib/locales/de/zammad.js b/frontend/src/lib/locales/de/zammad.js
new file mode 100644
index 000000000..098b623d1
--- /dev/null
+++ b/frontend/src/lib/locales/de/zammad.js
@@ -0,0 +1,101 @@
+export default { zammad: {
+ tab: 'Zammad',
+ connections: 'Zammad-Verbindungen',
+ connectionsDescription: 'Sichere und auf Arbeitsbereiche begrenzte Zammad-Ticket-Verbindungen konfigurieren.',
+ addConnection: 'Verbindung hinzufügen',
+ editConnection: 'Verbindung bearbeiten',
+ noConnections: 'Keine Zammad-Verbindungen konfiguriert',
+ loadConnectionsFailed: 'Zammad-Verbindungen konnten nicht geladen werden',
+ connectionCreated: 'Zammad-Verbindung erstellt',
+ connectionUpdated: 'Zammad-Verbindung aktualisiert',
+ saveConnectionFailed: 'Zammad-Verbindung konnte nicht gespeichert werden',
+ connectionDeleted: 'Zammad-Verbindung gelöscht',
+ deleteConnection: 'Zammad-Verbindung löschen',
+ deleteConnectionConfirm: '„{name}“ löschen? Bestehende Ticket-Verknüpfungen müssen vorher einzeln entfernt werden.',
+ deleteConnectionFailed: 'Zammad-Verbindung konnte nicht gelöscht werden',
+ testConnection: 'Verbindung testen',
+ connectionTestSucceeded: 'Zammad-Verbindung erfolgreich getestet',
+ connectionTestFailed: 'Zammad-Verbindung fehlgeschlagen',
+ metadataSummary: '{groups} Gruppen und {states} Ticketstatus verfügbar',
+ slug: 'Kurzname',
+ baseUrl: 'Basis-URL',
+ apiToken: 'API-Token',
+ apiTokenDescription: 'Für diese systemweite Verbindung einen Zammad-API-Token verwenden.',
+ apiTokenAvailable: 'API-Token vorhanden',
+ apiTokenMissing: 'API-Token fehlt',
+ authMethod: 'Anmeldemethode',
+ oauth: 'OAuth',
+ oauthDescription: 'Diese Verbindung per Zammad OAuth autorisieren.',
+ oauthClientId: 'OAuth-Client-ID',
+ oauthClientSecret: 'OAuth-Client-Secret',
+ secretStored: 'Sicher gespeichert - leer lassen, um ihn beizubehalten',
+ oauthPending: 'OAuth-Autorisierung ausstehend',
+ oauthConnectedStatus: 'OAuth verbunden',
+ oauthReauthorizationRequired: 'OAuth-Neuautorisierung erforderlich',
+ connectOAuth: 'Mit Zammad verbinden',
+ reauthorizeOAuth: 'Erneut autorisieren',
+ oauthConnected: 'Zammad OAuth verbunden',
+ oauthConnectionFailed: 'Zammad-OAuth-Autorisierung fehlgeschlagen',
+ oauthAuthorizationFailed: 'Zammad-OAuth-Autorisierung konnte nicht gestartet werden',
+ correlationFieldNotVerified: 'Eingeschränkte OAuth-Berechtigungen verhindern die automatische Prüfung des Korrelationsfelds. Vor der Ticketerstellung dessen Existenz in Zammad prüfen.',
+ defaultCustomer: 'Standardkunde für Tickets',
+ defaultGroup: 'Standardgruppe',
+ allowedGroups: 'Erlaubte Ticketgruppen',
+ allowedGroupIds: 'IDs erlaubter Zammad-Gruppen',
+ correlationField: 'Korrelationsfeld',
+ correlationFieldHint: 'Dieses Textfeld in Zammad-Tickets anlegen, damit Wiederholungen ein bereits erstelltes Ticket finden.',
+ completionStatus: 'Abschlussstatus in Windshift',
+ noAutomaticCompletion: 'Nicht automatisch abschließen',
+ closedStates: 'Geschlossene Zammad-Status',
+ closedStateIds: 'IDs geschlossener Zammad-Status',
+ allWorkspaces: 'Alle Arbeitsbereiche erlauben',
+ allowedWorkspaces: 'Erlaubte Arbeitsbereiche',
+ tickets: 'Zammad-Tickets',
+ noTickets: 'Noch kein Zammad-Ticket verknüpft',
+ createTicket: 'Zammad-Ticket erstellen',
+ createTicketConfirm: 'Titel und Beschreibung werden als interne Notiz an diese Zammad-Verbindung gesendet. Zielgruppe bestätigen.',
+ connection: 'Verbindung',
+ group: 'Gruppe',
+ status: 'Status',
+ owner: 'Verantwortlich',
+ unassignedOwner: 'Nicht zugewiesen',
+ unknown: 'Unbekannt',
+ notSynced: 'Noch nicht synchronisiert',
+ leaveUnchanged: 'Unverändert lassen',
+ connectionUnavailable: 'Eine oder mehrere Zammad-Verbindungen müssen erst autorisiert werden und stehen derzeit nicht für Ticketaktionen zur Verfügung.',
+ loadLinksFailed: 'Zammad-Ticket-Verknüpfungen konnten nicht geladen werden',
+ loadMetadataFailed: 'Zammad-Gruppen und -Status konnten nicht geladen werden',
+ loadOwnersFailed: 'Zammad-Verantwortliche konnten nicht geladen werden',
+ ticketCreated: 'Zammad-Ticket erstellt',
+ ticketCreationStarted: 'Die Erstellung des Zammad-Tickets läuft bereits',
+ ticketCreationInProgress: 'Die Ticketerstellung läuft. Eine Aktualisierung ist erst möglich, sobald ein Ticket verknüpft ist.',
+ ticketCreateFailed: 'Zammad-Ticket konnte nicht erstellt werden',
+ linkExistingTicket: 'Bestehendes Ticket zuordnen',
+ linkExistingTicketConfirm: 'Ordne ein bereits vorhandenes Zammad-Ticket anhand seiner Ticketnummer zu.',
+ ticketNumberLabel: 'Ticketnummer',
+ ticketNumberPlaceholder: 'Zum Beispiel 12345',
+ ticketLinked: 'Zammad-Ticket zugeordnet',
+ ticketLinkFailed: 'Zammad-Ticket konnte nicht zugeordnet werden',
+ editTicket: 'Zammad-Ticket bearbeiten',
+ ticketUpdated: 'Zammad-Ticket aktualisiert',
+ ticketUpdateFailed: 'Zammad-Ticket konnte nicht aktualisiert werden',
+ ticketRefreshed: 'Zammad-Ticketstatus aktualisiert',
+ ticketRefreshFailed: 'Zammad-Ticket konnte nicht aktualisiert werden',
+ retrySync: 'Synchronisation wiederholen',
+ refreshTicket: 'Ticketstatus aktualisieren',
+ syncFailedNoTicket: 'Die Synchronisation ist fehlgeschlagen. Eine Aktualisierung ist erst verfügbar, wenn ein Zammad-Ticket verknüpft wurde.',
+ creationUncertainNoTicket: 'Der Ausgang der Ticketerstellung ist unklar. Prüfe Zammad und ordne das bestehende Ticket bei Bedarf über die Ticketnummer zu.',
+ removeTicketLink: 'Zuordnung entfernen',
+ removeTicketLinkConfirm: 'Die Zuordnung zu Zammad #{number} und das Windshift-Korrelationsfeld werden entfernt. Das Zammad-Ticket selbst bleibt bestehen.',
+ ticketLinkRemoved: 'Zammad-Ticketzuordnung entfernt',
+ ticketLinkRemoveFailed: 'Zammad-Ticketzuordnung konnte nicht entfernt werden',
+ ticketNumber: 'Zammad #{number}',
+ lastSynced: 'Synchronisiert: {time}',
+ syncState: {
+ pending: 'Ticketerstellung ausstehend',
+ creating: 'Ticket wird erstellt',
+ linked: 'Ticket verknüpft',
+ sync_failed: 'Synchronisation fehlgeschlagen',
+ creation_uncertain: 'Erstellung unklar; Wiederholungen suchen nur nach dem vorhandenen Ticket',
+ },
+} };
diff --git a/frontend/src/lib/locales/en/index.js b/frontend/src/lib/locales/en/index.js
index 822c9f5b3..8fa5c4fd0 100644
--- a/frontend/src/lib/locales/en/index.js
+++ b/frontend/src/lib/locales/en/index.js
@@ -23,6 +23,7 @@ import pages from './pages.js';
import adminOperations from './adminOperations.js';
import agents from './agents.js';
import dashboard from './dashboard.js';
+import zammad from './zammad.js';
export default createLocale({
common,
@@ -44,4 +45,5 @@ export default createLocale({
adminOperations,
agents,
dashboard,
+ zammad,
});
diff --git a/frontend/src/lib/locales/en/zammad.js b/frontend/src/lib/locales/en/zammad.js
new file mode 100644
index 000000000..b713f90df
--- /dev/null
+++ b/frontend/src/lib/locales/en/zammad.js
@@ -0,0 +1,101 @@
+export default { zammad: {
+ tab: 'Zammad',
+ connections: 'Zammad connections',
+ connectionsDescription: 'Configure secure, workspace-scoped Zammad ticket connections.',
+ addConnection: 'Add connection',
+ editConnection: 'Edit connection',
+ noConnections: 'No Zammad connections configured',
+ loadConnectionsFailed: 'Failed to load Zammad connections',
+ connectionCreated: 'Zammad connection created',
+ connectionUpdated: 'Zammad connection updated',
+ saveConnectionFailed: 'Failed to save Zammad connection',
+ connectionDeleted: 'Zammad connection deleted',
+ deleteConnection: 'Delete Zammad connection',
+ deleteConnectionConfirm: 'Delete “{name}”? Existing ticket links must be unlinked first.',
+ deleteConnectionFailed: 'Failed to delete Zammad connection',
+ testConnection: 'Test connection',
+ connectionTestSucceeded: 'Zammad connection succeeded',
+ connectionTestFailed: 'Zammad connection failed',
+ metadataSummary: '{groups} groups and {states} ticket states available',
+ slug: 'Slug',
+ baseUrl: 'Base URL',
+ apiToken: 'API token',
+ apiTokenDescription: 'Use a Zammad API token for this system connection.',
+ apiTokenAvailable: 'API token available',
+ apiTokenMissing: 'API token missing',
+ authMethod: 'Authentication method',
+ oauth: 'OAuth',
+ oauthDescription: 'Authorize this connection with Zammad OAuth.',
+ oauthClientId: 'OAuth client ID',
+ oauthClientSecret: 'OAuth client secret',
+ secretStored: 'Stored securely - leave blank to keep unchanged',
+ oauthPending: 'OAuth authorization pending',
+ oauthConnectedStatus: 'OAuth connected',
+ oauthReauthorizationRequired: 'OAuth reauthorization required',
+ connectOAuth: 'Connect to Zammad',
+ reauthorizeOAuth: 'Authorize again',
+ oauthConnected: 'Zammad OAuth connected',
+ oauthConnectionFailed: 'Zammad OAuth authorization failed',
+ oauthAuthorizationFailed: 'Could not start Zammad OAuth authorization',
+ correlationFieldNotVerified: 'Restricted OAuth permissions prevent automatic verification of the correlation field. Verify that it exists in Zammad before creating tickets.',
+ defaultCustomer: 'Default ticket customer',
+ defaultGroup: 'Default group',
+ allowedGroups: 'Allowed ticket groups',
+ allowedGroupIds: 'Allowed Zammad group IDs',
+ correlationField: 'Correlation field',
+ correlationFieldHint: 'Create this text field on Zammad tickets so retries can find an already-created ticket.',
+ completionStatus: 'Windshift completion status',
+ noAutomaticCompletion: 'Do not complete automatically',
+ closedStates: 'Closed Zammad states',
+ closedStateIds: 'Closed Zammad state IDs',
+ allWorkspaces: 'Allow all workspaces',
+ allowedWorkspaces: 'Allowed workspaces',
+ tickets: 'Zammad tickets',
+ noTickets: 'No Zammad ticket linked yet',
+ createTicket: 'Create Zammad ticket',
+ createTicketConfirm: 'The item title and description will be sent to this Zammad connection as an internal note. Confirm the destination group.',
+ connection: 'Connection',
+ group: 'Group',
+ status: 'Status',
+ owner: 'Owner',
+ unassignedOwner: 'Not assigned',
+ unknown: 'Unknown',
+ notSynced: 'Not synced yet',
+ leaveUnchanged: 'Leave unchanged',
+ connectionUnavailable: 'One or more Zammad connections need authorization and cannot be used for ticket actions yet.',
+ loadLinksFailed: 'Failed to load Zammad ticket links',
+ loadMetadataFailed: 'Failed to load Zammad groups and states',
+ loadOwnersFailed: 'Failed to load Zammad owners',
+ ticketCreated: 'Zammad ticket created',
+ ticketCreationStarted: 'Zammad ticket creation is already in progress',
+ ticketCreationInProgress: 'Ticket creation is in progress. Refresh is available once a ticket is linked.',
+ ticketCreateFailed: 'Failed to create Zammad ticket',
+ linkExistingTicket: 'Link existing ticket',
+ linkExistingTicketConfirm: 'Link an existing Zammad ticket by its ticket number.',
+ ticketNumberLabel: 'Ticket number',
+ ticketNumberPlaceholder: 'For example, 12345',
+ ticketLinked: 'Zammad ticket linked',
+ ticketLinkFailed: 'Failed to link Zammad ticket',
+ editTicket: 'Edit Zammad ticket',
+ ticketUpdated: 'Zammad ticket updated',
+ ticketUpdateFailed: 'Failed to update Zammad ticket',
+ ticketRefreshed: 'Zammad ticket status refreshed',
+ ticketRefreshFailed: 'Failed to refresh Zammad ticket',
+ retrySync: 'Retry synchronization',
+ refreshTicket: 'Refresh ticket status',
+ syncFailedNoTicket: 'Synchronization failed. Refresh is available after a Zammad ticket has been linked.',
+ creationUncertainNoTicket: 'Ticket creation has an uncertain outcome. Check Zammad and link the existing ticket by number if needed.',
+ removeTicketLink: 'Remove assignment',
+ removeTicketLinkConfirm: 'This removes the assignment to Zammad #{number} and the Windshift correlation field. The Zammad ticket remains unchanged.',
+ ticketLinkRemoved: 'Zammad ticket assignment removed',
+ ticketLinkRemoveFailed: 'Failed to remove Zammad ticket assignment',
+ ticketNumber: 'Zammad #{number}',
+ lastSynced: 'Synced {time}',
+ syncState: {
+ pending: 'Ticket creation pending',
+ creating: 'Creating ticket',
+ linked: 'Ticket linked',
+ sync_failed: 'Synchronization failed',
+ creation_uncertain: 'Creation outcome uncertain; retries only search for the existing ticket',
+ },
+} };
diff --git a/frontend/src/lib/locales/es/index.js b/frontend/src/lib/locales/es/index.js
index 830d69f4d..878921d96 100644
--- a/frontend/src/lib/locales/es/index.js
+++ b/frontend/src/lib/locales/es/index.js
@@ -24,6 +24,7 @@ import quality from './quality.js';
import review from './review.js';
import adminOperations from './adminOperations.js';
import dashboard from './dashboard.js';
+import zammad from './zammad.js';
export default createLocale({
common,
@@ -47,4 +48,5 @@ export default createLocale({
review,
adminOperations,
dashboard,
+ zammad,
});
diff --git a/frontend/src/lib/locales/es/zammad.js b/frontend/src/lib/locales/es/zammad.js
new file mode 100644
index 000000000..c8387fdc3
--- /dev/null
+++ b/frontend/src/lib/locales/es/zammad.js
@@ -0,0 +1,29 @@
+export default { zammad: {
+ tab: 'Zammad', connections: 'Conexiones de Zammad',
+ connectionsDescription: 'Configura conexiones seguras de tickets de Zammad limitadas por espacio de trabajo.',
+ addConnection: 'Añadir conexión', editConnection: 'Editar conexión', noConnections: 'No hay conexiones de Zammad configuradas',
+ loadConnectionsFailed: 'No se pudieron cargar las conexiones de Zammad', connectionCreated: 'Conexión de Zammad creada',
+ connectionUpdated: 'Conexión de Zammad actualizada', saveConnectionFailed: 'No se pudo guardar la conexión de Zammad',
+ connectionDeleted: 'Conexión de Zammad eliminada', deleteConnection: 'Eliminar conexión de Zammad',
+ deleteConnectionConfirm: '¿Eliminar «{name}»? Primero deben desvincularse los tickets existentes.', deleteConnectionFailed: 'No se pudo eliminar la conexión de Zammad',
+ testConnection: 'Probar conexión', connectionTestSucceeded: 'La conexión de Zammad funciona', connectionTestFailed: 'La conexión de Zammad falló',
+ metadataSummary: '{groups} grupos y {states} estados de ticket disponibles', slug: 'Identificador', baseUrl: 'URL base', apiToken: 'Token de API',
+ apiTokenDescription: 'Usa un token de API de Zammad para esta conexión del sistema.', apiTokenAvailable: 'Token de API disponible', apiTokenMissing: 'Falta el token de API',
+ authMethod: 'Método de autenticación', oauth: 'OAuth', oauthDescription: 'Autoriza esta conexión con OAuth de Zammad.', oauthClientId: 'ID de cliente OAuth', oauthClientSecret: 'Secreto de cliente OAuth',
+ secretStored: 'Guardado de forma segura - déjalo vacío para conservarlo', oauthPending: 'Autorización de OAuth pendiente', oauthConnectedStatus: 'OAuth conectado', oauthReauthorizationRequired: 'Se requiere volver a autorizar OAuth',
+ connectOAuth: 'Conectar con Zammad', reauthorizeOAuth: 'Autorizar de nuevo', oauthConnected: 'OAuth de Zammad conectado', oauthConnectionFailed: 'Falló la autorización OAuth de Zammad', oauthAuthorizationFailed: 'No se pudo iniciar la autorización OAuth de Zammad',
+ correlationFieldNotVerified: 'Los permisos restringidos de OAuth impiden verificar automáticamente el campo de correlación. Comprueba que exista en Zammad antes de crear tickets.',
+ defaultCustomer: 'Cliente predeterminado del ticket', defaultGroup: 'Grupo predeterminado', allowedGroups: 'Grupos de tickets permitidos', allowedGroupIds: 'IDs de grupos de Zammad permitidos', correlationField: 'Campo de correlación',
+ correlationFieldHint: 'Crea este campo de texto en los tickets de Zammad para que los reintentos encuentren un ticket ya creado.',
+ completionStatus: 'Estado de finalización en Windshift', noAutomaticCompletion: 'No finalizar automáticamente', closedStates: 'Estados cerrados de Zammad',
+ closedStateIds: 'IDs de estados cerrados de Zammad', allWorkspaces: 'Permitir todos los espacios', allowedWorkspaces: 'Espacios permitidos',
+ tickets: 'Tickets de Zammad', noTickets: 'Aún no hay ningún ticket de Zammad enlazado', createTicket: 'Crear ticket de Zammad',
+ createTicketConfirm: 'El título y la descripción se enviarán a esta conexión de Zammad como nota interna. Confirma el grupo de destino.', connection: 'Conexión', group: 'Grupo', status: 'Estado', owner: 'Responsable', unassignedOwner: 'Sin asignar', unknown: 'Desconocido', notSynced: 'Aún no sincronizado', leaveUnchanged: 'Dejar sin cambios',
+ connectionUnavailable: 'Una o más conexiones de Zammad necesitan autorización y no se pueden usar todavía para acciones de tickets.',
+ loadLinksFailed: 'No se pudieron cargar los enlaces de tickets de Zammad', loadMetadataFailed: 'No se pudieron cargar los grupos y estados de Zammad', loadOwnersFailed: 'No se pudieron cargar los responsables de Zammad',
+ ticketCreated: 'Ticket de Zammad creado', ticketCreationStarted: 'La creación del ticket de Zammad ya está en curso', ticketCreationInProgress: 'La creación del ticket está en curso. La actualización estará disponible cuando se enlace un ticket.',
+ ticketCreateFailed: 'No se pudo crear el ticket de Zammad', ticketRefreshed: 'Estado del ticket de Zammad actualizado',
+ linkExistingTicket: 'Enlazar ticket existente', linkExistingTicketConfirm: 'Enlaza un ticket de Zammad existente mediante su número.', ticketNumberLabel: 'Número de ticket', ticketNumberPlaceholder: 'Por ejemplo, 12345', ticketLinked: 'Ticket de Zammad enlazado', ticketLinkFailed: 'No se pudo enlazar el ticket de Zammad', editTicket: 'Editar ticket de Zammad', ticketUpdated: 'Ticket de Zammad actualizado', ticketUpdateFailed: 'No se pudo actualizar el ticket de Zammad',
+ ticketRefreshFailed: 'No se pudo actualizar el ticket de Zammad', retrySync: 'Reintentar sincronización', refreshTicket: 'Actualizar estado del ticket', syncFailedNoTicket: 'La sincronización falló. La actualización estará disponible cuando se haya enlazado un ticket de Zammad.', creationUncertainNoTicket: 'El resultado de la creación del ticket es incierto. Comprueba Zammad y enlaza el ticket existente por número si es necesario.', removeTicketLink: 'Eliminar asignación', removeTicketLinkConfirm: 'Se eliminarán la asignación a Zammad n.º {number} y el campo de correlación de Windshift. El ticket de Zammad permanecerá sin cambios.', ticketLinkRemoved: 'Asignación de ticket de Zammad eliminada', ticketLinkRemoveFailed: 'No se pudo eliminar la asignación de ticket de Zammad', ticketNumber: 'Zammad n.º {number}',
+ lastSynced: 'Sincronizado: {time}', syncState: { pending: 'Creación pendiente', creating: 'Creando ticket', linked: 'Ticket enlazado', sync_failed: 'Sincronización fallida', creation_uncertain: 'Resultado de creación incierto; los reintentos solo buscan el ticket existente' },
+} };
diff --git a/frontend/src/lib/locales/pt-BR/index.js b/frontend/src/lib/locales/pt-BR/index.js
index cae9c440f..4418b11dc 100644
--- a/frontend/src/lib/locales/pt-BR/index.js
+++ b/frontend/src/lib/locales/pt-BR/index.js
@@ -23,6 +23,7 @@ import supplemental from './supplemental.js';
import quality from './quality.js';
import adminOperations from './adminOperations.js';
import dashboard from './dashboard.js';
+import zammad from './zammad.js';
export default createLocale({
common,
@@ -45,4 +46,5 @@ export default createLocale({
quality,
adminOperations,
dashboard,
+ zammad,
});
diff --git a/frontend/src/lib/locales/pt-BR/zammad.js b/frontend/src/lib/locales/pt-BR/zammad.js
new file mode 100644
index 000000000..8828a0cf4
--- /dev/null
+++ b/frontend/src/lib/locales/pt-BR/zammad.js
@@ -0,0 +1,29 @@
+export default { zammad: {
+ tab: 'Zammad', connections: 'Conexões do Zammad',
+ connectionsDescription: 'Configure conexões seguras de tickets do Zammad limitadas por espaço de trabalho.',
+ addConnection: 'Adicionar conexão', editConnection: 'Editar conexão', noConnections: 'Nenhuma conexão do Zammad configurada',
+ loadConnectionsFailed: 'Não foi possível carregar as conexões do Zammad', connectionCreated: 'Conexão do Zammad criada',
+ connectionUpdated: 'Conexão do Zammad atualizada', saveConnectionFailed: 'Não foi possível salvar a conexão do Zammad',
+ connectionDeleted: 'Conexão do Zammad excluída', deleteConnection: 'Excluir conexão do Zammad',
+ deleteConnectionConfirm: 'Excluir “{name}”? Os vínculos de tickets existentes devem ser removidos primeiro.', deleteConnectionFailed: 'Não foi possível excluir a conexão do Zammad',
+ testConnection: 'Testar conexão', connectionTestSucceeded: 'Conexão do Zammad bem-sucedida', connectionTestFailed: 'Falha na conexão do Zammad',
+ metadataSummary: '{groups} grupos e {states} estados de ticket disponíveis', slug: 'Identificador', baseUrl: 'URL base', apiToken: 'Token da API',
+ apiTokenDescription: 'Use um token da API do Zammad para esta conexão do sistema.', apiTokenAvailable: 'Token da API disponível', apiTokenMissing: 'Token da API ausente',
+ authMethod: 'Método de autenticação', oauth: 'OAuth', oauthDescription: 'Autorize esta conexão com OAuth do Zammad.', oauthClientId: 'ID do cliente OAuth', oauthClientSecret: 'Segredo do cliente OAuth',
+ secretStored: 'Armazenado com segurança - deixe em branco para manter', oauthPending: 'Autorização OAuth pendente', oauthConnectedStatus: 'OAuth conectado', oauthReauthorizationRequired: 'É necessária nova autorização OAuth',
+ connectOAuth: 'Conectar ao Zammad', reauthorizeOAuth: 'Autorizar novamente', oauthConnected: 'OAuth do Zammad conectado', oauthConnectionFailed: 'Falha na autorização OAuth do Zammad', oauthAuthorizationFailed: 'Não foi possível iniciar a autorização OAuth do Zammad',
+ correlationFieldNotVerified: 'Permissões OAuth restritas impedem a verificação automática do campo de correlação. Confirme que ele existe no Zammad antes de criar tickets.',
+ defaultCustomer: 'Cliente padrão do ticket', defaultGroup: 'Grupo padrão', allowedGroups: 'Grupos de tickets permitidos', allowedGroupIds: 'IDs de grupos Zammad permitidos', correlationField: 'Campo de correlação',
+ correlationFieldHint: 'Crie este campo de texto nos tickets do Zammad para que novas tentativas encontrem um ticket já criado.',
+ completionStatus: 'Status de conclusão no Windshift', noAutomaticCompletion: 'Não concluir automaticamente', closedStates: 'Estados fechados do Zammad',
+ closedStateIds: 'IDs de estados fechados do Zammad', allWorkspaces: 'Permitir todos os espaços', allowedWorkspaces: 'Espaços permitidos',
+ tickets: 'Tickets do Zammad', noTickets: 'Nenhum ticket do Zammad vinculado ainda', createTicket: 'Criar ticket no Zammad',
+ createTicketConfirm: 'O título e a descrição serão enviados a esta conexão Zammad como nota interna. Confirme o grupo de destino.', connection: 'Conexão', group: 'Grupo', status: 'Status', owner: 'Responsável', unassignedOwner: 'Não atribuído', unknown: 'Desconhecido', notSynced: 'Ainda não sincronizado', leaveUnchanged: 'Manter inalterado',
+ connectionUnavailable: 'Uma ou mais conexões do Zammad precisam de autorização e ainda não podem ser usadas para ações de tickets.',
+ loadLinksFailed: 'Não foi possível carregar os vínculos de tickets do Zammad', loadMetadataFailed: 'Não foi possível carregar grupos e estados do Zammad', loadOwnersFailed: 'Não foi possível carregar os responsáveis do Zammad',
+ ticketCreated: 'Ticket do Zammad criado', ticketCreationStarted: 'A criação do ticket do Zammad já está em andamento', ticketCreationInProgress: 'A criação do ticket está em andamento. A atualização estará disponível quando um ticket for vinculado.',
+ ticketCreateFailed: 'Não foi possível criar o ticket do Zammad', ticketRefreshed: 'Status do ticket do Zammad atualizado',
+ linkExistingTicket: 'Vincular ticket existente', linkExistingTicketConfirm: 'Vincule um ticket existente do Zammad pelo número do ticket.', ticketNumberLabel: 'Número do ticket', ticketNumberPlaceholder: 'Por exemplo, 12345', ticketLinked: 'Ticket do Zammad vinculado', ticketLinkFailed: 'Não foi possível vincular o ticket do Zammad', editTicket: 'Editar ticket do Zammad', ticketUpdated: 'Ticket do Zammad atualizado', ticketUpdateFailed: 'Não foi possível atualizar o ticket do Zammad',
+ ticketRefreshFailed: 'Não foi possível atualizar o ticket do Zammad', retrySync: 'Tentar sincronização novamente', refreshTicket: 'Atualizar status do ticket', syncFailedNoTicket: 'A sincronização falhou. A atualização estará disponível após vincular um ticket do Zammad.', creationUncertainNoTicket: 'O resultado da criação do ticket é incerto. Verifique o Zammad e vincule o ticket existente pelo número se necessário.', removeTicketLink: 'Remover atribuição', removeTicketLinkConfirm: 'A atribuição ao Zammad nº {number} e o campo de correlação do Windshift serão removidos. O ticket do Zammad permanecerá inalterado.', ticketLinkRemoved: 'Atribuição de ticket do Zammad removida', ticketLinkRemoveFailed: 'Não foi possível remover a atribuição de ticket do Zammad', ticketNumber: 'Zammad nº {number}',
+ lastSynced: 'Sincronizado: {time}', syncState: { pending: 'Criação pendente', creating: 'Criando ticket', linked: 'Ticket vinculado', sync_failed: 'Falha na sincronização', creation_uncertain: 'Resultado da criação incerto; novas tentativas apenas procuram o ticket existente' },
+} };
diff --git a/frontend/src/lib/locales/zh-CN/index.js b/frontend/src/lib/locales/zh-CN/index.js
index 7dfbc0588..aa98e7b5a 100644
--- a/frontend/src/lib/locales/zh-CN/index.js
+++ b/frontend/src/lib/locales/zh-CN/index.js
@@ -19,6 +19,7 @@ import supplemental from './supplemental.js';
import quality from './quality.js';
import adminOperations from './adminOperations.js';
import dashboard from './dashboard.js';
+import zammad from './zammad.js';
export default createLocale({
common,
@@ -41,4 +42,5 @@ export default createLocale({
quality,
adminOperations,
dashboard,
+ zammad,
});
diff --git a/frontend/src/lib/locales/zh-CN/zammad.js b/frontend/src/lib/locales/zh-CN/zammad.js
new file mode 100644
index 000000000..3132b7aaf
--- /dev/null
+++ b/frontend/src/lib/locales/zh-CN/zammad.js
@@ -0,0 +1,23 @@
+export default { zammad: {
+ tab: 'Zammad', connections: 'Zammad 连接', connectionsDescription: '配置安全且受工作区范围限制的 Zammad 工单连接。',
+ addConnection: '添加连接', editConnection: '编辑连接', noConnections: '尚未配置 Zammad 连接', loadConnectionsFailed: '无法加载 Zammad 连接',
+ connectionCreated: '已创建 Zammad 连接', connectionUpdated: '已更新 Zammad 连接', saveConnectionFailed: '无法保存 Zammad 连接',
+ connectionDeleted: '已删除 Zammad 连接', deleteConnection: '删除 Zammad 连接', deleteConnectionConfirm: '删除“{name}”?必须先取消所有现有工单关联。',
+ deleteConnectionFailed: '无法删除 Zammad 连接', testConnection: '测试连接', connectionTestSucceeded: 'Zammad 连接测试成功', connectionTestFailed: 'Zammad 连接失败',
+ metadataSummary: '可用群组 {groups} 个,工单状态 {states} 个', slug: '短标识', baseUrl: '基础网址', apiToken: 'API 令牌',
+ apiTokenDescription: '对此系统连接使用 Zammad API 令牌。', apiTokenAvailable: 'API 令牌可用', apiTokenMissing: '缺少 API 令牌',
+ authMethod: '认证方式', oauth: 'OAuth', oauthDescription: '使用 Zammad OAuth 授权此连接。', oauthClientId: 'OAuth 客户端 ID', oauthClientSecret: 'OAuth 客户端密钥',
+ secretStored: '已安全保存 - 留空以保持不变', oauthPending: '等待 OAuth 授权', oauthConnectedStatus: 'OAuth 已连接', oauthReauthorizationRequired: '需要重新授权 OAuth',
+ connectOAuth: '连接 Zammad', reauthorizeOAuth: '再次授权', oauthConnected: 'Zammad OAuth 已连接', oauthConnectionFailed: 'Zammad OAuth 授权失败', oauthAuthorizationFailed: '无法开始 Zammad OAuth 授权',
+ correlationFieldNotVerified: '受限的 OAuth 权限无法自动验证关联字段。请在创建工单前确认该字段已存在于 Zammad 中。',
+ defaultCustomer: '默认工单客户', defaultGroup: '默认群组', allowedGroups: '允许的工单组', allowedGroupIds: '允许的 Zammad 组 ID', correlationField: '关联字段',
+ correlationFieldHint: '请在 Zammad 工单中创建此文本字段,以便重试时找到已创建的工单。', completionStatus: 'Windshift 完成状态',
+ noAutomaticCompletion: '不自动完成', closedStates: 'Zammad 已关闭状态', closedStateIds: 'Zammad 已关闭状态 ID',
+ allWorkspaces: '允许所有工作区', allowedWorkspaces: '允许的工作区', tickets: 'Zammad 工单', noTickets: '尚未链接 Zammad 工单',
+ createTicket: '创建 Zammad 工单', createTicketConfirm: '条目标题和描述将作为内部备注发送到此 Zammad 连接。请确认目标群组。', connection: '连接', group: '群组', status: '状态', owner: '负责人', unassignedOwner: '未分配', unknown: '未知', notSynced: '尚未同步', leaveUnchanged: '保持不变',
+ connectionUnavailable: '一个或多个 Zammad 连接需要授权,暂时不能用于工单操作。',
+ loadLinksFailed: '无法加载 Zammad 工单链接', loadMetadataFailed: '无法加载 Zammad 群组和状态', loadOwnersFailed: '无法加载 Zammad 负责人', ticketCreated: '已创建 Zammad 工单',
+ ticketCreationStarted: 'Zammad 工单创建已在进行中', ticketCreationInProgress: '工单正在创建。关联工单后才可以刷新。', ticketCreateFailed: '无法创建 Zammad 工单', linkExistingTicket: '关联现有工单', linkExistingTicketConfirm: '通过工单编号关联现有的 Zammad 工单。', ticketNumberLabel: '工单编号', ticketNumberPlaceholder: '例如,12345', ticketLinked: '已关联 Zammad 工单', ticketLinkFailed: '无法关联 Zammad 工单', editTicket: '编辑 Zammad 工单', ticketUpdated: '已更新 Zammad 工单', ticketUpdateFailed: '无法更新 Zammad 工单', ticketRefreshed: '已刷新 Zammad 工单状态',
+ ticketRefreshFailed: '无法刷新 Zammad 工单', retrySync: '重试同步', refreshTicket: '刷新工单状态', syncFailedNoTicket: '同步失败。关联 Zammad 工单后才可以刷新。', creationUncertainNoTicket: '工单创建结果不确定。请检查 Zammad,并在需要时通过编号关联现有工单。', removeTicketLink: '移除关联', removeTicketLinkConfirm: '将移除与 Zammad #{number} 的关联及 Windshift 关联字段。Zammad 工单本身不会被删除。', ticketLinkRemoved: '已移除 Zammad 工单关联', ticketLinkRemoveFailed: '无法移除 Zammad 工单关联', ticketNumber: 'Zammad #{number}', lastSynced: '同步于 {time}',
+ syncState: { pending: '等待创建工单', creating: '正在创建工单', linked: '工单已链接', sync_failed: '同步失败', creation_uncertain: '创建结果不确定;重试只会查找现有工单' },
+} };
diff --git a/frontend/src/lib/settings/IntegrationsManager.svelte b/frontend/src/lib/settings/IntegrationsManager.svelte
index 07e751054..34b428f38 100644
--- a/frontend/src/lib/settings/IntegrationsManager.svelte
+++ b/frontend/src/lib/settings/IntegrationsManager.svelte
@@ -2,17 +2,26 @@
// OAuth integrations share a page: outbound providers connect Windshift to
// external apps, while inbound clients authorize apps to mint user tokens.
+ import { onMount } from 'svelte';
import Tabs from '../components/Tabs.svelte';
import IntegrationProviderManager from './IntegrationProviderManager.svelte';
import OAuthClientManager from './OAuthClientManager.svelte';
- import { ArrowUpRight, ArrowDownLeft } from '@lucide/svelte';
+ import ZammadConnectionManager from './ZammadConnectionManager.svelte';
+ import { ArrowUpRight, ArrowDownLeft, TicketCheck } from '@lucide/svelte';
import { t } from '../stores/i18n.svelte.js';
let activeTab = $state('outbound');
+ onMount(() => {
+ if (new URLSearchParams(window.location.search).get('tab') === 'zammad') {
+ activeTab = 'zammad';
+ }
+ });
+
const tabs = $derived([
{ id: 'outbound', label: t('integrations.directions.outbound'), icon: ArrowUpRight },
{ id: 'inbound', label: t('integrations.directions.inbound'), icon: ArrowDownLeft },
+ { id: 'zammad', label: t('zammad.tab'), icon: TicketCheck },
]);
@@ -28,6 +37,8 @@
{t('integrations.directions.inboundDescription')}
+ {:else if activeTab === 'zammad'}
+
{/if}
diff --git a/frontend/src/lib/settings/ZammadConnectionManager.svelte b/frontend/src/lib/settings/ZammadConnectionManager.svelte
new file mode 100644
index 000000000..74fbf6666
--- /dev/null
+++ b/frontend/src/lib/settings/ZammadConnectionManager.svelte
@@ -0,0 +1,479 @@
+
+
+
+
+ {#snippet actions()}
+
+
+ {/snippet}
+
+
+ {#if error}
{/if}
+
+ {#if loading}
+
+ {:else if connections.length === 0}
+
+ {:else}
+
+ {#each connections as connection}
+ {@const connectionAuthStatus = authStatus(connection)}
+
+
+
+
+
{connection.name}
+
+ {connection.enabled ? t('common.enabled') : t('common.disabled')}
+
+ {connectionAuthStatus.label}
+
+
{connection.base_url}
+ {#if connection.last_test_error}
+
{connection.last_test_error}
+ {/if}
+
+
+
+ {#if connection.auth_method === 'oauth'}
+
+ {/if}
+
+
+
+
+ {#if metadataByConnection[connection.id]}
+
+ {t('zammad.metadataSummary', {
+ groups: metadataByConnection[connection.id].groups.length,
+ states: metadataByConnection[connection.id].states.length,
+ })}
+
+ {#if metadataByConnection[connection.id].correlation_field_verified === false}
+
{t('zammad.correlationFieldNotVerified')}
+ {/if}
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+
+ (showModal = false)} />
+
+
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index 761e52c17..e964ed0d8 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -8,6 +8,128 @@ import (
"slices"
)
+const zammadSchemaMigrationSQLite = `
+ CREATE TABLE zammad_connections (
+ provider_id TEXT PRIMARY KEY,
+ credential_id INTEGER NOT NULL,
+ base_url TEXT NOT NULL,
+ default_group_id INTEGER,
+ default_group_name TEXT DEFAULT '',
+ allowed_group_ids TEXT NOT NULL DEFAULT '[]',
+ default_customer TEXT NOT NULL,
+ correlation_field TEXT NOT NULL DEFAULT 'windshift_item_key',
+ closed_state_ids TEXT NOT NULL DEFAULT '[]',
+ completion_status_id INTEGER,
+ applies_to_all_workspaces BOOLEAN NOT NULL DEFAULT false,
+ last_tested_at DATETIME,
+ last_test_error TEXT DEFAULT '',
+ created_by INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (provider_id) REFERENCES integration_providers(id) ON DELETE CASCADE,
+ FOREIGN KEY (credential_id) REFERENCES action_credentials(id) ON DELETE RESTRICT,
+ FOREIGN KEY (completion_status_id) REFERENCES statuses(id) ON DELETE SET NULL,
+ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
+ );
+ CREATE INDEX idx_zammad_connections_credential ON zammad_connections(credential_id);
+ CREATE TABLE zammad_connection_workspaces (
+ provider_id TEXT NOT NULL,
+ workspace_id INTEGER NOT NULL,
+ PRIMARY KEY (provider_id, workspace_id),
+ FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE
+ );
+ CREATE INDEX idx_zammad_connection_workspaces_workspace ON zammad_connection_workspaces(workspace_id);
+ CREATE TABLE zammad_ticket_links (
+ id TEXT PRIMARY KEY,
+ item_id INTEGER NOT NULL,
+ provider_id TEXT NOT NULL,
+ item_integration_link_id TEXT,
+ ticket_id INTEGER,
+ ticket_number TEXT DEFAULT '',
+ ticket_url TEXT DEFAULT '',
+ group_id INTEGER,
+ group_name TEXT DEFAULT '',
+ correlation_key TEXT NOT NULL,
+ sync_state TEXT NOT NULL DEFAULT 'pending',
+ creating_started_at DATETIME,
+ last_status_id INTEGER,
+ last_status_name TEXT DEFAULT '',
+ last_synced_at DATETIME,
+ last_error TEXT DEFAULT '',
+ completion_applied BOOLEAN NOT NULL DEFAULT false,
+ sync_lock_until DATETIME,
+ created_by INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
+ FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ FOREIGN KEY (item_integration_link_id) REFERENCES item_integration_links(id) ON DELETE SET NULL,
+ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
+ UNIQUE(item_id, provider_id),
+ UNIQUE(provider_id, ticket_id),
+ UNIQUE(provider_id, correlation_key)
+ );
+ CREATE INDEX idx_zammad_ticket_links_item ON zammad_ticket_links(item_id);
+ CREATE INDEX idx_zammad_ticket_links_sync ON zammad_ticket_links(sync_state, last_synced_at);
+`
+
+const zammadSchemaMigrationPostgres = `
+ CREATE TABLE zammad_connections (
+ provider_id TEXT PRIMARY KEY REFERENCES integration_providers(id) ON DELETE CASCADE,
+ credential_id INTEGER NOT NULL REFERENCES action_credentials(id) ON DELETE RESTRICT,
+ base_url TEXT NOT NULL,
+ default_group_id INTEGER,
+ default_group_name TEXT DEFAULT '',
+ allowed_group_ids TEXT NOT NULL DEFAULT '[]',
+ default_customer TEXT NOT NULL,
+ correlation_field TEXT NOT NULL DEFAULT 'windshift_item_key',
+ closed_state_ids TEXT NOT NULL DEFAULT '[]',
+ completion_status_id INTEGER REFERENCES statuses(id) ON DELETE SET NULL,
+ applies_to_all_workspaces BOOLEAN NOT NULL DEFAULT false,
+ last_tested_at TIMESTAMPTZ,
+ last_test_error TEXT DEFAULT '',
+ created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+ );
+ CREATE INDEX idx_zammad_connections_credential ON zammad_connections(credential_id);
+ CREATE TABLE zammad_connection_workspaces (
+ provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ PRIMARY KEY (provider_id, workspace_id)
+ );
+ CREATE INDEX idx_zammad_connection_workspaces_workspace ON zammad_connection_workspaces(workspace_id);
+ CREATE TABLE zammad_ticket_links (
+ id TEXT PRIMARY KEY,
+ item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+ provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ item_integration_link_id TEXT REFERENCES item_integration_links(id) ON DELETE SET NULL,
+ ticket_id INTEGER,
+ ticket_number TEXT DEFAULT '',
+ ticket_url TEXT DEFAULT '',
+ group_id INTEGER,
+ group_name TEXT DEFAULT '',
+ correlation_key TEXT NOT NULL,
+ sync_state TEXT NOT NULL DEFAULT 'pending',
+ creating_started_at TIMESTAMPTZ,
+ last_status_id INTEGER,
+ last_status_name TEXT DEFAULT '',
+ last_synced_at TIMESTAMPTZ,
+ last_error TEXT DEFAULT '',
+ completion_applied BOOLEAN NOT NULL DEFAULT false,
+ sync_lock_until TIMESTAMPTZ,
+ created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ UNIQUE(item_id, provider_id),
+ UNIQUE(provider_id, ticket_id),
+ UNIQUE(provider_id, correlation_key)
+ );
+ CREATE INDEX idx_zammad_ticket_links_item ON zammad_ticket_links(item_id);
+ CREATE INDEX idx_zammad_ticket_links_sync ON zammad_ticket_links(sync_state, last_synced_at);
+`
+
// Migration is one entry in the schema_migrations catalog. Version is a
// stable slug used as the schema_migrations primary key; Name is a human
// label. CheckSQLite / CheckPostgres are queries that return COUNT >= 1
@@ -499,6 +621,108 @@ var Catalog = []Migration{
Postgres: `ALTER TABLE sso_providers ALTER COLUMN attribute_mapping SET DEFAULT '{"email":"email","name":"name","given_name":"given_name","family_name":"family_name","username":"preferred_username","email_verified":"email_verified"}'`,
ApplySQLite: applySQLiteSSOAttributeMappingDefault,
},
+ {
+ Version: "20260829_zammad_integration",
+ Name: "Add Zammad connections and durable ticket links",
+ CheckSQLite: sqliteTableCheck("zammad_ticket_links"),
+ CheckPostgres: pgTableCheck("zammad_ticket_links"),
+ SQLite: zammadSchemaMigrationSQLite,
+ Postgres: zammadSchemaMigrationPostgres,
+ },
+ {
+ Version: "20260830_zammad_oauth_connections",
+ Name: "Add connection-scoped Zammad OAuth credentials",
+ CheckSQLite: sqliteTableCheck("zammad_oauth_tokens"),
+ CheckPostgres: pgTableCheck("zammad_oauth_tokens"),
+ SQLite: `
+ ALTER TABLE zammad_connections ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'api_token';
+ CREATE TABLE zammad_oauth_tokens (
+ provider_id TEXT PRIMARY KEY REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ expires_at DATETIME NOT NULL,
+ reauthorization_required BOOLEAN NOT NULL DEFAULT false, refresh_lock_until DATETIME, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE zammad_oauth_state (
+ state TEXT PRIMARY KEY, provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ initiated_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE INDEX idx_zammad_oauth_state_expires ON zammad_oauth_state(expires_at);
+ `,
+ Postgres: `
+ ALTER TABLE zammad_connections ADD COLUMN IF NOT EXISTS auth_method TEXT NOT NULL DEFAULT 'api_token';
+ CREATE TABLE zammad_oauth_tokens (
+ provider_id TEXT PRIMARY KEY REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ expires_at TIMESTAMPTZ NOT NULL,
+ reauthorization_required BOOLEAN NOT NULL DEFAULT false, refresh_lock_until TIMESTAMPTZ, updated_at TIMESTAMPTZ DEFAULT NOW()
+ );
+ CREATE TABLE zammad_oauth_state (
+ state TEXT PRIMARY KEY, provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ initiated_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW()
+ );
+ CREATE INDEX idx_zammad_oauth_state_expires ON zammad_oauth_state(expires_at);
+ `,
+ },
+ {
+ Version: "20260830_zammad_oauth_generation",
+ Name: "Bind Zammad OAuth writes to connection generations and refresh claims",
+ CheckSQLite: sqliteColumnCheck("zammad_connections", "oauth_generation"),
+ CheckPostgres: pgColumnCheck("zammad_connections", "oauth_generation"),
+ SQLite: `
+ ALTER TABLE zammad_connections ADD COLUMN oauth_generation INTEGER NOT NULL DEFAULT 1;
+ ALTER TABLE zammad_connections ADD COLUMN oauth_attempt_id TEXT;
+ ALTER TABLE zammad_oauth_tokens ADD COLUMN oauth_generation INTEGER NOT NULL DEFAULT 1;
+ ALTER TABLE zammad_oauth_tokens ADD COLUMN refresh_claim_owner TEXT;
+ ALTER TABLE zammad_oauth_state ADD COLUMN oauth_generation INTEGER NOT NULL DEFAULT 1;
+ DELETE FROM zammad_oauth_state;
+ CREATE UNIQUE INDEX idx_zammad_oauth_state_provider ON zammad_oauth_state(provider_id);
+ `,
+ Postgres: `
+ ALTER TABLE zammad_connections ADD COLUMN IF NOT EXISTS oauth_generation BIGINT NOT NULL DEFAULT 1;
+ ALTER TABLE zammad_connections ADD COLUMN IF NOT EXISTS oauth_attempt_id TEXT;
+ ALTER TABLE zammad_oauth_tokens ADD COLUMN IF NOT EXISTS oauth_generation BIGINT NOT NULL DEFAULT 1;
+ ALTER TABLE zammad_oauth_tokens ADD COLUMN IF NOT EXISTS refresh_claim_owner TEXT;
+ ALTER TABLE zammad_oauth_state ADD COLUMN IF NOT EXISTS oauth_generation BIGINT NOT NULL DEFAULT 1;
+ DELETE FROM zammad_oauth_state;
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_oauth_state_provider ON zammad_oauth_state(provider_id);
+ `,
+ },
+ {
+ Version: "20260830_zammad_ticket_link_metadata",
+ Name: "Add Zammad ticket ownership and retry metadata",
+ CheckSQLite: `
+ SELECT CASE WHEN COUNT(*) = 4 THEN 1 ELSE 0 END
+ FROM pragma_table_info('zammad_ticket_links')
+ WHERE name IN ('owner_id', 'owner_name', 'last_attempt_at', 'next_attempt_at')
+ `,
+ CheckPostgres: `
+ SELECT CASE WHEN COUNT(*) = 4 THEN 1 ELSE 0 END
+ FROM information_schema.columns
+ WHERE table_schema = current_schema()
+ AND table_name = 'zammad_ticket_links'
+ AND column_name IN ('owner_id', 'owner_name', 'last_attempt_at', 'next_attempt_at')
+ `,
+ SQLite: `
+ ALTER TABLE zammad_ticket_links ADD COLUMN owner_id INTEGER;
+ ALTER TABLE zammad_ticket_links ADD COLUMN owner_name TEXT DEFAULT '';
+ ALTER TABLE zammad_ticket_links ADD COLUMN last_attempt_at DATETIME;
+ ALTER TABLE zammad_ticket_links ADD COLUMN next_attempt_at DATETIME;
+ `,
+ Postgres: `
+ ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS owner_id INTEGER;
+ ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS owner_name TEXT DEFAULT '';
+ ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMPTZ;
+ ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ;
+ `,
+ },
+ {
+ Version: "20260830_zammad_ticket_link_completion_postgres",
+ Name: "Backfill missing PostgreSQL Zammad completion marker",
+ CheckSQLite: `SELECT 1`,
+ CheckPostgres: pgColumnCheck("zammad_ticket_links", "completion_applied"),
+ SQLite: `SELECT 1;`,
+ Postgres: `
+ ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS completion_applied BOOLEAN NOT NULL DEFAULT false;
+ `,
+ },
}
func applySQLiteSSOAttributeMappingDefault(db Database) (retErr error) {
diff --git a/internal/database/schema/integrations.sql b/internal/database/schema/integrations.sql
index bae1f5d71..5018e7144 100644
--- a/internal/database/schema/integrations.sql
+++ b/internal/database/schema/integrations.sql
@@ -135,4 +135,111 @@ CREATE INDEX IF NOT EXISTS idx_todoist_task_links_user ON todoist_task_links(use
CREATE INDEX IF NOT EXISTS idx_todoist_task_links_item ON todoist_task_links(item_id);
CREATE INDEX IF NOT EXISTS idx_todoist_task_links_todoist ON todoist_task_links(todoist_task_id);
+-- Zammad provider extension. Provider identity and visible item links remain
+-- in the generic integration tables; these tables hold only Zammad-specific
+-- connection policy and durable synchronization state.
+CREATE TABLE IF NOT EXISTS zammad_connections (
+ provider_id TEXT PRIMARY KEY,
+ credential_id INTEGER NOT NULL,
+ auth_method TEXT NOT NULL DEFAULT 'api_token',
+ oauth_generation INTEGER NOT NULL DEFAULT 1,
+ oauth_attempt_id TEXT,
+ base_url TEXT NOT NULL,
+ default_group_id INTEGER,
+ default_group_name TEXT DEFAULT '',
+ allowed_group_ids TEXT NOT NULL DEFAULT '[]',
+ default_customer TEXT NOT NULL,
+ correlation_field TEXT NOT NULL DEFAULT 'windshift_item_key',
+ closed_state_ids TEXT NOT NULL DEFAULT '[]',
+ completion_status_id INTEGER,
+ applies_to_all_workspaces BOOLEAN NOT NULL DEFAULT false,
+ last_tested_at DATETIME,
+ last_test_error TEXT DEFAULT '',
+ created_by INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (provider_id) REFERENCES integration_providers(id) ON DELETE CASCADE,
+ FOREIGN KEY (credential_id) REFERENCES action_credentials(id) ON DELETE RESTRICT,
+ FOREIGN KEY (completion_status_id) REFERENCES statuses(id) ON DELETE SET NULL,
+ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_zammad_connections_credential ON zammad_connections(credential_id);
+
+-- Connection-scoped OAuth material. Zammad is a system/workspace integration,
+-- so these tokens intentionally never use user_integration_tokens.
+CREATE TABLE IF NOT EXISTS zammad_oauth_tokens (
+ provider_id TEXT PRIMARY KEY,
+ oauth_generation INTEGER NOT NULL,
+ expires_at DATETIME NOT NULL,
+ reauthorization_required BOOLEAN NOT NULL DEFAULT false,
+ refresh_lock_until DATETIME,
+ refresh_claim_owner TEXT,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS zammad_oauth_state (
+ state TEXT PRIMARY KEY,
+ provider_id TEXT NOT NULL,
+ initiated_by INTEGER NOT NULL,
+ oauth_generation INTEGER NOT NULL,
+ expires_at DATETIME NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ FOREIGN KEY (initiated_by) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_zammad_oauth_state_expires ON zammad_oauth_state(expires_at);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_oauth_state_provider ON zammad_oauth_state(provider_id);
+
+CREATE TABLE IF NOT EXISTS zammad_connection_workspaces (
+ provider_id TEXT NOT NULL,
+ workspace_id INTEGER NOT NULL,
+ PRIMARY KEY (provider_id, workspace_id),
+ FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_zammad_connection_workspaces_workspace ON zammad_connection_workspaces(workspace_id);
+
+CREATE TABLE IF NOT EXISTS zammad_ticket_links (
+ id TEXT PRIMARY KEY,
+ item_id INTEGER NOT NULL,
+ provider_id TEXT NOT NULL,
+ item_integration_link_id TEXT,
+ ticket_id INTEGER,
+ ticket_number TEXT DEFAULT '',
+ ticket_url TEXT DEFAULT '',
+ group_id INTEGER,
+ group_name TEXT DEFAULT '',
+ owner_id INTEGER,
+ owner_name TEXT DEFAULT '',
+ correlation_key TEXT NOT NULL,
+ sync_state TEXT NOT NULL DEFAULT 'pending',
+ creating_started_at DATETIME,
+ last_status_id INTEGER,
+ last_status_name TEXT DEFAULT '',
+ last_synced_at DATETIME,
+ last_attempt_at DATETIME,
+ next_attempt_at DATETIME,
+ last_error TEXT DEFAULT '',
+ completion_applied BOOLEAN NOT NULL DEFAULT false,
+ sync_lock_until DATETIME,
+ created_by INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
+ FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ FOREIGN KEY (item_integration_link_id) REFERENCES item_integration_links(id) ON DELETE SET NULL,
+ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
+ UNIQUE(item_id, provider_id),
+ UNIQUE(provider_id, ticket_id),
+ UNIQUE(provider_id, correlation_key)
+);
+
+CREATE INDEX IF NOT EXISTS idx_zammad_ticket_links_item ON zammad_ticket_links(item_id);
+CREATE INDEX IF NOT EXISTS idx_zammad_ticket_links_sync ON zammad_ticket_links(sync_state, last_synced_at);
+
-- migration: 20260615_todoist_sync_tables
+-- migration: 20260829_zammad_integration
diff --git a/internal/database/schema/integrations_postgres.sql b/internal/database/schema/integrations_postgres.sql
index d0d92a6a2..e9a9a7400 100644
--- a/internal/database/schema/integrations_postgres.sql
+++ b/internal/database/schema/integrations_postgres.sql
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS todoist_sync_config (
sync_token TEXT DEFAULT '*',
last_synced_at TIMESTAMPTZ,
last_error TEXT DEFAULT '',
+ completion_applied BOOLEAN NOT NULL DEFAULT false,
sync_lock_until TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
@@ -119,3 +120,93 @@ CREATE TABLE IF NOT EXISTS todoist_task_links (
CREATE INDEX IF NOT EXISTS idx_todoist_task_links_user ON todoist_task_links(user_id);
CREATE INDEX IF NOT EXISTS idx_todoist_task_links_item ON todoist_task_links(item_id);
CREATE INDEX IF NOT EXISTS idx_todoist_task_links_todoist ON todoist_task_links(todoist_task_id);
+
+CREATE TABLE IF NOT EXISTS zammad_connections (
+ provider_id TEXT PRIMARY KEY REFERENCES integration_providers(id) ON DELETE CASCADE,
+ credential_id INTEGER NOT NULL REFERENCES action_credentials(id) ON DELETE RESTRICT,
+ auth_method TEXT NOT NULL DEFAULT 'api_token',
+ oauth_generation BIGINT NOT NULL DEFAULT 1,
+ oauth_attempt_id TEXT,
+ base_url TEXT NOT NULL,
+ default_group_id INTEGER,
+ default_group_name TEXT DEFAULT '',
+ allowed_group_ids TEXT NOT NULL DEFAULT '[]',
+ default_customer TEXT NOT NULL,
+ correlation_field TEXT NOT NULL DEFAULT 'windshift_item_key',
+ closed_state_ids TEXT NOT NULL DEFAULT '[]',
+ completion_status_id INTEGER REFERENCES statuses(id) ON DELETE SET NULL,
+ applies_to_all_workspaces BOOLEAN NOT NULL DEFAULT false,
+ last_tested_at TIMESTAMPTZ,
+ last_test_error TEXT DEFAULT '',
+ created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_zammad_connections_credential ON zammad_connections(credential_id);
+
+CREATE TABLE IF NOT EXISTS zammad_oauth_tokens (
+ provider_id TEXT PRIMARY KEY REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ oauth_generation BIGINT NOT NULL,
+ expires_at TIMESTAMPTZ NOT NULL,
+ reauthorization_required BOOLEAN NOT NULL DEFAULT false,
+ refresh_lock_until TIMESTAMPTZ,
+ refresh_claim_owner TEXT,
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS zammad_oauth_state (
+ state TEXT PRIMARY KEY,
+ provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ initiated_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ oauth_generation BIGINT NOT NULL,
+ expires_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_zammad_oauth_state_expires ON zammad_oauth_state(expires_at);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_oauth_state_provider ON zammad_oauth_state(provider_id);
+
+CREATE TABLE IF NOT EXISTS zammad_connection_workspaces (
+ provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ PRIMARY KEY (provider_id, workspace_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_zammad_connection_workspaces_workspace ON zammad_connection_workspaces(workspace_id);
+
+CREATE TABLE IF NOT EXISTS zammad_ticket_links (
+ id TEXT PRIMARY KEY,
+ item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+ provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ item_integration_link_id TEXT REFERENCES item_integration_links(id) ON DELETE SET NULL,
+ ticket_id INTEGER,
+ ticket_number TEXT DEFAULT '',
+ ticket_url TEXT DEFAULT '',
+ group_id INTEGER,
+ group_name TEXT DEFAULT '',
+ owner_id INTEGER,
+ owner_name TEXT DEFAULT '',
+ correlation_key TEXT NOT NULL,
+ sync_state TEXT NOT NULL DEFAULT 'pending',
+ creating_started_at TIMESTAMPTZ,
+ last_status_id INTEGER,
+ last_status_name TEXT DEFAULT '',
+ last_synced_at TIMESTAMPTZ,
+ last_attempt_at TIMESTAMPTZ,
+ next_attempt_at TIMESTAMPTZ,
+ last_error TEXT DEFAULT '',
+ completion_applied BOOLEAN NOT NULL DEFAULT false,
+ sync_lock_until TIMESTAMPTZ,
+ created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ UNIQUE(item_id, provider_id),
+ UNIQUE(provider_id, ticket_id),
+ UNIQUE(provider_id, correlation_key)
+);
+
+CREATE INDEX IF NOT EXISTS idx_zammad_ticket_links_item ON zammad_ticket_links(item_id);
+CREATE INDEX IF NOT EXISTS idx_zammad_ticket_links_sync ON zammad_ticket_links(sync_state, last_synced_at);
+
+-- migration: 20260829_zammad_integration
diff --git a/internal/database/zammad_schema_test.go b/internal/database/zammad_schema_test.go
new file mode 100644
index 000000000..07866fd88
--- /dev/null
+++ b/internal/database/zammad_schema_test.go
@@ -0,0 +1,241 @@
+package database
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestZammadSchemaInitializesOnSQLite(t *testing.T) {
+ db, err := NewSQLiteDB(filepath.Join(t.TempDir(), "windshift-test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = db.Close() }()
+ if err := db.Initialize(); err != nil {
+ t.Fatalf("initialize SQLite schema: %v", err)
+ }
+
+ for _, table := range []string{"zammad_connections", "zammad_connection_workspaces", "zammad_ticket_links", "zammad_oauth_tokens", "zammad_oauth_state"} {
+ var count int
+ if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatalf("expected table %s", table)
+ }
+ }
+
+ for _, version := range []string{"20260829_zammad_integration", "20260830_zammad_oauth_connections", "20260830_zammad_oauth_generation", "20260830_zammad_ticket_link_metadata", "20260830_zammad_ticket_link_completion_postgres"} {
+ var migrationCount int
+ if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version=?", version).Scan(&migrationCount); err != nil {
+ t.Fatal(err)
+ }
+ if migrationCount != 1 {
+ t.Fatalf("Zammad migration %s was not stamped", version)
+ }
+ }
+ rows, err := db.Query("PRAGMA table_info(zammad_connections)")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = rows.Close() }()
+ var requiredCredential, authMethod, oauthGeneration, oauthAttempt bool
+ for rows.Next() {
+ var cid int
+ var name, columnType string
+ var notNull, primaryKey int
+ var defaultValue any
+ if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
+ t.Fatal(err)
+ }
+ if name == "credential_id" {
+ requiredCredential = notNull == 1
+ }
+ if name == "auth_method" {
+ authMethod = true
+ }
+ if name == "oauth_generation" {
+ oauthGeneration = notNull == 1
+ }
+ if name == "oauth_attempt_id" {
+ oauthAttempt = true
+ }
+ }
+ if !requiredCredential || !authMethod || !oauthGeneration || !oauthAttempt {
+ t.Fatal("Zammad OAuth connection schema must retain the legacy managed-credential constraint")
+ }
+ for table, columns := range map[string][]string{
+ "zammad_oauth_tokens": {"oauth_generation", "refresh_claim_owner"},
+ "zammad_oauth_state": {"oauth_generation"},
+ } {
+ for _, column := range columns {
+ var count int
+ if err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?", table, column).Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatalf("expected %s.%s", table, column)
+ }
+ }
+ }
+ var stateProviderIndex int
+ if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_zammad_oauth_state_provider'").Scan(&stateProviderIndex); err != nil || stateProviderIndex != 1 {
+ t.Fatalf("expected unique per-connection OAuth state index: count=%d err=%v", stateProviderIndex, err)
+ }
+}
+
+func TestZammadTicketLinkMetadataSQLiteUpgrade(t *testing.T) {
+ db, err := NewSQLiteDB(filepath.Join(t.TempDir(), "legacy-zammad-links.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = db.Close() }()
+ if err := db.Initialize(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Recreate the stamped baseline table without the metadata columns. This
+ // models an existing install whose 20260829 migration already ran.
+ if _, err := db.Exec("PRAGMA foreign_keys=OFF"); err != nil {
+ t.Fatal(err)
+ }
+ _, err = db.Exec(`
+ CREATE TABLE zammad_ticket_links_legacy (
+ id TEXT PRIMARY KEY, item_id INTEGER NOT NULL, provider_id TEXT NOT NULL,
+ item_integration_link_id TEXT, ticket_id INTEGER, ticket_number TEXT DEFAULT '',
+ ticket_url TEXT DEFAULT '', group_id INTEGER, group_name TEXT DEFAULT '',
+ correlation_key TEXT NOT NULL, sync_state TEXT NOT NULL DEFAULT 'pending',
+ creating_started_at DATETIME, last_status_id INTEGER, last_status_name TEXT DEFAULT '',
+ last_synced_at DATETIME, last_error TEXT DEFAULT '',
+ completion_applied BOOLEAN NOT NULL DEFAULT false, sync_lock_until DATETIME,
+ created_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(item_id, provider_id), UNIQUE(provider_id, ticket_id),
+ UNIQUE(provider_id, correlation_key)
+ );
+ DROP TABLE zammad_ticket_links;
+ ALTER TABLE zammad_ticket_links_legacy RENAME TO zammad_ticket_links;
+ DELETE FROM schema_migrations WHERE version = '20260830_zammad_ticket_link_metadata';
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
+ t.Fatal(err)
+ }
+ if err := runPendingMigrations(db, Catalog); err != nil {
+ t.Fatalf("run additive ticket-link metadata migration: %v", err)
+ }
+
+ for _, column := range []string{"owner_id", "owner_name", "last_attempt_at", "next_attempt_at", "completion_applied"} {
+ var count int
+ if err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('zammad_ticket_links') WHERE name = ?", column).Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatalf("expected migrated zammad_ticket_links.%s", column)
+ }
+ }
+}
+
+func TestZammadTicketLinkMetadataMigrationBackendParity(t *testing.T) {
+ var migration *Migration
+ for i := range Catalog {
+ if Catalog[i].Version == "20260830_zammad_ticket_link_metadata" {
+ migration = &Catalog[i]
+ break
+ }
+ }
+ if migration == nil {
+ t.Fatal("Zammad ticket-link metadata migration is missing")
+ }
+ for _, column := range []string{"owner_id", "owner_name", "last_attempt_at", "next_attempt_at"} {
+ if !strings.Contains(migration.CheckSQLite, column) || !strings.Contains(migration.CheckPostgres, column) {
+ t.Fatalf("metadata column %s is missing from one of the backend checks", column)
+ }
+ if !strings.Contains(migration.SQLite, "ADD COLUMN "+column) || !strings.Contains(migration.Postgres, "ADD COLUMN IF NOT EXISTS "+column) {
+ t.Fatalf("metadata column %s is missing from one of the backend migration bodies", column)
+ }
+ }
+ if !strings.Contains(zammadSchemaMigrationSQLite, "completion_applied") || !strings.Contains(zammadSchemaMigrationPostgres, "completion_applied") {
+ t.Fatal("baseline Zammad migrations must include completion_applied")
+ }
+ var completionMigration *Migration
+ for i := range Catalog {
+ if Catalog[i].Version == "20260830_zammad_ticket_link_completion_postgres" {
+ completionMigration = &Catalog[i]
+ break
+ }
+ }
+ if completionMigration == nil || !strings.Contains(completionMigration.Postgres, "ADD COLUMN IF NOT EXISTS completion_applied") {
+ t.Fatal("PostgreSQL completion_applied backfill migration is missing")
+ }
+}
+
+// This starts from the deployed 20260829 table shape. The OAuth migration is
+// additive: it preserves an API-token connection and permits a new OAuth row
+// with its mandatory managed-credential container.
+func TestZammadOAuthSQLiteUpgradePreservesLegacyConnection(t *testing.T) {
+ db, err := NewSQLiteDB(filepath.Join(t.TempDir(), "legacy-zammad.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = db.Close() }()
+ if err := db.Initialize(); err != nil {
+ t.Fatal(err)
+ }
+ for _, statement := range []string{
+ `INSERT INTO users(id, email, username, first_name, last_name) VALUES (9001, 'zammad-upgrade@example.test', 'zammad-upgrade', 'Zammad', 'Upgrade')`,
+ `INSERT INTO action_credentials(id, name, credential_type, applies_to_all_workspaces, encrypted_secret, is_enabled) VALUES (9001, 'legacy API', 'custom_header', true, 'opaque-ciphertext', true)`,
+ `INSERT INTO integration_providers(id, slug, name, provider_type, enabled, provider_config) VALUES ('legacy-zammad', 'legacy-zammad', 'Legacy Zammad', 'zammad', true, '{}')`,
+ `INSERT INTO zammad_connections(provider_id, credential_id, auth_method, base_url, allowed_group_ids, default_customer, closed_state_ids, applies_to_all_workspaces, created_by) VALUES ('legacy-zammad', 9001, 'api_token', 'https://legacy.example.test', '[]', 'robot@example.test', '[]', true, 9001)`,
+ } {
+ if _, err := db.ExecWrite(statement); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := db.Exec("PRAGMA foreign_keys=OFF"); err != nil {
+ t.Fatal(err)
+ }
+ _, err = db.Exec(`
+ CREATE TABLE zammad_connections_legacy (
+ provider_id TEXT PRIMARY KEY, credential_id INTEGER NOT NULL, base_url TEXT NOT NULL, default_group_id INTEGER,
+ default_group_name TEXT DEFAULT '', allowed_group_ids TEXT NOT NULL DEFAULT '[]', default_customer TEXT NOT NULL,
+ correlation_field TEXT NOT NULL DEFAULT 'windshift_item_key', closed_state_ids TEXT NOT NULL DEFAULT '[]', completion_status_id INTEGER,
+ applies_to_all_workspaces BOOLEAN NOT NULL DEFAULT false, last_tested_at DATETIME, last_test_error TEXT DEFAULT '', created_by INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ INSERT INTO zammad_connections_legacy(provider_id, credential_id, base_url, default_group_id, default_group_name, allowed_group_ids, default_customer, correlation_field, closed_state_ids, completion_status_id, applies_to_all_workspaces, last_tested_at, last_test_error, created_by, created_at, updated_at)
+ SELECT provider_id, credential_id, base_url, default_group_id, default_group_name, allowed_group_ids, default_customer, correlation_field, closed_state_ids, completion_status_id, applies_to_all_workspaces, last_tested_at, last_test_error, created_by, created_at, updated_at FROM zammad_connections;
+ DROP TABLE zammad_connections;
+ ALTER TABLE zammad_connections_legacy RENAME TO zammad_connections;
+ DROP TABLE zammad_oauth_state;
+ DROP TABLE zammad_oauth_tokens;
+ DELETE FROM schema_migrations WHERE version = '20260830_zammad_oauth_connections';
+ DELETE FROM schema_migrations WHERE version = '20260830_zammad_oauth_generation';
+ `)
+ if _, onErr := db.Exec("PRAGMA foreign_keys=ON"); onErr != nil {
+ t.Fatal(onErr)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := runPendingMigrations(db, Catalog); err != nil {
+ t.Fatalf("run additive OAuth migration: %v", err)
+ }
+ var credentialID int
+ var authMethod string
+ if err := db.QueryRow(`SELECT credential_id, auth_method FROM zammad_connections WHERE provider_id='legacy-zammad'`).Scan(&credentialID, &authMethod); err != nil || credentialID != 9001 || authMethod != "api_token" {
+ t.Fatalf("legacy API connection was not preserved: credential=%d method=%q err=%v", credentialID, authMethod, err)
+ }
+ for _, statement := range []string{
+ `INSERT INTO action_credentials(id, name, credential_type, applies_to_all_workspaces, encrypted_secret, is_enabled) VALUES (9002, 'OAuth pending', 'custom_header', true, 'opaque-pending-ciphertext', true)`,
+ `INSERT INTO integration_providers(id, slug, name, provider_type, enabled, oauth_client_id, oauth_client_secret_encrypted, provider_config) VALUES ('oauth-zammad', 'oauth-zammad', 'OAuth Zammad', 'zammad', true, 'client', 'opaque-ciphertext', '{}')`,
+ `INSERT INTO zammad_connections(provider_id, credential_id, auth_method, base_url, allowed_group_ids, default_customer, closed_state_ids, applies_to_all_workspaces, created_by) VALUES ('oauth-zammad', 9002, 'oauth', 'https://oauth.example.test', '[]', 'robot@example.test', '[]', true, 9001)`,
+ } {
+ if _, err := db.ExecWrite(statement); err != nil {
+ t.Fatalf("upgraded schema rejected OAuth pending managed credential: %v", err)
+ }
+ }
+}
diff --git a/internal/handlers/integration_item_links.go b/internal/handlers/integration_item_links.go
index 3c97fbee2..5b6fa7f38 100644
--- a/internal/handlers/integration_item_links.go
+++ b/internal/handlers/integration_item_links.go
@@ -86,7 +86,7 @@ func (h *IntegrationItemLinksHandler) GetItemLinks(w http.ResponseWriter, r *htt
ip.name AS provider_name, ip.provider_type
FROM item_integration_links iil
JOIN integration_providers ip ON ip.id = iil.integration_provider_id
- WHERE iil.item_id = ?
+ WHERE iil.item_id = ? AND ip.provider_type != 'zammad'
ORDER BY iil.created_at DESC
`, itemID)
if err != nil {
@@ -172,7 +172,7 @@ func (h *IntegrationItemLinksHandler) CreateItemLink(w http.ResponseWriter, r *h
// Verify provider exists and is enabled
var providerExists bool
- err := h.db.QueryRow("SELECT EXISTS(SELECT 1 FROM integration_providers WHERE id = ? AND enabled = true)", req.ProviderID).Scan(&providerExists)
+ err := h.db.QueryRow("SELECT EXISTS(SELECT 1 FROM integration_providers WHERE id = ? AND enabled = true AND provider_type IN ('notion', 'todoist'))", req.ProviderID).Scan(&providerExists)
if err != nil || !providerExists {
respondBadRequest(w, r, "Integration provider not found or disabled")
return
@@ -234,6 +234,10 @@ func (h *IntegrationItemLinksHandler) DeleteItemLink(w http.ResponseWriter, r *h
respondInternalError(w, r, fmt.Errorf("invalid item id on integration link: %w", err))
return
}
+ if link.ProviderType == string(models.IntegrationProviderZammad) {
+ respondNotFound(w, r, "integration_link")
+ return
+ }
if !CheckItemPermission(w, r, repository.NewItemRepository(h.db), h.permissionService, itemID, models.PermissionItemEdit) {
return
@@ -245,7 +249,6 @@ func (h *IntegrationItemLinksHandler) DeleteItemLink(w http.ResponseWriter, r *h
respondInternalError(w, r, err)
return
}
-
h.auditItemLink(r, user, logger.ActionIntegrationItemLinkDelete, link)
w.WriteHeader(http.StatusNoContent)
}
@@ -276,6 +279,10 @@ func (h *IntegrationItemLinksHandler) RefreshItemLink(w http.ResponseWriter, r *
}
return
}
+ if providerType == models.IntegrationProviderZammad {
+ respondNotFound(w, r, "integration_link")
+ return
+ }
if !CheckItemPermission(w, r, repository.NewItemRepository(h.db), h.permissionService, itemID, models.PermissionItemView) {
return
diff --git a/internal/handlers/integration_oauth.go b/internal/handlers/integration_oauth.go
index 7bcf93ebe..0f249f34f 100644
--- a/internal/handlers/integration_oauth.go
+++ b/internal/handlers/integration_oauth.go
@@ -55,6 +55,7 @@ func (h *IntegrationOAuthHandler) StartOAuth(w http.ResponseWriter, r *http.Requ
err := h.db.QueryRow(`
SELECT id, provider_type, oauth_client_id
FROM integration_providers WHERE slug = ? AND enabled = true
+ AND provider_type IN ('notion', 'todoist')
`, slug).Scan(&providerID, &providerType, &clientID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -364,7 +365,7 @@ func (h *IntegrationOAuthHandler) GetAvailableProviders(w http.ResponseWriter, r
rows, err := h.db.Query(`
SELECT id, slug, name, provider_type
FROM integration_providers
- WHERE enabled = true
+ WHERE enabled = true AND provider_type IN ('notion', 'todoist')
ORDER BY name
`)
if err != nil {
diff --git a/internal/handlers/zammad.go b/internal/handlers/zammad.go
new file mode 100644
index 000000000..846105e6b
--- /dev/null
+++ b/internal/handlers/zammad.go
@@ -0,0 +1,418 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "windshift/internal/integrations/zammad"
+ "windshift/internal/logger"
+ "windshift/internal/models"
+ "windshift/internal/repository"
+ "windshift/internal/restapi"
+ "windshift/internal/services"
+ "windshift/internal/utils"
+)
+
+type ZammadHandler struct {
+ itemRepo *repository.ItemRepository
+ service *services.ZammadService
+ permissionService *services.PermissionService
+ auditor *logger.Auditor
+ publicBaseURL string
+}
+
+func NewZammadHandler(itemRepo *repository.ItemRepository, service *services.ZammadService, permissionService *services.PermissionService, auditor *logger.Auditor) *ZammadHandler {
+ return &ZammadHandler{itemRepo: itemRepo, service: service, permissionService: permissionService, auditor: auditor}
+}
+
+func (h *ZammadHandler) SetOAuthBaseURL(baseURL string) { h.publicBaseURL = baseURL }
+
+// StartOAuth is protected by the system-admin route. State binding and the
+// fixed callback URI are enforced in the service, not delegated to clients.
+func (h *ZammadHandler) StartOAuth(w http.ResponseWriter, r *http.Request) {
+ user, ok := RequireAuth(w, r)
+ if !ok {
+ return
+ }
+ authURL, err := h.service.StartOAuth(r.Context(), r.PathValue("id"), user.ID, h.publicBaseURL)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, user, logger.ActionZammadConnectionUpdate, logger.ResourceZammadConnection, r.PathValue("id"), "", map[string]any{"oauth_started": true})
+ respondJSONOK(w, map[string]string{"auth_url": authURL})
+}
+
+// OAuthCallback intentionally has no session requirement: the unguessable,
+// short-lived state is atomically consumed and carries its initiating admin.
+func (h *ZammadHandler) OAuthCallback(w http.ResponseWriter, r *http.Request) {
+ state, code := r.URL.Query().Get("state"), r.URL.Query().Get("code")
+ if state == "" || code == "" || r.URL.Query().Get("error") != "" {
+ result, _ := h.service.ConsumeFailedOAuthCallback(state)
+ h.auditOAuthCallback(r, result, false)
+ h.redirectOAuthResult(w, r, false)
+ return
+ }
+ result, err := h.service.CompleteOAuth(r.Context(), state, code, h.publicBaseURL)
+ if err != nil {
+ h.auditOAuthCallback(r, result, false)
+ h.redirectOAuthResult(w, r, false)
+ return
+ }
+ h.auditOAuthCallback(r, result, true)
+ h.redirectOAuthResult(w, r, true)
+}
+
+func (h *ZammadHandler) auditOAuthCallback(r *http.Request, result *services.ZammadOAuthCallbackResult, success bool) {
+ if h.auditor == nil || result == nil || result.Initiator == nil {
+ return
+ }
+ details := map[string]any{"external_id": result.ProviderID, "oauth_callback": true, "credential_mutation": true}
+ if success {
+ h.auditor.LogWithDetails(r, result.Initiator, logger.ActionZammadOAuthCredentialSet, logger.ResourceZammadConnection, nil, result.ProviderName, details)
+ return
+ }
+ h.auditor.LogFailure(r, result.Initiator, logger.ActionZammadOAuthCredentialSet, logger.ResourceZammadConnection, nil, result.ProviderName, "oauth_callback_failed", details)
+}
+
+func (h *ZammadHandler) redirectOAuthResult(w http.ResponseWriter, r *http.Request, success bool) {
+ result := "error"
+ if success {
+ result = "success"
+ }
+ contextPath := ""
+ if publicURL, err := url.Parse(h.publicBaseURL); err == nil {
+ contextPath = strings.TrimRight(publicURL.EscapedPath(), "/")
+ }
+ http.Redirect(w, r, contextPath+"/admin/integration-providers?tab=zammad&oauth="+url.QueryEscape(result), http.StatusFound)
+}
+
+func (h *ZammadHandler) ListConnections(w http.ResponseWriter, r *http.Request) {
+ connections, err := h.service.ListConnections()
+ if err != nil {
+ respondInternalError(w, r, err)
+ return
+ }
+ respondJSONOK(w, connections)
+}
+
+func (h *ZammadHandler) GetConnection(w http.ResponseWriter, r *http.Request) {
+ connection, err := h.service.GetConnection(r.PathValue("id"))
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ respondJSONOK(w, connection)
+}
+
+func (h *ZammadHandler) CreateConnection(w http.ResponseWriter, r *http.Request) {
+ req, ok := decodeJSON[models.CreateZammadConnectionRequest](w, r)
+ if !ok {
+ return
+ }
+ user, ok := RequireAuth(w, r)
+ if !ok {
+ return
+ }
+ connection, err := h.service.CreateConnection(req, user.ID)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, user, logger.ActionZammadConnectionCreate, logger.ResourceZammadConnection, connection.ProviderID, connection.Name, map[string]any{"workspace_ids": connection.WorkspaceIDs, "all_workspaces": connection.AppliesToAllWorkspaces})
+ respondJSONCreated(w, connection)
+}
+
+func (h *ZammadHandler) UpdateConnection(w http.ResponseWriter, r *http.Request) {
+ req, ok := decodeJSON[models.UpdateZammadConnectionRequest](w, r)
+ if !ok {
+ return
+ }
+ connection, err := h.service.UpdateConnection(r.PathValue("id"), req)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, currentUser(r), logger.ActionZammadConnectionUpdate, logger.ResourceZammadConnection, connection.ProviderID, connection.Name, map[string]any{"token_rotated": req.APIToken != nil && *req.APIToken != ""})
+ respondJSONOK(w, connection)
+}
+
+func (h *ZammadHandler) DeleteConnection(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ connection, err := h.service.GetConnection(id)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ if !h.respondServiceError(w, r, h.service.DeleteConnection(id)) {
+ return
+ }
+ h.audit(r, currentUser(r), logger.ActionZammadConnectionDelete, logger.ResourceZammadConnection, id, connection.Name, nil)
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *ZammadHandler) TestConnection(w http.ResponseWriter, r *http.Request) {
+ metadata, err := h.service.TestConnection(r.Context(), r.PathValue("id"))
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, currentUser(r), logger.ActionZammadConnectionTest, logger.ResourceZammadConnection, r.PathValue("id"), "", map[string]any{"groups": len(metadata.Groups), "states": len(metadata.States)})
+ respondJSONOK(w, map[string]any{"ok": true, "metadata": metadata})
+}
+
+func (h *ZammadHandler) RetryUncertainTicketCreation(w http.ResponseWriter, r *http.Request) {
+ user, ok := RequireAuth(w, r)
+ if !ok {
+ return
+ }
+ link, err := h.service.RetryUncertainTicketCreation(r.Context(), r.PathValue("linkId"), user.ID)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, user, logger.ActionZammadTicketCreate, logger.ResourceZammadTicket, link.ID, link.TicketNumber, map[string]any{
+ "item_id": link.ItemID, "connection_id": link.ProviderID, "ticket_id": link.TicketID, "admin_uncertain_override": true,
+ })
+ respondJSONOK(w, link.Response())
+}
+
+func (h *ZammadHandler) ListWorkspaceConnections(w http.ResponseWriter, r *http.Request) {
+ workspaceID, ok := requireIDParam(w, r, "workspaceId")
+ if !ok {
+ return
+ }
+ user, ok := RequireAuth(w, r)
+ if !ok || !RequireWorkspacePermission(w, r, user.ID, workspaceID, models.PermissionItemView, h.permissionService) {
+ return
+ }
+ connections, err := h.service.ListConnectionsForWorkspace(workspaceID)
+ if err != nil {
+ respondInternalError(w, r, err)
+ return
+ }
+ responses := make([]models.ZammadWorkspaceConnection, 0, len(connections))
+ for _, connection := range connections {
+ ready := connection.AuthMethod == models.ZammadAuthMethodAPIToken && connection.HasAPIToken
+ if connection.AuthMethod == models.ZammadAuthMethodOAuth {
+ ready = connection.OAuthConnected && !connection.ReauthorizationRequired
+ }
+ responses = append(responses, models.ZammadWorkspaceConnection{
+ ProviderID: connection.ProviderID, Name: connection.Name, AuthMethod: connection.AuthMethod,
+ Ready: ready, OAuthConnected: connection.OAuthConnected, ReauthorizationRequired: connection.ReauthorizationRequired,
+ DefaultGroupID: connection.DefaultGroupID, DefaultGroupName: connection.DefaultGroupName,
+ AllowedGroupIDs: connection.AllowedGroupIDs,
+ })
+ }
+ respondJSONOK(w, responses)
+}
+
+func (h *ZammadHandler) GetWorkspaceMetadata(w http.ResponseWriter, r *http.Request) {
+ workspaceID, ok := requireIDParam(w, r, "workspaceId")
+ if !ok {
+ return
+ }
+ user, ok := RequireAuth(w, r)
+ if !ok || !RequireWorkspacePermission(w, r, user.ID, workspaceID, models.PermissionItemView, h.permissionService) {
+ return
+ }
+ metadata, err := h.service.MetadataForWorkspace(r.Context(), r.PathValue("id"), workspaceID)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ respondJSONOK(w, metadata)
+}
+
+func (h *ZammadHandler) GetWorkspaceOwners(w http.ResponseWriter, r *http.Request) {
+ workspaceID, ok := requireIDParam(w, r, "workspaceId")
+ if !ok {
+ return
+ }
+ user, ok := RequireAuth(w, r)
+ if !ok || !RequireWorkspacePermission(w, r, user.ID, workspaceID, models.PermissionItemView, h.permissionService) {
+ return
+ }
+ groupID, err := strconv.Atoi(r.URL.Query().Get("group_id"))
+ if err != nil || groupID <= 0 {
+ respondValidationError(w, r, "group_id must be positive")
+ return
+ }
+ owners, err := h.service.OwnersForWorkspace(r.Context(), r.PathValue("id"), workspaceID, groupID)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ respondJSONOK(w, owners)
+}
+
+func (h *ZammadHandler) GetItemLinks(w http.ResponseWriter, r *http.Request) {
+ itemID, ok := requireIDParam(w, r, "id")
+ if !ok {
+ return
+ }
+ if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, itemID, models.PermissionItemView) {
+ return
+ }
+ links, err := h.service.TicketLinksForItem(itemID)
+ if err != nil {
+ respondInternalError(w, r, err)
+ return
+ }
+ responses := make([]models.ZammadTicketLinkResponse, 0, len(links))
+ for _, link := range links {
+ responses = append(responses, link.Response())
+ }
+ respondJSONOK(w, responses)
+}
+
+func (h *ZammadHandler) CreateTicket(w http.ResponseWriter, r *http.Request) {
+ itemID, ok := requireIDParam(w, r, "id")
+ if !ok {
+ return
+ }
+ if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, itemID, models.PermissionItemEdit) {
+ return
+ }
+ user, ok := RequireAuth(w, r)
+ if !ok {
+ return
+ }
+ req, ok := decodeJSON[models.CreateZammadTicketRequest](w, r)
+ if !ok {
+ return
+ }
+ link, err := h.service.CreateTicket(r.Context(), itemID, user.ID, req)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, user, logger.ActionZammadTicketCreate, logger.ResourceZammadTicket, link.ID, link.TicketNumber, map[string]any{"item_id": link.ItemID, "connection_id": link.ProviderID, "ticket_id": link.TicketID, "sync_state": link.SyncState})
+ respondJSONOK(w, link.Response())
+}
+
+func (h *ZammadHandler) LinkExistingTicket(w http.ResponseWriter, r *http.Request) {
+ itemID, ok := requireIDParam(w, r, "id")
+ if !ok {
+ return
+ }
+ if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, itemID, models.PermissionItemEdit) {
+ return
+ }
+ user, ok := RequireAuth(w, r)
+ if !ok {
+ return
+ }
+ req, ok := decodeJSON[models.LinkZammadTicketRequest](w, r)
+ if !ok {
+ return
+ }
+ link, err := h.service.LinkExistingTicket(r.Context(), itemID, user.ID, req)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, user, logger.ActionZammadTicketLinkExisting, logger.ResourceZammadTicket, link.ID, link.TicketNumber, map[string]any{
+ "item_id": link.ItemID, "connection_id": link.ProviderID, "ticket_id": link.TicketID,
+ })
+ respondJSONCreated(w, link.Response())
+}
+
+func (h *ZammadHandler) UpdateTicketLink(w http.ResponseWriter, r *http.Request) {
+ link, err := h.service.GetTicketLink(r.PathValue("linkId"))
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, link.ItemID, models.PermissionItemEdit) {
+ return
+ }
+ req, ok := decodeJSON[models.UpdateZammadTicketLinkRequest](w, r)
+ if !ok {
+ return
+ }
+ updated, err := h.service.UpdateTicketLink(r.Context(), link.ID, req)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, currentUser(r), logger.ActionZammadTicketUpdate, logger.ResourceZammadTicket, updated.ID, updated.TicketNumber, map[string]any{
+ "item_id": updated.ItemID, "connection_id": updated.ProviderID, "ticket_id": updated.TicketID,
+ "state_id": updated.LastStatusID, "group_id": updated.GroupID, "owner_id": updated.OwnerID,
+ })
+ respondJSONOK(w, updated.Response())
+}
+
+func (h *ZammadHandler) DeleteTicketLink(w http.ResponseWriter, r *http.Request) {
+ link, err := h.service.GetTicketLink(r.PathValue("linkId"))
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, link.ItemID, models.PermissionItemEdit) {
+ return
+ }
+ removed, err := h.service.UnlinkTicket(r.Context(), link.ID)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, currentUser(r), logger.ActionZammadTicketUnlink, logger.ResourceZammadTicket, removed.ID, removed.TicketNumber, map[string]any{
+ "item_id": removed.ItemID, "connection_id": removed.ProviderID, "ticket_id": removed.TicketID,
+ })
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *ZammadHandler) RefreshTicket(w http.ResponseWriter, r *http.Request) {
+ link, err := h.service.GetTicketLink(r.PathValue("linkId"))
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, link.ItemID, models.PermissionItemEdit) {
+ return
+ }
+ link, err = h.service.SyncTicketLink(r.Context(), link.ID)
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, currentUser(r), logger.ActionZammadTicketRefresh, logger.ResourceZammadTicket, link.ID, link.TicketNumber, map[string]any{"item_id": link.ItemID, "connection_id": link.ProviderID, "ticket_id": link.TicketID, "status_id": link.LastStatusID})
+ respondJSONOK(w, link.Response())
+}
+
+func (h *ZammadHandler) audit(r *http.Request, user *models.User, action, resourceType, externalID, name string, details map[string]any) {
+ if h.auditor == nil || user == nil {
+ return
+ }
+ if details == nil {
+ details = map[string]any{}
+ }
+ details["external_id"] = externalID
+ h.auditor.LogWithDetails(r, user, action, resourceType, nil, name, details)
+}
+
+func currentUser(r *http.Request) *models.User {
+ return utils.GetCurrentUser(r)
+}
+
+func (h *ZammadHandler) respondServiceError(w http.ResponseWriter, r *http.Request, err error) bool {
+ if err == nil {
+ return true
+ }
+ switch {
+ case errors.Is(err, repository.ErrNotFound), errors.Is(err, services.ErrCredentialScopeMismatch):
+ respondNotFound(w, r, "zammad_connection")
+ case errors.Is(err, repository.ErrDuplicateEntry):
+ respondConflict(w, r, "A Zammad connection or ticket link with these identifiers already exists")
+ default:
+ var apiErr *zammad.APIError
+ var upstreamErr *zammad.UpstreamError
+ var configurationErr *zammad.ConfigurationError
+ var validationErr *services.ZammadValidationError
+ var transitionErr *services.TransitionRejection
+ if errors.Is(err, services.ErrZammadReauthorizationRequired) {
+ respondError(w, r, restapi.NewAPIError(http.StatusConflict, "ZAMMAD_REAUTHORIZATION_REQUIRED", "Zammad authorization must be renewed by a system administrator"))
+ return false
+ }
+ switch {
+ case errors.As(err, &validationErr):
+ respondValidationError(w, r, validationErr.Error())
+ case errors.As(err, &configurationErr):
+ respondBadRequest(w, r, configurationErr.Error())
+ case errors.As(err, &apiErr) || errors.As(err, &upstreamErr):
+ respondError(w, r, restapi.NewAPIError(http.StatusBadGateway, "ZAMMAD_UPSTREAM_ERROR", "Zammad could not complete the request"))
+ case errors.As(err, &transitionErr):
+ respondBadRequest(w, r, "The configured Windshift completion transition is not currently allowed")
+ default:
+ respondInternalError(w, r, err)
+ }
+ }
+ return false
+}
diff --git a/internal/handlers/zammad_test.go b/internal/handlers/zammad_test.go
new file mode 100644
index 000000000..da7f4fbf3
--- /dev/null
+++ b/internal/handlers/zammad_test.go
@@ -0,0 +1,219 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "windshift/internal/database"
+ "windshift/internal/integrations/zammad"
+ "windshift/internal/logger"
+ "windshift/internal/middleware"
+ "windshift/internal/models"
+ "windshift/internal/repository"
+ "windshift/internal/services"
+ "windshift/internal/sso"
+)
+
+func newZammadHandlerTest(t *testing.T) (*ZammadHandler, database.Database, *models.User, int) {
+ t.Helper()
+ db, err := database.NewSQLiteDB(t.TempDir() + "/windshift.db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ if err := db.Initialize(); err != nil {
+ t.Fatal(err)
+ }
+ result, err := db.ExecWrite(`INSERT INTO users (email, username, first_name, last_name) VALUES (?, ?, ?, ?)`, "admin@example.test", "synthetic-admin", "Synthetic", "Admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ userID, _ := result.LastInsertId()
+ result, err = db.ExecWrite(`INSERT INTO workspaces (name, key) VALUES (?, ?)`, "Primary", "PRI")
+ if err != nil {
+ t.Fatal(err)
+ }
+ workspaceID, _ := result.LastInsertId()
+ credentialService := services.NewActionCredentialService(repository.NewActionCredentialRepository(db), "synthetic-handler-test-secret")
+ service := services.NewZammadService(db, repository.NewZammadRepository(db), credentialService, nil, nil, nil, nil)
+ return NewZammadHandler(repository.NewItemRepository(db), service, nil, logger.NewAuditor(db)), db, &models.User{ID: int(userID), Username: "synthetic-admin"}, int(workspaceID)
+}
+
+func authenticatedZammadRequest(method, target string, body []byte, user *models.User) *http.Request {
+ req := httptest.NewRequest(method, target, bytes.NewReader(body))
+ return req.WithContext(context.WithValue(req.Context(), middleware.ContextKeyUser, user))
+}
+
+func TestZammadHandlerCreateConnectionResponseOmitsSecret(t *testing.T) {
+ handler, db, user, workspaceID := newZammadHandlerTest(t)
+ body, _ := json.Marshal(models.CreateZammadConnectionRequest{
+ Slug: "helpdesk", Name: "Synthetic helpdesk", BaseURL: "https://zammad.example.test",
+ APIToken: "handler-secret-token", DefaultGroupID: 7, DefaultGroupName: "Support",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{workspaceID},
+ })
+ recorder := httptest.NewRecorder()
+ handler.CreateConnection(recorder, authenticatedZammadRequest(http.MethodPost, "/api/admin/zammad-connections", body, user))
+ if recorder.Code != http.StatusCreated || recorder.Header().Get("Content-Type") != "application/json" {
+ t.Fatalf("unexpected response status or type: %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
+ }
+ var response map[string]any
+ if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
+ t.Fatal(err)
+ }
+ if response["has_api_token"] != true || response["id"] == "" || response["base_url"] != "https://zammad.example.test" {
+ t.Fatalf("unexpected response shape: %#v", response)
+ }
+ if _, exists := response["api_token"]; exists {
+ t.Fatalf("response disclosed api_token field: %s", recorder.Body.String())
+ }
+ if _, exists := response["credential_id"]; exists {
+ t.Fatalf("response disclosed credential_id field: %s", recorder.Body.String())
+ }
+ if strings.Contains(recorder.Body.String(), "handler-secret-token") {
+ t.Fatalf("response disclosed token: %s", recorder.Body.String())
+ }
+ var encrypted string
+ if err := db.QueryRow("SELECT encrypted_secret FROM action_credentials").Scan(&encrypted); err != nil || encrypted == "handler-secret-token" {
+ t.Fatalf("credential was not encrypted: err=%v", err)
+ }
+ genericRepo := repository.NewIntegrationProviderRepository(db)
+ providers, err := genericRepo.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(providers) != 0 {
+ t.Fatalf("provider-specific Zammad connection leaked into generic OAuth CRUD: %#v", providers)
+ }
+ if err := genericRepo.Delete(response["id"].(string)); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("generic provider delete was not blocked: %v", err)
+ }
+}
+
+func TestZammadHandlerReturnsStructuredValidationAndNotFoundErrors(t *testing.T) {
+ handler, _, user, workspaceID := newZammadHandlerTest(t)
+ body, _ := json.Marshal(models.CreateZammadConnectionRequest{
+ Slug: "helpdesk", Name: "Synthetic helpdesk", BaseURL: "http://zammad.example.test",
+ APIToken: "handler-secret-token", DefaultCustomer: "robot@example.test",
+ WorkspaceIDs: []int{workspaceID},
+ })
+ recorder := httptest.NewRecorder()
+ handler.CreateConnection(recorder, authenticatedZammadRequest(http.MethodPost, "/api/admin/zammad-connections", body, user))
+ if recorder.Code != http.StatusBadRequest {
+ t.Fatalf("expected validation status 400, got %d: %s", recorder.Code, recorder.Body.String())
+ }
+ var validation map[string]any
+ if err := json.Unmarshal(recorder.Body.Bytes(), &validation); err != nil {
+ t.Fatal(err)
+ }
+ if validation["code"] != "VALIDATION_FAILED" {
+ t.Fatalf("unexpected validation response: %#v", validation)
+ }
+
+ request := httptest.NewRequest(http.MethodGet, "/api/admin/zammad-connections/missing", nil)
+ request.SetPathValue("id", "missing")
+ recorder = httptest.NewRecorder()
+ handler.GetConnection(recorder, request)
+ if recorder.Code != http.StatusNotFound {
+ t.Fatalf("expected not-found status 404, got %d: %s", recorder.Code, recorder.Body.String())
+ }
+}
+
+func TestZammadHandlerMapsRemoteAuthenticationFailureToBadGateway(t *testing.T) {
+ handler, _, _, _ := newZammadHandlerTest(t)
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPost, "/api/admin/zammad-connections/example/test", nil)
+ if handler.respondServiceError(recorder, request, &zammad.APIError{StatusCode: http.StatusUnauthorized}) {
+ t.Fatal("expected handler to write an error response")
+ }
+ if recorder.Code != http.StatusBadGateway || strings.Contains(recorder.Body.String(), "401") {
+ t.Fatalf("unexpected upstream response: %d %s", recorder.Code, recorder.Body.String())
+ }
+}
+
+func TestZammadOAuthCallbackRedirectsToIntegrationsAdminTab(t *testing.T) {
+ handler, _, _, _ := newZammadHandlerTest(t)
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/api/integrations/zammad/oauth/callback?error=access_denied&state=missing", nil)
+ handler.OAuthCallback(recorder, request)
+ if recorder.Code != http.StatusFound || recorder.Header().Get("Location") != "/admin/integration-providers?tab=zammad&oauth=error" {
+ t.Fatalf("unexpected OAuth callback redirect: status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
+ }
+}
+
+func TestZammadOAuthCallbackAuditsInitiatorSuccessAndFailureWithoutSecrets(t *testing.T) {
+ handler, db, user, workspaceID := newZammadHandlerTest(t)
+ handler.SetOAuthBaseURL("https://windshift.example.test")
+ handler.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-handler-test-secret"))
+ handler.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, body []byte, _ map[string]string) (*zammad.Response, error) {
+ if strings.Contains(string(body), "failure-code") {
+ return &zammad.Response{StatusCode: http.StatusBadGateway, Body: []byte(`{"error":"upstream_failure"}`)}, nil
+ }
+ return &zammad.Response{StatusCode: http.StatusOK, Body: []byte(`{"access_token":"audit-access-secret","refresh_token":"audit-refresh-secret","expires_in":3600}`)}, nil
+ }))
+ connection, err := handler.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "audit-oauth", Name: "Audit OAuth", BaseURL: "https://audit-oauth.example.test",
+ AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "audit-client", OAuthClientSecret: "audit-client-secret",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{workspaceID},
+ }, user.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ callback := func(code string) {
+ t.Helper()
+ authURL, err := handler.service.StartOAuth(context.Background(), connection.ProviderID, user.ID, "https://windshift.example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := url.Parse(authURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/api/integrations/zammad/oauth/callback?state="+url.QueryEscape(parsed.Query().Get("state"))+"&code="+url.QueryEscape(code), nil)
+ recorder := httptest.NewRecorder()
+ handler.OAuthCallback(recorder, request)
+ if recorder.Code != http.StatusFound {
+ t.Fatalf("callback status = %d", recorder.Code)
+ }
+ }
+ callback("success-code")
+ callback("failure-code")
+
+ rows, err := db.Query(`SELECT user_id, username, success, COALESCE(error_message, ''), COALESCE(details, '')
+ FROM audit_logs WHERE action_type = ? ORDER BY id`, logger.ActionZammadOAuthCredentialSet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = rows.Close() }()
+ type auditRow struct {
+ userID int
+ username, error, data string
+ success bool
+ }
+ var audits []auditRow
+ for rows.Next() {
+ var row auditRow
+ if err := rows.Scan(&row.userID, &row.username, &row.success, &row.error, &row.data); err != nil {
+ t.Fatal(err)
+ }
+ audits = append(audits, row)
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+ if len(audits) != 2 || !audits[0].success || audits[1].success || audits[0].userID != user.ID || audits[1].userID != user.ID || audits[0].username != user.Username || audits[1].error != "oauth_callback_failed" {
+ t.Fatalf("unexpected callback audits: %#v", audits)
+ }
+ for _, audit := range audits {
+ if strings.Contains(audit.data, "success-code") || strings.Contains(audit.data, "failure-code") || strings.Contains(audit.data, "audit-access-secret") || strings.Contains(audit.data, "audit-refresh-secret") || strings.Contains(audit.data, "audit-client-secret") {
+ t.Fatalf("callback audit disclosed OAuth material: %s", audit.data)
+ }
+ }
+}
diff --git a/internal/integrations/zammad/client.go b/internal/integrations/zammad/client.go
new file mode 100644
index 000000000..9d7e4621b
--- /dev/null
+++ b/internal/integrations/zammad/client.go
@@ -0,0 +1,357 @@
+package zammad
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "windshift/internal/models"
+)
+
+type Response struct {
+ StatusCode int
+ Body []byte
+}
+
+type Transport interface {
+ Do(ctx context.Context, method, targetURL string, body []byte, headers map[string]string) (*Response, error)
+}
+
+type TransportFunc func(context.Context, string, string, []byte, map[string]string) (*Response, error)
+
+func (f TransportFunc) Do(ctx context.Context, method, targetURL string, body []byte, headers map[string]string) (*Response, error) {
+ return f(ctx, method, targetURL, body, headers)
+}
+
+type APIError struct {
+ StatusCode int
+}
+
+func (e *APIError) Error() string {
+ return fmt.Sprintf("Zammad API returned HTTP %d", e.StatusCode)
+}
+
+// UpstreamError represents a network or response-shape failure without
+// exposing the remote response or request credentials through Error().
+type UpstreamError struct{ Cause error }
+
+func (e *UpstreamError) Error() string { return "Zammad request failed" }
+func (e *UpstreamError) Unwrap() error { return e.Cause }
+
+type ConfigurationError struct{ Message string }
+
+func (e *ConfigurationError) Error() string { return e.Message }
+
+type Client struct {
+ baseURL string
+ authValue string
+ transport Transport
+}
+
+func NewClient(baseURL, apiToken string, transport Transport) *Client {
+ return &Client{baseURL: strings.TrimRight(baseURL, "/"), authValue: "Token token=" + apiToken, transport: transport}
+}
+
+// NewOAuthClient uses OAuth bearer authentication. Keeping NewClient unchanged
+// preserves the API-token wire format used by existing Zammad connections.
+func NewOAuthClient(baseURL, accessToken string, transport Transport) *Client {
+ return &Client{baseURL: strings.TrimRight(baseURL, "/"), authValue: "Bearer " + accessToken, transport: transport}
+}
+
+type Ticket struct {
+ ID int
+ Number string
+ GroupID int
+ GroupName string
+ StateID int
+ StateName string
+ OwnerID int
+ OwnerName string
+ Attributes map[string]json.RawMessage
+}
+
+type Owner struct {
+ ID int
+ Name string
+}
+
+type objectAttribute struct {
+ Name string `json:"name"`
+ Object string `json:"object"`
+ Active bool `json:"active"`
+}
+
+func (c *Client) Metadata(ctx context.Context) (*models.ZammadConnectionMetadata, error) {
+ activeGroups, err := c.Groups(ctx)
+ if err != nil {
+ return nil, err
+ }
+ states := []models.ZammadState{}
+ if err := c.getJSON(ctx, "/api/v1/ticket_states", &states); err != nil {
+ return nil, err
+ }
+ activeStates := states[:0]
+ for _, state := range states {
+ if state.Active {
+ activeStates = append(activeStates, state)
+ }
+ }
+ return &models.ZammadConnectionMetadata{Groups: activeGroups, States: activeStates}, nil
+}
+
+func (c *Client) Groups(ctx context.Context) ([]models.ZammadGroup, error) {
+ groups := []models.ZammadGroup{}
+ if err := c.getJSON(ctx, "/api/v1/groups", &groups); err != nil {
+ return nil, err
+ }
+ activeGroups := groups[:0]
+ for _, group := range groups {
+ if group.Active {
+ activeGroups = append(activeGroups, group)
+ }
+ }
+ return activeGroups, nil
+}
+
+func (c *Client) ValidateCorrelationField(ctx context.Context, field string) error {
+ attributes := []objectAttribute{}
+ if err := c.getJSON(ctx, "/api/v1/object_manager_attributes", &attributes); err != nil {
+ return err
+ }
+ for _, attribute := range attributes {
+ if attribute.Active && attribute.Name == field && strings.EqualFold(attribute.Object, "Ticket") {
+ return nil
+ }
+ }
+ return &ConfigurationError{Message: fmt.Sprintf("Zammad ticket field %q is missing or inactive", field)}
+}
+
+func (c *Client) FindByCorrelation(ctx context.Context, field, value string) (*Ticket, error) {
+ query := url.Values{}
+ query.Set("query", field+":"+strconv.Quote(value))
+ query.Set("per_page", "10")
+ var rawTickets []map[string]json.RawMessage
+ if err := c.getJSON(ctx, "/api/v1/tickets/search?"+query.Encode(), &rawTickets); err != nil {
+ return nil, err
+ }
+ for _, raw := range rawTickets {
+ var correlation string
+ if fieldValue, ok := raw[field]; ok {
+ _ = json.Unmarshal(fieldValue, &correlation)
+ }
+ if correlation != value {
+ continue
+ }
+ ticket, err := decodeTicket(raw)
+ if err != nil {
+ return nil, &UpstreamError{Cause: err}
+ }
+ return ticket, nil
+ }
+ return nil, nil
+}
+
+// FindByNumber performs an exact match after asking Zammad to narrow the
+// result set. The local check is essential because the search endpoint is not
+// an exact-match API.
+func (c *Client) FindByNumber(ctx context.Context, number string) (*Ticket, error) {
+ query := url.Values{}
+ query.Set("query", "number:"+strconv.Quote(number))
+ query.Set("per_page", "10")
+ var rawTickets []map[string]json.RawMessage
+ if err := c.getJSON(ctx, "/api/v1/tickets/search?"+query.Encode(), &rawTickets); err != nil {
+ return nil, err
+ }
+ for _, raw := range rawTickets {
+ ticket, err := decodeTicket(raw)
+ if err != nil {
+ return nil, &UpstreamError{Cause: err}
+ }
+ if ticket.Number == number {
+ return ticket, nil
+ }
+ }
+ return nil, nil
+}
+
+func (c *Client) CreateTicket(ctx context.Context, title, body, customer, groupName, correlationField, correlationValue string) (*Ticket, error) {
+ payload := map[string]any{
+ "title": title,
+ "group": groupName,
+ "customer": customer,
+ "article": map[string]any{
+ "subject": title,
+ "body": body,
+ "type": "note",
+ "sender": "Agent",
+ "internal": true,
+ },
+ correlationField: correlationValue,
+ }
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ var raw map[string]json.RawMessage
+ if err := c.requestJSON(ctx, http.MethodPost, "/api/v1/tickets", encoded, &raw); err != nil {
+ return nil, err
+ }
+ ticket, err := decodeTicket(raw)
+ if err != nil {
+ return nil, &UpstreamError{Cause: err}
+ }
+ return ticket, nil
+}
+
+func (c *Client) GetTicket(ctx context.Context, ticketID int) (*Ticket, error) {
+ var raw map[string]json.RawMessage
+ path := "/api/v1/tickets/" + strconv.Itoa(ticketID) + "?expand=true"
+ if err := c.getJSON(ctx, path, &raw); err != nil {
+ return nil, err
+ }
+ ticket, err := decodeTicket(raw)
+ if err != nil {
+ return nil, &UpstreamError{Cause: err}
+ }
+ return ticket, nil
+}
+
+func (c *Client) UpdateTicket(ctx context.Context, ticketID int, stateID, groupID, ownerID *int, correlationField, correlationValue string) (*Ticket, error) {
+ payload := map[string]any{}
+ if stateID != nil {
+ payload["state_id"] = *stateID
+ }
+ if groupID != nil {
+ payload["group_id"] = *groupID
+ }
+ if ownerID != nil {
+ payload["owner_id"] = *ownerID
+ }
+ if correlationField != "" {
+ payload[correlationField] = correlationValue
+ }
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ var raw map[string]json.RawMessage
+ if err := c.requestJSON(ctx, http.MethodPut, "/api/v1/tickets/"+strconv.Itoa(ticketID), encoded, &raw); err != nil {
+ return nil, err
+ }
+ ticket, err := decodeTicket(raw)
+ if err != nil {
+ return nil, &UpstreamError{Cause: err}
+ }
+ return ticket, nil
+}
+
+// Owners searches on the effective target group. Zammad only keeps an
+// assignment when the selected agent has full access to the ticket group.
+func (c *Client) Owners(ctx context.Context, groupID int) ([]Owner, error) {
+ query := url.Values{}
+ query.Set("query", "*")
+ query.Add("permissions[]", "ticket.agent")
+ query.Set(fmt.Sprintf("group_ids[%d]", groupID), "full")
+ query.Set("per_page", "100")
+ var rawUsers []map[string]json.RawMessage
+ if err := c.getJSON(ctx, "/api/v1/users/search?"+query.Encode(), &rawUsers); err != nil {
+ return nil, err
+ }
+ owners := make([]Owner, 0, len(rawUsers))
+ for _, raw := range rawUsers {
+ var id int
+ var active bool
+ var first, last, login string
+ if json.Unmarshal(raw["id"], &id) != nil || id <= 0 {
+ continue
+ }
+ if value, ok := raw["active"]; ok {
+ if err := json.Unmarshal(value, &active); err != nil || !active {
+ continue
+ }
+ }
+ var groups map[string][]string
+ groupValue, ok := raw["group_ids"]
+ if !ok || json.Unmarshal(groupValue, &groups) != nil || !containsAccess(groups[strconv.Itoa(groupID)], "full") {
+ continue
+ }
+ _ = json.Unmarshal(raw["firstname"], &first)
+ _ = json.Unmarshal(raw["lastname"], &last)
+ _ = json.Unmarshal(raw["login"], &login)
+ name := strings.TrimSpace(first + " " + last)
+ if name == "" {
+ name = login
+ }
+ owners = append(owners, Owner{ID: id, Name: name})
+ }
+ return owners, nil
+}
+
+func containsAccess(values []string, accepted ...string) bool {
+ for _, value := range values {
+ for _, want := range accepted {
+ if value == want {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (c *Client) getJSON(ctx context.Context, path string, target any) error {
+ return c.requestJSON(ctx, http.MethodGet, path, nil, target)
+}
+
+func (c *Client) requestJSON(ctx context.Context, method, path string, body []byte, target any) error {
+ if c.transport == nil {
+ return errors.New("zammad transport is not configured")
+ }
+ response, err := c.transport.Do(ctx, method, c.baseURL+path, body, map[string]string{
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ "Authorization": c.authValue,
+ })
+ if err != nil {
+ return &UpstreamError{Cause: err}
+ }
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return &APIError{StatusCode: response.StatusCode}
+ }
+ if err := json.Unmarshal(response.Body, target); err != nil {
+ return &UpstreamError{Cause: errors.New("zammad API returned invalid JSON")}
+ }
+ return nil
+}
+
+func decodeTicket(raw map[string]json.RawMessage) (*Ticket, error) {
+ ticket := &Ticket{Attributes: raw}
+ if err := decodeRequired(raw, "id", &ticket.ID); err != nil {
+ return nil, err
+ }
+ if err := decodeRequired(raw, "number", &ticket.Number); err != nil {
+ return nil, err
+ }
+ _ = json.Unmarshal(raw["group_id"], &ticket.GroupID)
+ _ = json.Unmarshal(raw["group"], &ticket.GroupName)
+ _ = json.Unmarshal(raw["state_id"], &ticket.StateID)
+ _ = json.Unmarshal(raw["state"], &ticket.StateName)
+ _ = json.Unmarshal(raw["owner_id"], &ticket.OwnerID)
+ _ = json.Unmarshal(raw["owner"], &ticket.OwnerName)
+ return ticket, nil
+}
+
+func decodeRequired(raw map[string]json.RawMessage, field string, target any) error {
+ value, ok := raw[field]
+ if !ok {
+ return fmt.Errorf("zammad ticket response is missing %s", field)
+ }
+ if err := json.Unmarshal(value, target); err != nil {
+ return fmt.Errorf("zammad ticket response has invalid %s", field)
+ }
+ return nil
+}
diff --git a/internal/integrations/zammad/client_test.go b/internal/integrations/zammad/client_test.go
new file mode 100644
index 000000000..db7dfea88
--- /dev/null
+++ b/internal/integrations/zammad/client_test.go
@@ -0,0 +1,178 @@
+package zammad
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+type httpTestTransport struct{ client *http.Client }
+
+func (t httpTestTransport) Do(ctx context.Context, method, targetURL string, body []byte, headers map[string]string) (*Response, error) {
+ req, err := http.NewRequestWithContext(ctx, method, targetURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ for key, value := range headers {
+ req.Header.Set(key, value)
+ }
+ resp, err := t.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ return &Response{StatusCode: resp.StatusCode, Body: responseBody}, nil
+}
+
+func TestClientMetadataCorrelationAndTicketCreation(t *testing.T) {
+ t.Parallel()
+ const token = "synthetic-secret-token"
+ postCount := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Token token="+token {
+ t.Fatalf("unexpected authorization header: %q", got)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.URL.Path == "/api/v1/groups":
+ _, _ = w.Write([]byte(`[{"id":1,"name":"Support","active":true},{"id":2,"name":"Old","active":false}]`))
+ case r.URL.Path == "/api/v1/ticket_states":
+ _, _ = w.Write([]byte(`[{"id":4,"name":"closed","state_type_id":5,"active":true}]`))
+ case r.URL.Path == "/api/v1/object_manager_attributes":
+ _, _ = w.Write([]byte(`[{"name":"windshift_item_key","object":"Ticket","active":true}]`))
+ case r.URL.Path == "/api/v1/tickets/search":
+ _, _ = w.Write([]byte(`[{"id":9,"number":"42009","group_id":1,"state_id":2,"state":"open","windshift_item_key":"windshift:abc:ITEM-49"}]`))
+ case r.URL.Path == "/api/v1/tickets" && r.Method == http.MethodPost:
+ postCount++
+ body, _ := io.ReadAll(r.Body)
+ if !strings.Contains(string(body), `"windshift_item_key":"windshift:abc:ITEM-50"`) {
+ t.Fatalf("ticket payload lacks correlation field: %s", body)
+ }
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"id":10,"number":"42010","group_id":1,"state_id":2,"state":"open"}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, token, httpTestTransport{client: server.Client()})
+ metadata, err := client.Metadata(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(metadata.Groups) != 1 || metadata.Groups[0].Name != "Support" || len(metadata.States) != 1 {
+ t.Fatalf("unexpected metadata: %#v", metadata)
+ }
+ if err := client.ValidateCorrelationField(context.Background(), "windshift_item_key"); err != nil {
+ t.Fatal(err)
+ }
+ found, err := client.FindByCorrelation(context.Background(), "windshift_item_key", "windshift:abc:ITEM-49")
+ if err != nil || found == nil || found.ID != 9 {
+ t.Fatalf("unexpected search result: ticket=%#v err=%v", found, err)
+ }
+ created, err := client.CreateTicket(context.Background(), "ITEM-50", "Synthetic body", "robot@example.test", "Support", "windshift_item_key", "windshift:abc:ITEM-50")
+ if err != nil || created.ID != 10 || postCount != 1 {
+ t.Fatalf("unexpected create result: ticket=%#v posts=%d err=%v", created, postCount, err)
+ }
+}
+
+func TestClientRedactsAPIErrorBodyAndToken(t *testing.T) {
+ t.Parallel()
+ const token = "must-not-appear"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"token must-not-appear is invalid"}`))
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, token, httpTestTransport{client: server.Client()})
+ _, err := client.Metadata(context.Background())
+ if err == nil {
+ t.Fatal("expected authentication error")
+ }
+ if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "invalid") {
+ t.Fatalf("API error disclosed response content: %v", err)
+ }
+}
+
+func TestClientHonorsContextTimeout(t *testing.T) {
+ t.Parallel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ time.Sleep(200 * time.Millisecond)
+ _, _ = w.Write([]byte(`[]`))
+ }))
+ defer server.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+ client := NewClient(server.URL, "token", httpTestTransport{client: server.Client()})
+ _, err := client.Metadata(ctx)
+ if err == nil {
+ t.Fatal("expected timeout")
+ }
+}
+
+func TestClientFindByNumberRequiresExactMatch(t *testing.T) {
+ t.Parallel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/tickets/search" {
+ http.NotFound(w, r)
+ return
+ }
+ if got := r.URL.Query().Get("query"); got != `number:"42009"` {
+ t.Fatalf("unexpected ticket-number query: %q", got)
+ }
+ _, _ = w.Write([]byte(`[{"id":9,"number":"420090"},{"id":10,"number":"42008"}]`))
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "token", httpTestTransport{client: server.Client()})
+ found, err := client.FindByNumber(context.Background(), "42009")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if found != nil {
+ t.Fatalf("non-exact search result was accepted: %#v", found)
+ }
+}
+
+func TestClientOwnersUsesGroupPermissionQueryAndFiltersIneligibleUsers(t *testing.T) {
+ t.Parallel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/users/search" {
+ http.NotFound(w, r)
+ return
+ }
+ query := r.URL.Query()
+ if query.Get("query") != "*" || query.Get("permissions[]") != "ticket.agent" || query.Get("group_ids[7]") != "full" || query.Get("per_page") != "100" {
+ t.Fatalf("unexpected owner query: %s", r.URL.RawQuery)
+ }
+ _, _ = w.Write([]byte(`[
+ {"id":2,"active":true,"firstname":"Ada","lastname":"Lovelace","group_ids":{"7":["full"]}},
+ {"id":6,"active":true,"login":"change-only","group_ids":{"7":["change"]}},
+ {"id":3,"active":true,"login":"read-only","group_ids":{"7":["read"]}},
+ {"id":4,"active":false,"login":"inactive","group_ids":{"7":["full"]}},
+ {"id":5,"active":true,"login":"fallback","group_ids":{"8":["full"]}}
+ ]`))
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "token", httpTestTransport{client: server.Client()})
+ owners, err := client.Owners(context.Background(), 7)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(owners) != 1 || owners[0] != (Owner{ID: 2, Name: "Ada Lovelace"}) {
+ t.Fatalf("unexpected eligible owners: %#v", owners)
+ }
+}
diff --git a/internal/integrations/zammad/oauth.go b/internal/integrations/zammad/oauth.go
new file mode 100644
index 000000000..2bf7bffb3
--- /dev/null
+++ b/internal/integrations/zammad/oauth.go
@@ -0,0 +1,62 @@
+package zammad
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var ErrInvalidGrant = errors.New("zammad OAuth authorization is no longer valid")
+
+type OAuthTokens struct {
+ AccessToken string
+ RefreshToken string
+ ExpiresIn time.Duration
+}
+
+// ExchangeOAuthCode and RefreshOAuthToken keep OAuth responses out of logs and
+// return only a generic upstream error for malformed remote replies.
+func ExchangeOAuthCode(ctx context.Context, transport Transport, tokenURL, clientID, clientSecret, code, redirectURI string) (*OAuthTokens, error) {
+ values := url.Values{"grant_type": {"authorization_code"}, "client_id": {clientID}, "client_secret": {clientSecret}, "code": {code}, "redirect_uri": {redirectURI}}
+ return requestOAuthTokens(ctx, transport, tokenURL, values)
+}
+
+func RefreshOAuthToken(ctx context.Context, transport Transport, tokenURL, clientID, clientSecret, refreshToken string) (*OAuthTokens, error) {
+ values := url.Values{"grant_type": {"refresh_token"}, "client_id": {clientID}, "client_secret": {clientSecret}, "refresh_token": {refreshToken}}
+ return requestOAuthTokens(ctx, transport, tokenURL, values)
+}
+
+func requestOAuthTokens(ctx context.Context, transport Transport, tokenURL string, values url.Values) (*OAuthTokens, error) {
+ response, err := transport.Do(ctx, http.MethodPost, tokenURL, []byte(values.Encode()), map[string]string{"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"})
+ if err != nil {
+ return nil, &UpstreamError{Cause: err}
+ }
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ var problem struct {
+ Error string `json:"error"`
+ }
+ _ = json.Unmarshal(response.Body, &problem)
+ if problem.Error == "invalid_grant" {
+ return nil, ErrInvalidGrant
+ }
+ return nil, &APIError{StatusCode: response.StatusCode}
+ }
+ var payload struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn json.RawMessage `json:"expires_in"`
+ }
+ if err := json.Unmarshal(response.Body, &payload); err != nil || strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" {
+ return nil, &UpstreamError{Cause: errors.New("invalid OAuth token response")}
+ }
+ seconds, err := strconv.ParseInt(strings.Trim(string(payload.ExpiresIn), `"`), 10, 64)
+ if err != nil || seconds <= 0 {
+ return nil, &UpstreamError{Cause: errors.New("invalid OAuth token expiry")}
+ }
+ return &OAuthTokens{AccessToken: payload.AccessToken, RefreshToken: payload.RefreshToken, ExpiresIn: time.Duration(seconds) * time.Second}, nil
+}
diff --git a/internal/logger/audit.go b/internal/logger/audit.go
index fc557e5e0..ba6d4bcdc 100644
--- a/internal/logger/audit.go
+++ b/internal/logger/audit.go
@@ -545,6 +545,16 @@ const (
ActionIntegrationProviderDelete = "integration_provider.delete"
ActionIntegrationItemLinkCreate = "integration_item_link.create"
ActionIntegrationItemLinkDelete = "integration_item_link.delete"
+ ActionZammadConnectionCreate = "zammad_connection.create"
+ ActionZammadConnectionUpdate = "zammad_connection.update"
+ ActionZammadConnectionDelete = "zammad_connection.delete"
+ ActionZammadConnectionTest = "zammad_connection.test"
+ ActionZammadOAuthCredentialSet = "zammad_connection.oauth_credential_set" // #nosec G101 -- audit action name, not a credential
+ ActionZammadTicketCreate = "zammad_ticket.create"
+ ActionZammadTicketLinkExisting = "zammad_ticket.link_existing"
+ ActionZammadTicketUpdate = "zammad_ticket.update"
+ ActionZammadTicketUnlink = "zammad_ticket.unlink"
+ ActionZammadTicketRefresh = "zammad_ticket.refresh"
// Issue sync configuration
ActionIssueSyncConfigCreate = "issue_sync_config.create"
@@ -825,6 +835,8 @@ const (
ResourceLLMConnection = "llm_connection"
ResourceIntegrationProvider = "integration_provider"
ResourceIntegrationItemLink = "integration_item_link"
+ ResourceZammadConnection = "zammad_connection"
+ ResourceZammadTicket = "zammad_ticket"
ResourceIssueSyncConfig = "issue_sync_config"
ResourceWorkItemStaleness = "work_item_staleness"
ResourceEmailProvider = "email_provider"
diff --git a/internal/models/zammad.go b/internal/models/zammad.go
new file mode 100644
index 000000000..ec21526b0
--- /dev/null
+++ b/internal/models/zammad.go
@@ -0,0 +1,209 @@
+package models
+
+import "time"
+
+const IntegrationProviderZammad IntegrationProviderType = "zammad"
+
+type ZammadAuthMethod string
+
+const (
+ ZammadAuthMethodAPIToken ZammadAuthMethod = "api_token"
+ ZammadAuthMethodOAuth ZammadAuthMethod = "oauth"
+)
+
+type ZammadSyncState string
+
+const (
+ ZammadSyncPending ZammadSyncState = "pending"
+ ZammadSyncCreating ZammadSyncState = "creating"
+ ZammadSyncLinked ZammadSyncState = "linked"
+ ZammadSyncFailed ZammadSyncState = "sync_failed"
+ ZammadSyncUncertain ZammadSyncState = "creation_uncertain"
+)
+
+// ZammadConnection is the provider-specific extension of an
+// integration_providers row. The API token stays in action_credentials and is
+// never serialized through this model.
+type ZammadConnection struct {
+ ProviderID string `json:"id"`
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ BaseURL string `json:"base_url"`
+ CredentialID int `json:"-"`
+ AuthMethod ZammadAuthMethod `json:"auth_method"`
+ OAuthGeneration int64 `json:"-"`
+ OAuthAttemptID string `json:"-"`
+ OAuthClientID string `json:"oauth_client_id,omitempty"`
+ OAuthClientSecretEncrypted string `json:"-"`
+ HasOAuthClientSecret bool `json:"has_oauth_client_secret,omitempty"`
+ OAuthConnected bool `json:"oauth_connected,omitempty"`
+ ReauthorizationRequired bool `json:"reauthorization_required,omitempty"`
+ DefaultGroupID int `json:"default_group_id,omitempty"`
+ DefaultGroupName string `json:"default_group_name,omitempty"`
+ AllowedGroupIDs []int `json:"allowed_group_ids"`
+ DefaultCustomer string `json:"default_customer"`
+ CorrelationField string `json:"correlation_field"`
+ ClosedStateIDs []int `json:"closed_state_ids"`
+ CompletionStatusID *int `json:"completion_status_id,omitempty"`
+ AppliesToAllWorkspaces bool `json:"applies_to_all_workspaces"`
+ WorkspaceIDs []int `json:"workspace_ids,omitempty"`
+ HasAPIToken bool `json:"has_api_token"`
+ LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
+ LastTestError string `json:"last_test_error,omitempty"`
+ CreatedBy *int `json:"created_by,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type ZammadWorkspaceConnection struct {
+ ProviderID string `json:"id"`
+ Name string `json:"name"`
+ AuthMethod ZammadAuthMethod `json:"auth_method"`
+ Ready bool `json:"ready"`
+ OAuthConnected bool `json:"oauth_connected,omitempty"`
+ ReauthorizationRequired bool `json:"reauthorization_required,omitempty"`
+ DefaultGroupID int `json:"default_group_id,omitempty"`
+ DefaultGroupName string `json:"default_group_name,omitempty"`
+ AllowedGroupIDs []int `json:"allowed_group_ids"`
+}
+
+type CreateZammadConnectionRequest struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Enabled *bool `json:"enabled,omitempty"`
+ BaseURL string `json:"base_url"`
+ APIToken string `json:"api_token"`
+ AuthMethod ZammadAuthMethod `json:"auth_method,omitempty"`
+ OAuthClientID string `json:"oauth_client_id,omitempty"`
+ OAuthClientSecret string `json:"oauth_client_secret,omitempty"`
+ DefaultGroupID int `json:"default_group_id,omitempty"`
+ DefaultGroupName string `json:"default_group_name,omitempty"`
+ AllowedGroupIDs []int `json:"allowed_group_ids,omitempty"`
+ DefaultCustomer string `json:"default_customer"`
+ CorrelationField string `json:"correlation_field,omitempty"`
+ ClosedStateIDs []int `json:"closed_state_ids,omitempty"`
+ CompletionStatusID *int `json:"completion_status_id,omitempty"`
+ AppliesToAllWorkspaces *bool `json:"applies_to_all_workspaces,omitempty"`
+ WorkspaceIDs []int `json:"workspace_ids,omitempty"`
+}
+
+type UpdateZammadConnectionRequest struct {
+ Slug *string `json:"slug,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Enabled *bool `json:"enabled,omitempty"`
+ BaseURL *string `json:"base_url,omitempty"`
+ APIToken *string `json:"api_token,omitempty"`
+ AuthMethod *ZammadAuthMethod `json:"auth_method,omitempty"`
+ OAuthClientID *string `json:"oauth_client_id,omitempty"`
+ OAuthClientSecret *string `json:"oauth_client_secret,omitempty"`
+ DefaultGroupID *int `json:"default_group_id,omitempty"`
+ DefaultGroupName *string `json:"default_group_name,omitempty"`
+ AllowedGroupIDs *[]int `json:"allowed_group_ids,omitempty"`
+ DefaultCustomer *string `json:"default_customer,omitempty"`
+ CorrelationField *string `json:"correlation_field,omitempty"`
+ ClosedStateIDs *[]int `json:"closed_state_ids,omitempty"`
+ CompletionStatusID *int `json:"completion_status_id,omitempty"`
+ ClearCompletionStatus bool `json:"clear_completion_status,omitempty"`
+ AppliesToAllWorkspaces *bool `json:"applies_to_all_workspaces,omitempty"`
+ WorkspaceIDs *[]int `json:"workspace_ids,omitempty"`
+}
+
+type ZammadGroup struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Active bool `json:"active"`
+}
+
+type ZammadState struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ StateTypeID int `json:"state_type_id"`
+ Active bool `json:"active"`
+}
+
+type ZammadConnectionMetadata struct {
+ Groups []ZammadGroup `json:"groups"`
+ States []ZammadState `json:"states"`
+ CorrelationFieldVerified bool `json:"correlation_field_verified"`
+}
+
+type CreateZammadTicketRequest struct {
+ ConnectionID string `json:"connection_id"`
+ GroupID int `json:"group_id,omitempty"`
+}
+
+type LinkZammadTicketRequest struct {
+ ConnectionID string `json:"connection_id"`
+ TicketNumber string `json:"ticket_number"`
+}
+
+type UpdateZammadTicketLinkRequest struct {
+ StateID *int `json:"state_id,omitempty"`
+ GroupID *int `json:"group_id,omitempty"`
+ OwnerID *int `json:"owner_id,omitempty"`
+}
+
+type ZammadOwner struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+}
+
+type ZammadTicketLink struct {
+ ID string `json:"id"`
+ ItemID int `json:"item_id"`
+ ProviderID string `json:"connection_id"`
+ ProviderName string `json:"connection_name,omitempty"`
+ ItemIntegrationLinkID string `json:"item_integration_link_id,omitempty"`
+ TicketID int `json:"ticket_id,omitempty"`
+ TicketNumber string `json:"ticket_number,omitempty"`
+ TicketURL string `json:"ticket_url,omitempty"`
+ GroupID int `json:"group_id,omitempty"`
+ GroupName string `json:"group_name,omitempty"`
+ OwnerID int `json:"owner_id,omitempty"`
+ OwnerName string `json:"owner_name,omitempty"`
+ CorrelationKey string `json:"correlation_key"`
+ SyncState ZammadSyncState `json:"sync_state"`
+ LastStatusID int `json:"last_status_id,omitempty"`
+ LastStatusName string `json:"last_status_name,omitempty"`
+ LastSyncedAt *time.Time `json:"last_synced_at,omitempty"`
+ LastAttemptAt *time.Time `json:"-"`
+ NextAttemptAt *time.Time `json:"-"`
+ LastError string `json:"last_error,omitempty"`
+ CompletionApplied bool `json:"-"`
+ CreatedBy *int `json:"created_by,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type ZammadTicketLinkResponse struct {
+ ID string `json:"id"`
+ ProviderID string `json:"connection_id"`
+ ProviderName string `json:"connection_name,omitempty"`
+ TicketID int `json:"ticket_id,omitempty"`
+ TicketNumber string `json:"ticket_number,omitempty"`
+ TicketURL string `json:"ticket_url,omitempty"`
+ GroupID int `json:"group_id,omitempty"`
+ GroupName string `json:"group_name,omitempty"`
+ OwnerID int `json:"owner_id,omitempty"`
+ OwnerName string `json:"owner_name,omitempty"`
+ SyncState ZammadSyncState `json:"sync_state"`
+ LastStatusID int `json:"last_status_id,omitempty"`
+ LastStatusName string `json:"last_status_name,omitempty"`
+ LastSyncedAt *time.Time `json:"last_synced_at,omitempty"`
+ LastError string `json:"last_error,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func (link *ZammadTicketLink) Response() ZammadTicketLinkResponse {
+ return ZammadTicketLinkResponse{
+ ID: link.ID, ProviderID: link.ProviderID, ProviderName: link.ProviderName,
+ TicketID: link.TicketID, TicketNumber: link.TicketNumber, TicketURL: link.TicketURL,
+ GroupID: link.GroupID, GroupName: link.GroupName, SyncState: link.SyncState,
+ OwnerID: link.OwnerID, OwnerName: link.OwnerName,
+ LastStatusID: link.LastStatusID, LastStatusName: link.LastStatusName,
+ LastSyncedAt: link.LastSyncedAt, LastError: link.LastError,
+ CreatedAt: link.CreatedAt, UpdatedAt: link.UpdatedAt,
+ }
+}
diff --git a/internal/repository/action_credential_repository.go b/internal/repository/action_credential_repository.go
index 65f81e4f2..2c95e9e25 100644
--- a/internal/repository/action_credential_repository.go
+++ b/internal/repository/action_credential_repository.go
@@ -251,6 +251,41 @@ func (r *ActionCredentialRepository) UpdateActionCredentialMetadataWithWorkspace
})
}
+// UpdateManagedCredentialTx updates a provider-owned credential inside the
+// caller's transaction so provider policy and credential scope cannot diverge.
+func (r *ActionCredentialRepository) UpdateManagedCredentialTx(tx database.Tx, c *models.ActionCredential, workspaceIDs []int, encryptedSecret, prefix *string) error {
+ if err := updateActionCredentialMetadata(tx, c); err != nil {
+ return err
+ }
+ if err := setCredentialWorkspaces(tx, c.ID, workspaceIDs); err != nil {
+ return err
+ }
+ if encryptedSecret != nil && prefix != nil {
+ _, err := tx.ExecWrite(`UPDATE action_credentials
+ SET encrypted_secret = ?, secret_prefix = ?, updated_at = ? WHERE id = ?`,
+ *encryptedSecret, nullableString(*prefix), time.Now(), c.ID)
+ return err
+ }
+ return nil
+}
+
+// UpdateManagedCredentialSecretTx rotates only secret material. The metadata
+// predicate keeps the provider purpose/owner check valid at the write point,
+// while deliberately leaving name, enabled state, and workspace scope alone.
+func (r *ActionCredentialRepository) UpdateManagedCredentialSecretTx(tx database.Tx, id int, expectedMetadata, encryptedSecret, prefix string) (bool, error) {
+ if strings.TrimSpace(encryptedSecret) == "" {
+ return false, errors.New("managed action credential: encrypted_secret is required")
+ }
+ result, err := tx.ExecWrite(`UPDATE action_credentials
+ SET encrypted_secret = ?, secret_prefix = ?, updated_at = ?
+ WHERE id = ? AND secret_metadata = ?`, encryptedSecret, nullableString(prefix), time.Now(), id, expectedMetadata)
+ if err != nil {
+ return false, fmt.Errorf("failed to rotate managed action credential: %w", err)
+ }
+ rows, err := result.RowsAffected()
+ return rows == 1, err
+}
+
// RotateActionCredential replaces the encrypted secret and prefix on an
// existing credential row.
func (r *ActionCredentialRepository) RotateActionCredential(id int, encryptedSecret, prefix string) error {
diff --git a/internal/repository/integration_provider_repository.go b/internal/repository/integration_provider_repository.go
index d4b180eef..bff7ee4d6 100644
--- a/internal/repository/integration_provider_repository.go
+++ b/internal/repository/integration_provider_repository.go
@@ -65,10 +65,11 @@ type IntegrationProviderUpdate struct {
}
const integrationProviderColumns = "id, slug, name, provider_type, enabled, oauth_client_id, oauth_client_secret_encrypted, provider_config, created_at, updated_at"
+const genericIntegrationProviderFilter = "provider_type IN ('notion', 'todoist')"
// List returns all integration_providers ordered by name.
func (r *IntegrationProviderRepository) List() ([]IntegrationProvider, error) {
- rows, err := r.db.Query("SELECT " + integrationProviderColumns + " FROM integration_providers ORDER BY name")
+ rows, err := r.db.Query("SELECT " + integrationProviderColumns + " FROM integration_providers WHERE " + genericIntegrationProviderFilter + " ORDER BY name")
if err != nil {
return nil, fmt.Errorf("list integration_providers: %w", err)
}
@@ -90,7 +91,7 @@ func (r *IntegrationProviderRepository) List() ([]IntegrationProvider, error) {
// GetByID returns a single integration_provider or ErrNotFound.
func (r *IntegrationProviderRepository) GetByID(id string) (*IntegrationProvider, error) {
- row := r.db.QueryRow("SELECT "+integrationProviderColumns+" FROM integration_providers WHERE id = ?", id)
+ row := r.db.QueryRow("SELECT "+integrationProviderColumns+" FROM integration_providers WHERE id = ? AND "+genericIntegrationProviderFilter, id)
p, err := scanIntegrationProvider(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -167,7 +168,7 @@ func (r *IntegrationProviderRepository) Update(id string, req IntegrationProvide
sets = append(sets, "updated_at = CURRENT_TIMESTAMP")
args = append(args, id)
- query := "UPDATE integration_providers SET " + strings.Join(sets, ", ") + " WHERE id = ?"
+ query := "UPDATE integration_providers SET " + strings.Join(sets, ", ") + " WHERE id = ? AND " + genericIntegrationProviderFilter
result, err := r.db.ExecWrite(query, args...)
if err != nil {
if database.IsUniqueConstraintError(err) {
@@ -185,7 +186,7 @@ func (r *IntegrationProviderRepository) Update(id string, req IntegrationProvide
// Delete removes a row. Returns ErrNotFound when no row matches.
func (r *IntegrationProviderRepository) Delete(id string) error {
- result, err := r.db.ExecWrite("DELETE FROM integration_providers WHERE id = ?", id)
+ result, err := r.db.ExecWrite("DELETE FROM integration_providers WHERE id = ? AND "+genericIntegrationProviderFilter, id)
if err != nil {
return fmt.Errorf("delete integration_provider %s: %w", id, err)
}
diff --git a/internal/repository/zammad_oauth_repository.go b/internal/repository/zammad_oauth_repository.go
new file mode 100644
index 000000000..ceef1d377
--- /dev/null
+++ b/internal/repository/zammad_oauth_repository.go
@@ -0,0 +1,260 @@
+package repository
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "windshift/internal/database"
+ "windshift/internal/models"
+)
+
+// ZammadOAuthToken is private persistence state. It deliberately contains
+// ciphertext only and is never returned by handlers.
+type ZammadOAuthToken struct {
+ ProviderID string
+ OAuthGeneration int64
+ ExpiresAt time.Time
+ ReauthorizationRequired bool
+}
+
+type ZammadOAuthState struct {
+ ProviderID string
+ InitiatedBy int
+ OAuthGeneration int64
+}
+
+func (r *ZammadRepository) CreateOAuthState(state, providerID string, initiatedBy int, generation int64, expiresAt time.Time) error {
+ // Expired-state cleanup is deliberately outside the per-connection
+ // transaction. PostgreSQL must never hold an OAuth-state lock while waiting
+ // for the connection row, since reset paths lock in the opposite order.
+ if _, err := r.db.ExecWrite("DELETE FROM zammad_oauth_state WHERE expires_at <= CURRENT_TIMESTAMP"); err != nil {
+ return err
+ }
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ claim, err := tx.Exec(`UPDATE zammad_connections SET oauth_attempt_id = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE provider_id = ? AND oauth_generation = ?`, state, providerID, generation)
+ if err != nil {
+ return err
+ }
+ rows, err := claim.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return ErrNotFound
+ }
+ result, err := tx.Exec(`INSERT INTO zammad_oauth_state(state, provider_id, initiated_by, oauth_generation, expires_at)
+ SELECT ?, provider_id, ?, oauth_generation, ? FROM zammad_connections
+ WHERE provider_id = ? AND oauth_generation = ?
+ ON CONFLICT(provider_id) DO UPDATE SET
+ state = excluded.state, initiated_by = excluded.initiated_by,
+ oauth_generation = excluded.oauth_generation, expires_at = excluded.expires_at,
+ created_at = CURRENT_TIMESTAMP`, state, initiatedBy, expiresAt, providerID, generation)
+ if err != nil {
+ return err
+ }
+ rows, err = result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return ErrNotFound
+ }
+ return nil
+ })
+}
+
+// ConsumeOAuthState atomically deletes a usable state. A second callback, an
+// expired state, or a state for another connection cannot observe it.
+func (r *ZammadRepository) ConsumeOAuthState(state string) (*ZammadOAuthState, error) {
+ consumed := &ZammadOAuthState{}
+ err := r.db.QueryRow(`DELETE FROM zammad_oauth_state
+ WHERE state = ? AND expires_at > CURRENT_TIMESTAMP
+ RETURNING provider_id, initiated_by, oauth_generation`, state).Scan(&consumed.ProviderID, &consumed.InitiatedBy, &consumed.OAuthGeneration)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("consume Zammad OAuth state: %w", err)
+ }
+ return consumed, nil
+}
+
+// InvalidateOAuthState discards an abandoned provider callback without
+// returning any state metadata. It is intentionally idempotent.
+func (r *ZammadRepository) InvalidateOAuthState(state string) error {
+ var providerID string
+ var generation int64
+ err := r.db.QueryRow(`SELECT provider_id, oauth_generation
+ FROM zammad_oauth_state WHERE state = ?`, state).Scan(&providerID, &generation)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ // Keep the same connection -> state lock order as StartOAuth and reset.
+ if _, err := tx.Exec(`UPDATE zammad_connections SET oauth_attempt_id = NULL
+ WHERE provider_id = ? AND oauth_generation = ? AND oauth_attempt_id = ?`, providerID, generation, state); err != nil {
+ return err
+ }
+ _, err := tx.Exec("DELETE FROM zammad_oauth_state WHERE state = ?", state)
+ return err
+ })
+}
+
+func (r *ZammadRepository) UpsertOAuthToken(token ZammadOAuthToken) error {
+ if token.OAuthGeneration == 0 {
+ if err := r.db.QueryRow("SELECT oauth_generation FROM zammad_connections WHERE provider_id = ?", token.ProviderID).Scan(&token.OAuthGeneration); err != nil {
+ return err
+ }
+ }
+ _, err := r.db.ExecWrite(`INSERT INTO zammad_oauth_tokens
+ (provider_id, oauth_generation, expires_at, reauthorization_required, refresh_lock_until, refresh_claim_owner, updated_at)
+ VALUES (?, ?, ?, false, NULL, NULL, CURRENT_TIMESTAMP)
+ ON CONFLICT(provider_id) DO UPDATE SET
+ oauth_generation = excluded.oauth_generation,
+ expires_at = excluded.expires_at,
+ reauthorization_required = false,
+ refresh_lock_until = NULL,
+ refresh_claim_owner = NULL,
+ updated_at = CURRENT_TIMESTAMP`, token.ProviderID, token.OAuthGeneration, token.ExpiresAt)
+ return err
+}
+
+func (r *ZammadRepository) UpsertOAuthTokenTx(tx database.Tx, token ZammadOAuthToken) error {
+ _, err := tx.Exec(`INSERT INTO zammad_oauth_tokens
+ (provider_id, oauth_generation, expires_at, reauthorization_required, refresh_lock_until, refresh_claim_owner, updated_at)
+ VALUES (?, ?, ?, false, NULL, NULL, CURRENT_TIMESTAMP)
+ ON CONFLICT(provider_id) DO UPDATE SET expires_at = excluded.expires_at,
+ oauth_generation = excluded.oauth_generation, reauthorization_required = false,
+ refresh_lock_until = NULL, refresh_claim_owner = NULL, updated_at = CURRENT_TIMESTAMP`, token.ProviderID, token.OAuthGeneration, token.ExpiresAt)
+ return err
+}
+
+func (r *ZammadRepository) GetOAuthToken(providerID string) (*ZammadOAuthToken, error) {
+ t := &ZammadOAuthToken{ProviderID: providerID}
+ err := r.db.QueryRow(`SELECT oauth_generation, expires_at, reauthorization_required
+ FROM zammad_oauth_tokens WHERE provider_id = ?`, providerID).Scan(&t.OAuthGeneration, &t.ExpiresAt, &t.ReauthorizationRequired)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get Zammad OAuth token: %w", err)
+ }
+ return t, nil
+}
+
+func (r *ZammadRepository) GetOAuthTokenForRefreshClaim(providerID string, generation int64, owner string) (*ZammadOAuthToken, error) {
+ t := &ZammadOAuthToken{ProviderID: providerID}
+ err := r.db.QueryRow(`SELECT oauth_generation, expires_at, reauthorization_required
+ FROM zammad_oauth_tokens WHERE provider_id = ? AND oauth_generation = ?
+ AND refresh_claim_owner = ? AND refresh_lock_until > CURRENT_TIMESTAMP`, providerID, generation, owner).
+ Scan(&t.OAuthGeneration, &t.ExpiresAt, &t.ReauthorizationRequired)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get claimed Zammad OAuth token: %w", err)
+ }
+ return t, nil
+}
+
+func (r *ZammadRepository) ClaimOAuthRefresh(providerID string, generation int64, owner string, until time.Time) (bool, error) {
+ result, err := r.db.ExecWrite(`UPDATE zammad_oauth_tokens
+ SET refresh_lock_until = ?, refresh_claim_owner = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE provider_id = ? AND oauth_generation = ? AND reauthorization_required = false
+ AND (refresh_lock_until IS NULL OR refresh_lock_until < CURRENT_TIMESTAMP)
+ AND EXISTS (SELECT 1 FROM zammad_connections zc WHERE zc.provider_id = zammad_oauth_tokens.provider_id AND zc.oauth_generation = ?)`, until, owner, providerID, generation, generation)
+ if err != nil {
+ return false, err
+ }
+ n, err := result.RowsAffected()
+ return n == 1, err
+}
+
+func (r *ZammadRepository) ReleaseOAuthRefreshClaim(providerID string, generation int64, owner string) error {
+ _, err := r.db.ExecWrite(`UPDATE zammad_oauth_tokens
+ SET refresh_lock_until = NULL, refresh_claim_owner = NULL, updated_at = CURRENT_TIMESTAMP
+ WHERE provider_id = ? AND oauth_generation = ? AND refresh_claim_owner = ?`, providerID, generation, owner)
+ return err
+}
+
+func (r *ZammadRepository) GuardOAuthGenerationTx(tx database.Tx, providerID string, generation int64) (bool, error) {
+ result, err := tx.Exec(`UPDATE zammad_connections SET oauth_generation = oauth_generation
+ WHERE provider_id = ? AND oauth_generation = ?`, providerID, generation)
+ if err != nil {
+ return false, err
+ }
+ rows, err := result.RowsAffected()
+ return rows == 1, err
+}
+
+func (r *ZammadRepository) GuardOAuthCallbackTx(tx database.Tx, providerID string, generation int64, attemptID string) (bool, error) {
+ result, err := tx.Exec(`UPDATE zammad_connections SET oauth_attempt_id = NULL
+ WHERE provider_id = ? AND oauth_generation = ? AND oauth_attempt_id = ?`, providerID, generation, attemptID)
+ if err != nil {
+ return false, err
+ }
+ rows, err := result.RowsAffected()
+ return rows == 1, err
+}
+
+func (r *ZammadRepository) ClearOAuthAttempt(providerID string, generation int64, attemptID string) error {
+ _, err := r.db.ExecWrite(`UPDATE zammad_connections SET oauth_attempt_id = NULL
+ WHERE provider_id = ? AND oauth_generation = ? AND oauth_attempt_id = ?`, providerID, generation, attemptID)
+ return err
+}
+
+func (r *ZammadRepository) GuardOAuthRefreshClaimTx(tx database.Tx, providerID string, generation int64, owner string) (bool, error) {
+ result, err := tx.Exec(`UPDATE zammad_oauth_tokens SET updated_at = updated_at
+ WHERE provider_id = ? AND oauth_generation = ? AND refresh_claim_owner = ?
+ AND refresh_lock_until > CURRENT_TIMESTAMP AND reauthorization_required = false`, providerID, generation, owner)
+ if err != nil {
+ return false, err
+ }
+ rows, err := result.RowsAffected()
+ return rows == 1, err
+}
+
+func (r *ZammadRepository) MarkOAuthReauthorizationRequiredTx(tx database.Tx, providerID string, generation int64, owner string) (bool, error) {
+ result, err := tx.Exec(`UPDATE zammad_oauth_tokens
+ SET reauthorization_required = true, refresh_lock_until = NULL, refresh_claim_owner = NULL, updated_at = CURRENT_TIMESTAMP
+ WHERE provider_id = ? AND oauth_generation = ? AND refresh_claim_owner = ?`, providerID, generation, owner)
+ if err != nil {
+ return false, err
+ }
+ rows, err := result.RowsAffected()
+ return rows == 1, err
+}
+
+// ResetOAuthAuthorizationTx removes all token metadata and in-flight states
+// when OAuth client credentials change. The matching credential payload is
+// reset by the service in the same transaction.
+func (r *ZammadRepository) ResetOAuthAuthorizationTx(tx database.Tx, providerID string) error {
+ result, err := tx.Exec(`UPDATE zammad_connections
+ SET oauth_generation = oauth_generation + 1, oauth_attempt_id = NULL, updated_at = CURRENT_TIMESTAMP
+ WHERE provider_id = ?`, providerID)
+ if err != nil {
+ return err
+ }
+ if rows, err := result.RowsAffected(); err != nil || rows != 1 {
+ if err != nil {
+ return err
+ }
+ return ErrNotFound
+ }
+ if _, err := tx.Exec("DELETE FROM zammad_oauth_tokens WHERE provider_id = ?", providerID); err != nil {
+ return err
+ }
+ _, err = tx.Exec("DELETE FROM zammad_oauth_state WHERE provider_id = ?", providerID)
+ return err
+}
+
+func (r *ZammadRepository) SetOAuthAuthMethod(connection *models.ZammadConnection) error {
+ _, err := r.db.ExecWrite(`UPDATE zammad_connections SET credential_id = ?, auth_method = ?, updated_at = CURRENT_TIMESTAMP WHERE provider_id = ?`, nullablePositiveInt(connection.CredentialID), connection.AuthMethod, connection.ProviderID)
+ return err
+}
diff --git a/internal/repository/zammad_repository.go b/internal/repository/zammad_repository.go
new file mode 100644
index 000000000..c9c717aae
--- /dev/null
+++ b/internal/repository/zammad_repository.go
@@ -0,0 +1,650 @@
+package repository
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "windshift/internal/database"
+ "windshift/internal/models"
+)
+
+type ZammadRepository struct {
+ db database.Database
+}
+
+func NewZammadRepository(db database.Database) *ZammadRepository {
+ return &ZammadRepository{db: db}
+}
+
+const zammadConnectionColumns = `
+ zc.provider_id, ip.slug, ip.name, ip.enabled, zc.base_url,
+ zc.credential_id, zc.auth_method, zc.oauth_generation, COALESCE(zc.oauth_attempt_id, ''), ip.oauth_client_id, ip.oauth_client_secret_encrypted,
+ EXISTS(SELECT 1 FROM zammad_oauth_tokens zot WHERE zot.provider_id = zc.provider_id AND zot.reauthorization_required = false),
+ COALESCE((SELECT reauthorization_required FROM zammad_oauth_tokens zot WHERE zot.provider_id = zc.provider_id), false),
+ zc.default_group_id, zc.default_group_name,
+ zc.allowed_group_ids, zc.default_customer, zc.correlation_field, zc.closed_state_ids,
+ zc.completion_status_id, zc.applies_to_all_workspaces,
+ zc.last_tested_at, zc.last_test_error, zc.created_by,
+ zc.created_at, zc.updated_at`
+
+func (r *ZammadRepository) ListConnections() ([]*models.ZammadConnection, error) {
+ rows, err := r.db.Query(`SELECT ` + zammadConnectionColumns + `
+ FROM zammad_connections zc
+ JOIN integration_providers ip ON ip.id = zc.provider_id
+ ORDER BY ip.name`)
+ if err != nil {
+ return nil, fmt.Errorf("list Zammad connections: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ return r.scanConnections(rows)
+}
+
+func (r *ZammadRepository) ListConnectionsForWorkspace(workspaceID int) ([]*models.ZammadConnection, error) {
+ rows, err := r.db.Query(`SELECT `+zammadConnectionColumns+`
+ FROM zammad_connections zc
+ JOIN integration_providers ip ON ip.id = zc.provider_id
+ WHERE ip.enabled = true AND (
+ zc.applies_to_all_workspaces = true OR EXISTS (
+ SELECT 1 FROM zammad_connection_workspaces zcw
+ WHERE zcw.provider_id = zc.provider_id AND zcw.workspace_id = ?
+ )
+ )
+ ORDER BY ip.name`, workspaceID)
+ if err != nil {
+ return nil, fmt.Errorf("list workspace Zammad connections: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ return r.scanConnections(rows)
+}
+
+func (r *ZammadRepository) GetConnection(id string) (*models.ZammadConnection, error) {
+ row := r.db.QueryRow(`SELECT `+zammadConnectionColumns+`
+ FROM zammad_connections zc
+ JOIN integration_providers ip ON ip.id = zc.provider_id
+ WHERE zc.provider_id = ?`, id)
+ connection, err := scanZammadConnection(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get Zammad connection: %w", err)
+ }
+ connection.WorkspaceIDs, err = r.connectionWorkspaceIDs(id)
+ if err != nil {
+ return nil, err
+ }
+ return connection, nil
+}
+
+func (r *ZammadRepository) IsConnectionAvailableToWorkspace(id string, workspaceID int) (bool, error) {
+ var available bool
+ err := r.db.QueryRow(`SELECT EXISTS(
+ SELECT 1 FROM zammad_connections zc
+ JOIN integration_providers ip ON ip.id = zc.provider_id
+ WHERE zc.provider_id = ? AND ip.enabled = true AND (
+ zc.applies_to_all_workspaces = true OR EXISTS (
+ SELECT 1 FROM zammad_connection_workspaces zcw
+ WHERE zcw.provider_id = zc.provider_id AND zcw.workspace_id = ?
+ )
+ ))`, id, workspaceID).Scan(&available)
+ return available, err
+}
+
+func (r *ZammadRepository) CreateConnection(connection *models.ZammadConnection) error {
+ closedJSON, err := json.Marshal(connection.ClosedStateIDs)
+ if err != nil {
+ return err
+ }
+ allowedGroupsJSON, err := json.Marshal(connection.AllowedGroupIDs)
+ if err != nil {
+ return err
+ }
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ if _, err := tx.Exec(`INSERT INTO integration_providers
+ (id, slug, name, provider_type, enabled, oauth_client_id, oauth_client_secret_encrypted, provider_config)
+ VALUES (?, ?, ?, ?, ?, ?, ?, '{}')`, connection.ProviderID, connection.Slug,
+ connection.Name, models.IntegrationProviderZammad, connection.Enabled,
+ nullableString(connection.OAuthClientID), nullableString(connection.OAuthClientSecretEncrypted)); err != nil {
+ if database.IsUniqueConstraintError(err) {
+ return ErrDuplicateEntry
+ }
+ return err
+ }
+ if _, err := tx.Exec(`INSERT INTO zammad_connections
+ (provider_id, credential_id, auth_method, base_url, default_group_id,
+ default_group_name, allowed_group_ids, default_customer, correlation_field,
+ closed_state_ids, completion_status_id, applies_to_all_workspaces,
+ created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, connection.ProviderID,
+ nullablePositiveInt(connection.CredentialID), connection.AuthMethod, connection.BaseURL, nullablePositiveInt(connection.DefaultGroupID),
+ connection.DefaultGroupName, string(allowedGroupsJSON), connection.DefaultCustomer, connection.CorrelationField,
+ string(closedJSON), connection.CompletionStatusID,
+ connection.AppliesToAllWorkspaces, connection.CreatedBy); err != nil {
+ return err
+ }
+ return replaceZammadConnectionWorkspaces(tx, connection.ProviderID, connection.AppliesToAllWorkspaces, connection.WorkspaceIDs)
+ })
+}
+
+func (r *ZammadRepository) UpdateConnection(connection *models.ZammadConnection) error {
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ return r.UpdateConnectionTx(tx, connection)
+ })
+}
+
+func (r *ZammadRepository) UpdateConnectionTx(tx database.Tx, connection *models.ZammadConnection) error {
+ closedJSON, err := json.Marshal(connection.ClosedStateIDs)
+ if err != nil {
+ return err
+ }
+ allowedGroupsJSON, err := json.Marshal(connection.AllowedGroupIDs)
+ if err != nil {
+ return err
+ }
+ result, err := tx.Exec(`UPDATE integration_providers
+ SET slug = ?, name = ?, enabled = ?, oauth_client_id = ?, oauth_client_secret_encrypted = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND provider_type = ?`, connection.Slug, connection.Name,
+ connection.Enabled, nullableString(connection.OAuthClientID), nullableString(connection.OAuthClientSecretEncrypted), connection.ProviderID, models.IntegrationProviderZammad)
+ if err != nil {
+ if database.IsUniqueConstraintError(err) {
+ return ErrDuplicateEntry
+ }
+ return err
+ }
+ if rows, _ := result.RowsAffected(); rows == 0 {
+ return ErrNotFound
+ }
+ _, err = tx.Exec(`UPDATE zammad_connections SET
+ credential_id = ?, auth_method = ?, base_url = ?, default_group_id = ?, default_group_name = ?, allowed_group_ids = ?,
+ default_customer = ?, correlation_field = ?, closed_state_ids = ?,
+ completion_status_id = ?, applies_to_all_workspaces = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE provider_id = ?`, nullablePositiveInt(connection.CredentialID), connection.AuthMethod, connection.BaseURL, nullablePositiveInt(connection.DefaultGroupID),
+ connection.DefaultGroupName, string(allowedGroupsJSON), connection.DefaultCustomer, connection.CorrelationField,
+ string(closedJSON), connection.CompletionStatusID,
+ connection.AppliesToAllWorkspaces, connection.ProviderID)
+ if err != nil {
+ return err
+ }
+ return replaceZammadConnectionWorkspaces(tx, connection.ProviderID, connection.AppliesToAllWorkspaces, connection.WorkspaceIDs)
+}
+
+func (r *ZammadRepository) DeleteConnection(id string) error {
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ var credentialID sql.NullInt64
+ if err := tx.QueryRow("SELECT credential_id FROM zammad_connections WHERE provider_id = ?", id).Scan(&credentialID); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
+ return err
+ }
+ if _, err := tx.Exec("DELETE FROM integration_providers WHERE id = ?", id); err != nil {
+ return err
+ }
+ if credentialID.Valid {
+ _, err := tx.Exec("DELETE FROM action_credentials WHERE id = ?", credentialID.Int64)
+ return err
+ }
+ return nil
+ })
+}
+
+func (r *ZammadRepository) HasTicketLinksForConnection(id string) (bool, error) {
+ var hasLinks bool
+ err := r.db.QueryRow("SELECT EXISTS(SELECT 1 FROM zammad_ticket_links WHERE provider_id = ?)", id).Scan(&hasLinks)
+ return hasLinks, err
+}
+
+func (r *ZammadRepository) SetConnectionTestResult(id string, testedAt time.Time, testError string) error {
+ _, err := r.db.ExecWrite(`UPDATE zammad_connections
+ SET last_tested_at = ?, last_test_error = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE provider_id = ?`, testedAt, testError, id)
+ return err
+}
+
+func (r *ZammadRepository) scanConnections(rows *sql.Rows) ([]*models.ZammadConnection, error) {
+ connections := []*models.ZammadConnection{}
+ for rows.Next() {
+ connection, err := scanZammadConnection(rows)
+ if err != nil {
+ return nil, err
+ }
+ connection.WorkspaceIDs, err = r.connectionWorkspaceIDs(connection.ProviderID)
+ if err != nil {
+ return nil, err
+ }
+ connections = append(connections, connection)
+ }
+ return connections, rows.Err()
+}
+
+func scanZammadConnection(scanner interface{ Scan(...any) error }) (*models.ZammadConnection, error) {
+ var connection models.ZammadConnection
+ var credentialID, groupID, completionStatusID, createdBy sql.NullInt64
+ var groupName, lastError sql.NullString
+ var authMethod, oauthClientID, oauthClientSecret sql.NullString
+ var testedAt sql.NullTime
+ var oauthConnected, reauthorizationRequired bool
+ var allowedGroupsJSON, closedJSON string
+ err := scanner.Scan(&connection.ProviderID, &connection.Slug, &connection.Name,
+ &connection.Enabled, &connection.BaseURL, &credentialID, &authMethod, &connection.OAuthGeneration, &connection.OAuthAttemptID, &oauthClientID, &oauthClientSecret,
+ &oauthConnected, &reauthorizationRequired, &groupID,
+ &groupName, &allowedGroupsJSON, &connection.DefaultCustomer, &connection.CorrelationField,
+ &closedJSON, &completionStatusID, &connection.AppliesToAllWorkspaces,
+ &testedAt, &lastError, &createdBy, &connection.CreatedAt, &connection.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ if groupID.Valid {
+ connection.DefaultGroupID = int(groupID.Int64)
+ }
+ if credentialID.Valid {
+ v := int(credentialID.Int64)
+ connection.CredentialID = v
+ }
+ if authMethod.Valid {
+ connection.AuthMethod = models.ZammadAuthMethod(authMethod.String)
+ }
+ if oauthClientID.Valid {
+ connection.OAuthClientID = oauthClientID.String
+ }
+ connection.HasOAuthClientSecret = oauthClientSecret.Valid && oauthClientSecret.String != ""
+ if oauthClientSecret.Valid {
+ connection.OAuthClientSecretEncrypted = oauthClientSecret.String
+ }
+ connection.OAuthConnected = oauthConnected
+ connection.ReauthorizationRequired = reauthorizationRequired
+ if groupName.Valid {
+ connection.DefaultGroupName = groupName.String
+ }
+ if completionStatusID.Valid {
+ v := int(completionStatusID.Int64)
+ connection.CompletionStatusID = &v
+ }
+ if testedAt.Valid {
+ connection.LastTestedAt = &testedAt.Time
+ }
+ if lastError.Valid {
+ connection.LastTestError = lastError.String
+ }
+ if createdBy.Valid {
+ v := int(createdBy.Int64)
+ connection.CreatedBy = &v
+ }
+ if err := json.Unmarshal([]byte(closedJSON), &connection.ClosedStateIDs); err != nil {
+ return nil, fmt.Errorf("decode closed_state_ids: %w", err)
+ }
+ if err := json.Unmarshal([]byte(allowedGroupsJSON), &connection.AllowedGroupIDs); err != nil {
+ return nil, fmt.Errorf("decode allowed_group_ids: %w", err)
+ }
+ connection.HasAPIToken = connection.AuthMethod == models.ZammadAuthMethodAPIToken && connection.CredentialID > 0
+ return &connection, nil
+}
+
+func (r *ZammadRepository) connectionWorkspaceIDs(providerID string) ([]int, error) {
+ rows, err := r.db.Query(`SELECT workspace_id FROM zammad_connection_workspaces
+ WHERE provider_id = ? ORDER BY workspace_id`, providerID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ ids := []int{}
+ for rows.Next() {
+ var id int
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ return ids, rows.Err()
+}
+
+func replaceZammadConnectionWorkspaces(tx database.Tx, providerID string, appliesAll bool, workspaceIDs []int) error {
+ if _, err := tx.Exec("DELETE FROM zammad_connection_workspaces WHERE provider_id = ?", providerID); err != nil {
+ return err
+ }
+ if appliesAll {
+ return nil
+ }
+ for _, workspaceID := range workspaceIDs {
+ if _, err := tx.Exec(`INSERT INTO zammad_connection_workspaces(provider_id, workspace_id)
+ VALUES (?, ?)`, providerID, workspaceID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (r *ZammadRepository) GetTicketLinksForItem(itemID int) ([]*models.ZammadTicketLink, error) {
+ rows, err := r.db.Query(`SELECT `+zammadTicketLinkColumns+`
+ FROM zammad_ticket_links ztl
+ JOIN integration_providers ip ON ip.id = ztl.provider_id
+ WHERE ztl.item_id = ? ORDER BY ztl.created_at DESC`, itemID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ links := []*models.ZammadTicketLink{}
+ for rows.Next() {
+ link, err := scanZammadTicketLink(rows)
+ if err != nil {
+ return nil, err
+ }
+ links = append(links, link)
+ }
+ return links, rows.Err()
+}
+
+const zammadTicketLinkColumns = `
+ ztl.id, ztl.item_id, ztl.provider_id, ip.name,
+ ztl.item_integration_link_id, ztl.ticket_id, ztl.ticket_number,
+ ztl.ticket_url, ztl.group_id, ztl.group_name, ztl.owner_id, ztl.owner_name, ztl.correlation_key,
+ ztl.sync_state, ztl.last_status_id, ztl.last_status_name,
+ ztl.last_synced_at, ztl.last_attempt_at, ztl.next_attempt_at, ztl.last_error, ztl.completion_applied, ztl.created_by,
+ ztl.created_at, ztl.updated_at`
+
+func (r *ZammadRepository) GetTicketLink(id string) (*models.ZammadTicketLink, error) {
+ link, err := scanZammadTicketLink(r.db.QueryRow(`SELECT `+zammadTicketLinkColumns+`
+ FROM zammad_ticket_links ztl
+ JOIN integration_providers ip ON ip.id = ztl.provider_id
+ WHERE ztl.id = ?`, id))
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return link, err
+}
+
+func (r *ZammadRepository) GetTicketLinkForItem(itemID int, providerID string) (*models.ZammadTicketLink, error) {
+ link, err := scanZammadTicketLink(r.db.QueryRow(`SELECT `+zammadTicketLinkColumns+`
+ FROM zammad_ticket_links ztl
+ JOIN integration_providers ip ON ip.id = ztl.provider_id
+ WHERE ztl.item_id = ? AND ztl.provider_id = ?`, itemID, providerID))
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return link, err
+}
+
+func (r *ZammadRepository) HasTicketLinks(providerID string) (bool, error) {
+ var exists bool
+ err := r.db.QueryRow("SELECT EXISTS(SELECT 1 FROM zammad_ticket_links WHERE provider_id = ?)", providerID).Scan(&exists)
+ return exists, err
+}
+
+func (r *ZammadRepository) CreatePendingTicketLink(link *models.ZammadTicketLink) error {
+ _, err := r.db.ExecWrite(`INSERT INTO zammad_ticket_links
+ (id, item_id, provider_id, group_id, group_name, correlation_key,
+ sync_state, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, link.ID, link.ItemID, link.ProviderID,
+ nullablePositiveInt(link.GroupID), link.GroupName, link.CorrelationKey,
+ models.ZammadSyncPending, link.CreatedBy)
+ if database.IsUniqueConstraintError(err) {
+ return ErrDuplicateEntry
+ }
+ return err
+}
+
+// ReserveExistingTicketLink claims the provider/ticket pair before changing
+// the remote correlation field. The unique constraint makes competing item
+// links fail closed rather than attaching one ticket to two Windshift items.
+func (r *ZammadRepository) ReserveExistingTicketLink(link *models.ZammadTicketLink) error {
+ _, err := r.db.ExecWrite(`INSERT INTO zammad_ticket_links
+ (id, item_id, provider_id, ticket_id, ticket_number, ticket_url, group_id, group_name,
+ owner_id, owner_name, correlation_key, sync_state, last_status_id, last_status_name, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ link.ID, link.ItemID, link.ProviderID, link.TicketID, link.TicketNumber, link.TicketURL,
+ nullablePositiveInt(link.GroupID), link.GroupName, nullablePositiveInt(link.OwnerID), link.OwnerName,
+ link.CorrelationKey, models.ZammadSyncCreating, nullablePositiveInt(link.LastStatusID), link.LastStatusName, link.CreatedBy)
+ if database.IsUniqueConstraintError(err) {
+ return ErrDuplicateEntry
+ }
+ return err
+}
+
+func (r *ZammadRepository) ClaimTicketCreation(itemID int, providerID string, now time.Time) (bool, error) {
+ staleBefore := now.Add(-2 * time.Minute)
+ result, err := r.db.ExecWrite(`UPDATE zammad_ticket_links
+ SET sync_state = ?, creating_started_at = ?, last_error = '', updated_at = CURRENT_TIMESTAMP
+ WHERE item_id = ? AND provider_id = ? AND (
+ sync_state IN (?, ?, ?) OR (sync_state = ? AND creating_started_at < ?)
+ )`, models.ZammadSyncCreating, now, itemID, providerID,
+ models.ZammadSyncPending, models.ZammadSyncFailed, models.ZammadSyncUncertain,
+ models.ZammadSyncCreating, staleBefore)
+ if err != nil {
+ return false, err
+ }
+ rows, _ := result.RowsAffected()
+ return rows == 1, nil
+}
+
+func (r *ZammadRepository) CompleteTicketCreation(linkID string, ticketID int, number, ticketURL string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName string, linkedBy int) error {
+ genericLinkID := linkID + "-external"
+ metadata, _ := json.Marshal(map[string]any{
+ "status_id": statusID, "status_name": statusName,
+ "group_id": groupID, "group_name": groupName,
+ "owner_id": ownerID, "owner_name": ownerName,
+ })
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ if _, err := tx.Exec(`INSERT INTO item_integration_links
+ (id, item_id, integration_provider_id, external_id, external_url,
+ title, icon, link_type, link_metadata, linked_by)
+ SELECT ?, item_id, provider_id, ?, ?, ?, ?, 'ticket', ?, ?
+ FROM zammad_ticket_links WHERE id = ?
+ ON CONFLICT (item_id, integration_provider_id, external_id) DO UPDATE SET
+ external_url = excluded.external_url,
+ title = excluded.title,
+ link_metadata = excluded.link_metadata,
+ updated_at = CURRENT_TIMESTAMP`,
+ genericLinkID, strconv.Itoa(ticketID), ticketURL, "Zammad #"+number,
+ "ticket", string(metadata), strconv.Itoa(linkedBy), linkID); err != nil {
+ return err
+ }
+ if err := tx.QueryRow(`SELECT iil.id FROM item_integration_links iil
+ JOIN zammad_ticket_links ztl ON CAST(ztl.item_id AS TEXT) = iil.item_id
+ AND ztl.provider_id = iil.integration_provider_id
+ WHERE ztl.id = ? AND iil.external_id = ?`, linkID, strconv.Itoa(ticketID)).Scan(&genericLinkID); err != nil {
+ return err
+ }
+ _, err := tx.Exec(`UPDATE zammad_ticket_links SET
+ item_integration_link_id = ?, ticket_id = ?, ticket_number = ?,
+ ticket_url = ?, group_id = ?, group_name = ?, owner_id = ?, owner_name = ?,
+ sync_state = ?, creating_started_at = NULL,
+ last_status_id = ?, last_status_name = ?, last_synced_at = CURRENT_TIMESTAMP,
+ last_attempt_at = CURRENT_TIMESTAMP, next_attempt_at = NULL,
+ last_error = '', updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?`, genericLinkID, ticketID, number, ticketURL,
+ nullablePositiveInt(groupID), groupName, nullablePositiveInt(ownerID), ownerName,
+ models.ZammadSyncLinked, nullablePositiveInt(statusID), statusName, linkID)
+ return err
+ })
+}
+
+func (r *ZammadRepository) CompleteExistingTicketLink(linkID, genericLinkID string, ticket *models.ZammadTicketLink, linkedBy int) error {
+ metadata, _ := json.Marshal(map[string]any{"status_id": ticket.LastStatusID, "status_name": ticket.LastStatusName, "group_id": ticket.GroupID, "group_name": ticket.GroupName, "owner_id": ticket.OwnerID, "owner_name": ticket.OwnerName})
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ if _, err := tx.Exec(`INSERT INTO item_integration_links
+ (id, item_id, integration_provider_id, external_id, external_url, title, icon, link_type, link_metadata, linked_by)
+ SELECT ?, item_id, provider_id, ?, ?, ?, ?, 'ticket', ?, ? FROM zammad_ticket_links WHERE id = ?
+ ON CONFLICT (item_id, integration_provider_id, external_id) DO UPDATE SET external_url=excluded.external_url, title=excluded.title, link_metadata=excluded.link_metadata, updated_at=CURRENT_TIMESTAMP`,
+ genericLinkID, strconv.Itoa(ticket.TicketID), ticket.TicketURL, "Zammad #"+ticket.TicketNumber, "ticket", string(metadata), strconv.Itoa(linkedBy), linkID); err != nil {
+ return err
+ }
+ _, err := tx.Exec(`UPDATE zammad_ticket_links SET item_integration_link_id=?, ticket_id=?, ticket_number=?, ticket_url=?, group_id=?, group_name=?, owner_id=?, owner_name=?, sync_state=?, creating_started_at=NULL, last_status_id=?, last_status_name=?, last_synced_at=CURRENT_TIMESTAMP, last_attempt_at=CURRENT_TIMESTAMP, next_attempt_at=NULL, last_error='', updated_at=CURRENT_TIMESTAMP WHERE id=?`,
+ genericLinkID, ticket.TicketID, ticket.TicketNumber, ticket.TicketURL, nullablePositiveInt(ticket.GroupID), ticket.GroupName, nullablePositiveInt(ticket.OwnerID), ticket.OwnerName, models.ZammadSyncLinked, nullablePositiveInt(ticket.LastStatusID), ticket.LastStatusName, linkID)
+ return err
+ })
+}
+
+func (r *ZammadRepository) MarkTicketLinkFailed(id, safeError string) error {
+ _, err := r.db.ExecWrite(`UPDATE zammad_ticket_links SET
+ sync_state = ?, creating_started_at = NULL, last_error = ?,
+ updated_at = CURRENT_TIMESTAMP WHERE id = ?`, models.ZammadSyncFailed, safeError, id)
+ return err
+}
+
+func (r *ZammadRepository) MarkTicketLinkUncertain(id, safeError string) error {
+ _, err := r.db.ExecWrite(`UPDATE zammad_ticket_links SET
+ sync_state = ?, creating_started_at = NULL, last_error = ?,
+ updated_at = CURRENT_TIMESTAMP WHERE id = ?`, models.ZammadSyncUncertain, safeError, id)
+ return err
+}
+
+func (r *ZammadRepository) ResetUncertainTicketCreation(id string) (bool, error) {
+ result, err := r.db.ExecWrite(`UPDATE zammad_ticket_links SET
+ sync_state = ?, last_error = '', updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND sync_state = ? AND ticket_id IS NULL`,
+ models.ZammadSyncFailed, id, models.ZammadSyncUncertain)
+ if err != nil {
+ return false, err
+ }
+ rows, _ := result.RowsAffected()
+ return rows == 1, nil
+}
+
+func (r *ZammadRepository) UpdateTicketLinkSync(id string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName, safeError string, now time.Time, setCompletionApplied, completionApplied bool) error {
+ state := models.ZammadSyncLinked
+ var nextAttempt any
+ if safeError != "" {
+ state = models.ZammadSyncFailed
+ nextAttempt = now.Add(time.Minute)
+ }
+ _, err := r.db.ExecWrite(`UPDATE zammad_ticket_links SET
+ last_status_id = ?, last_status_name = ?,
+ group_id = ?, group_name = ?, owner_id = ?, owner_name = ?,
+ last_synced_at = CASE WHEN ? = '' THEN ? ELSE last_synced_at END,
+ last_attempt_at = ?, next_attempt_at = ?,
+ last_error = ?, sync_state = ?, sync_lock_until = NULL,
+ completion_applied = CASE WHEN ? THEN ? ELSE completion_applied END,
+ updated_at = CURRENT_TIMESTAMP WHERE id = ?`, nullablePositiveInt(statusID), statusName,
+ nullablePositiveInt(groupID), groupName, nullablePositiveInt(ownerID), ownerName,
+ safeError, now, now, nextAttempt, safeError, state, setCompletionApplied, completionApplied, id)
+ return err
+}
+
+func (r *ZammadRepository) ListDueTicketLinks(before time.Time, limit int) ([]*models.ZammadTicketLink, error) {
+ rows, err := r.db.Query(`SELECT `+zammadTicketLinkColumns+`
+ FROM zammad_ticket_links ztl
+ JOIN integration_providers ip ON ip.id = ztl.provider_id
+ JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id
+ WHERE ip.enabled = true AND ztl.ticket_id IS NOT NULL
+ AND (zc.auth_method != 'oauth' OR EXISTS (
+ SELECT 1 FROM zammad_oauth_tokens zot
+ WHERE zot.provider_id = zc.provider_id AND zot.reauthorization_required = false
+ ))
+ AND (ztl.last_synced_at IS NULL OR ztl.last_synced_at < ?)
+ AND (ztl.next_attempt_at IS NULL OR ztl.next_attempt_at <= CURRENT_TIMESTAMP)
+ AND (ztl.sync_lock_until IS NULL OR ztl.sync_lock_until < CURRENT_TIMESTAMP)
+ ORDER BY COALESCE(ztl.last_attempt_at, ztl.last_synced_at, ztl.created_at) ASC LIMIT ?`, before, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ links := []*models.ZammadTicketLink{}
+ for rows.Next() {
+ link, err := scanZammadTicketLink(rows)
+ if err != nil {
+ return nil, err
+ }
+ links = append(links, link)
+ }
+ return links, rows.Err()
+}
+
+func (r *ZammadRepository) ClaimSync(id string, until time.Time) (bool, error) {
+ result, err := r.db.ExecWrite(`UPDATE zammad_ticket_links SET sync_lock_until = ?
+ WHERE id = ? AND (sync_lock_until IS NULL OR sync_lock_until < CURRENT_TIMESTAMP)`, until, id)
+ if err != nil {
+ return false, err
+ }
+ rows, _ := result.RowsAffected()
+ return rows == 1, nil
+}
+
+func scanZammadTicketLink(scanner interface{ Scan(...any) error }) (*models.ZammadTicketLink, error) {
+ var link models.ZammadTicketLink
+ var genericLinkID, number, ticketURL, groupName, ownerName, statusName, lastError sql.NullString
+ var ticketID, groupID, ownerID, statusID, createdBy sql.NullInt64
+ var lastSynced, lastAttempt, nextAttempt sql.NullTime
+ err := scanner.Scan(&link.ID, &link.ItemID, &link.ProviderID, &link.ProviderName,
+ &genericLinkID, &ticketID, &number, &ticketURL, &groupID, &groupName, &ownerID, &ownerName,
+ &link.CorrelationKey, &link.SyncState, &statusID, &statusName,
+ &lastSynced, &lastAttempt, &nextAttempt, &lastError, &link.CompletionApplied, &createdBy, &link.CreatedAt, &link.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ if genericLinkID.Valid {
+ link.ItemIntegrationLinkID = genericLinkID.String
+ }
+ if ticketID.Valid {
+ link.TicketID = int(ticketID.Int64)
+ }
+ if number.Valid {
+ link.TicketNumber = number.String
+ }
+ if ticketURL.Valid {
+ link.TicketURL = ticketURL.String
+ }
+ if groupID.Valid {
+ link.GroupID = int(groupID.Int64)
+ }
+ if groupName.Valid {
+ link.GroupName = groupName.String
+ }
+ if ownerID.Valid {
+ link.OwnerID = int(ownerID.Int64)
+ }
+ if ownerName.Valid {
+ link.OwnerName = ownerName.String
+ }
+ if statusID.Valid {
+ link.LastStatusID = int(statusID.Int64)
+ }
+ if statusName.Valid {
+ link.LastStatusName = statusName.String
+ }
+ if lastSynced.Valid {
+ link.LastSyncedAt = &lastSynced.Time
+ }
+ if lastAttempt.Valid {
+ link.LastAttemptAt = &lastAttempt.Time
+ }
+ if nextAttempt.Valid {
+ link.NextAttemptAt = &nextAttempt.Time
+ }
+ if lastError.Valid {
+ link.LastError = lastError.String
+ }
+ if createdBy.Valid {
+ v := int(createdBy.Int64)
+ link.CreatedBy = &v
+ }
+ return &link, nil
+}
+
+func (r *ZammadRepository) DeleteTicketLink(id string) error {
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ var genericID sql.NullString
+ if err := tx.QueryRow(`SELECT item_integration_link_id FROM zammad_ticket_links WHERE id=?`, id).Scan(&genericID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM zammad_ticket_links WHERE id=?`, id); err != nil {
+ return err
+ }
+ if genericID.Valid {
+ _, err := tx.Exec(`DELETE FROM item_integration_links WHERE id=?`, genericID.String)
+ return err
+ }
+ return nil
+ })
+}
+
+func nullablePositiveInt(value int) any {
+ if value <= 0 {
+ return nil
+ }
+ return value
+}
diff --git a/internal/routes/integrations.go b/internal/routes/integrations.go
index d5ed871ce..5b22a0f78 100644
--- a/internal/routes/integrations.go
+++ b/internal/routes/integrations.go
@@ -12,6 +12,7 @@ type IntegrationHandlers struct {
OAuth *handlers.IntegrationOAuthHandler
ItemLinks *handlers.IntegrationItemLinksHandler
TodoistSync *handlers.TodoistSyncHandler
+ Zammad *handlers.ZammadHandler
}
// RegisterIntegrationRoutes registers integration provider, OAuth, and item link routes.
@@ -42,6 +43,26 @@ func RegisterIntegrationRoutes(deps *Deps) {
api.HandleH("GET /users/me/todoist-sync/projects", auth(http.HandlerFunc(deps.Integrations.TodoistSync.GetProjects)))
api.HandleH("POST /users/me/todoist-sync/run", auth(http.HandlerFunc(deps.Integrations.TodoistSync.RunSync)))
+ // Zammad system connections and workspace-scoped ticket operations.
+ api.HandleH("GET /admin/zammad-connections", admin(http.HandlerFunc(deps.Integrations.Zammad.ListConnections)))
+ api.HandleH("POST /admin/zammad-connections", admin(http.HandlerFunc(deps.Integrations.Zammad.CreateConnection)))
+ api.HandleH("GET /admin/zammad-connections/{id}", admin(http.HandlerFunc(deps.Integrations.Zammad.GetConnection)))
+ api.HandleH("PUT /admin/zammad-connections/{id}", admin(http.HandlerFunc(deps.Integrations.Zammad.UpdateConnection)))
+ api.HandleH("DELETE /admin/zammad-connections/{id}", admin(http.HandlerFunc(deps.Integrations.Zammad.DeleteConnection)))
+ api.HandleH("POST /admin/zammad-connections/{id}/test", admin(http.HandlerFunc(deps.Integrations.Zammad.TestConnection)))
+ api.HandleH("POST /admin/zammad-connections/{id}/oauth/start", admin(http.HandlerFunc(deps.Integrations.Zammad.StartOAuth)))
+ api.Handle("GET /integrations/zammad/oauth/callback", http.HandlerFunc(deps.Integrations.Zammad.OAuthCallback))
+ api.HandleH("POST /admin/zammad-ticket-links/{linkId}/retry-create", admin(http.HandlerFunc(deps.Integrations.Zammad.RetryUncertainTicketCreation)))
+ api.HandleH("GET /workspaces/{workspaceId}/zammad-connections", auth(http.HandlerFunc(deps.Integrations.Zammad.ListWorkspaceConnections)))
+ api.HandleH("GET /workspaces/{workspaceId}/zammad-connections/{id}/metadata", auth(http.HandlerFunc(deps.Integrations.Zammad.GetWorkspaceMetadata)))
+ api.HandleH("GET /workspaces/{workspaceId}/zammad-connections/{id}/owners", auth(http.HandlerFunc(deps.Integrations.Zammad.GetWorkspaceOwners)))
+ api.HandleH("GET /items/{id}/zammad-links", auth(http.HandlerFunc(deps.Integrations.Zammad.GetItemLinks)))
+ api.HandleH("POST /items/{id}/zammad-tickets", auth(http.HandlerFunc(deps.Integrations.Zammad.CreateTicket)))
+ api.HandleH("POST /items/{id}/zammad-ticket-links", auth(http.HandlerFunc(deps.Integrations.Zammad.LinkExistingTicket)))
+ api.HandleH("PUT /zammad-ticket-links/{linkId}", auth(http.HandlerFunc(deps.Integrations.Zammad.UpdateTicketLink)))
+ api.HandleH("DELETE /zammad-ticket-links/{linkId}", auth(http.HandlerFunc(deps.Integrations.Zammad.DeleteTicketLink)))
+ api.HandleH("POST /zammad-ticket-links/{linkId}/refresh", auth(http.HandlerFunc(deps.Integrations.Zammad.RefreshTicket)))
+
// Item links
api.HandleH("GET /items/{id}/integration-links", auth(http.HandlerFunc(deps.Integrations.ItemLinks.GetItemLinks)))
api.HandleH("POST /items/{id}/integration-links", auth(http.HandlerFunc(deps.Integrations.ItemLinks.CreateItemLink)))
diff --git a/internal/scheduler/zammad_sync_scheduler.go b/internal/scheduler/zammad_sync_scheduler.go
new file mode 100644
index 000000000..1937bd8b6
--- /dev/null
+++ b/internal/scheduler/zammad_sync_scheduler.go
@@ -0,0 +1,65 @@
+package scheduler
+
+import (
+ "context"
+ "log/slog"
+ "sync"
+ "time"
+
+ "windshift/internal/services"
+)
+
+type ZammadSyncScheduler struct {
+ service *services.ZammadService
+ interval time.Duration
+ ticker *time.Ticker
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+ mu sync.Mutex
+ running bool
+}
+
+func NewZammadSyncScheduler(service *services.ZammadService) *ZammadSyncScheduler {
+ return &ZammadSyncScheduler{service: service, interval: 2 * time.Minute}
+}
+
+func (s *ZammadSyncScheduler) Start() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.running {
+ return
+ }
+ s.ticker = time.NewTicker(s.interval)
+ ctx, cancel := context.WithCancel(context.Background())
+ s.cancel = cancel
+ s.running = true
+ s.wg.Add(1)
+ go s.loop(ctx, s.ticker)
+}
+
+func (s *ZammadSyncScheduler) Stop() {
+ s.mu.Lock()
+ if !s.running {
+ s.mu.Unlock()
+ return
+ }
+ s.running = false
+ s.ticker.Stop()
+ s.cancel()
+ s.mu.Unlock()
+ s.wg.Wait()
+}
+
+func (s *ZammadSyncScheduler) loop(ctx context.Context, ticker *time.Ticker) {
+ defer s.wg.Done()
+ for {
+ select {
+ case <-ticker.C:
+ if err := s.service.SyncDue(ctx, 50); err != nil && ctx.Err() == nil {
+ slog.Warn("Zammad ticket synchronization failed", slog.String("component", "zammad-sync"), slog.Any("error", err))
+ }
+ case <-ctx.Done():
+ return
+ }
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 95730011f..661b31860 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -78,6 +78,7 @@ type Server struct {
recurrenceScheduler *scheduler.RecurrenceScheduler
cfvCleanupScheduler *scheduler.CFVCleanupScheduler
todoistSyncScheduler *scheduler.TodoistSyncScheduler
+ zammadSyncScheduler *scheduler.ZammadSyncScheduler
runnerLeaseReaper *scheduler.RunnerLeaseReaper
globalRankMigrationScheduler *scheduler.GlobalRankMigrationScheduler
codingRunService *services.RunService
@@ -661,11 +662,7 @@ func (s *Server) initialize() error {
// Wire credential resolution into the action runtime so HTTP capabilities
// can reference tokens by ID. The service shares the same SSO_SECRET via
// a domain-separated HKDF label (ActionCredentialEncryptionInfo).
- credentialSvc := services.NewActionCredentialService(
- repository.NewActionCredentialRepository(s.db),
- cfg.Auth.SessionSecret,
- )
- s.actionService.SetCredentialService(credentialSvc)
+ s.actionService.SetCredentialService(actionCredentialService)
// Lets container_run nodes dispatch to a remote runner pool (WI-146).
s.actionService.SetAgentRunRepository(repository.NewAgentRunRepository(s.db))
// One-shot scanner: warn about any legacy capability whose
@@ -890,7 +887,7 @@ func (s *Server) initialize() error {
workspaceBootstrapHandler := handlers.NewWorkspaceBootstrapHandler(workspaceHandler, userHandler, milestoneHandler, iterationHandler, timeProjectHandler)
// Secretless access layer (WI-144): brokers a granted credential to a
// running job without it ever living on the runner host.
- runnerBrokerHandler := handlers.NewRunnerBrokerHandler(tokenManager, repository.NewAgentRunRepository(s.db), credentialSvc, llmManager, &scmCredsAdapter{cr: scmCredResolver})
+ runnerBrokerHandler := handlers.NewRunnerBrokerHandler(tokenManager, repository.NewAgentRunRepository(s.db), actionCredentialService, llmManager, &scmCredsAdapter{cr: scmCredResolver})
runnerBrokerHandler.SetUsageRepository(repository.NewLLMUsageRepository(s.db)) // meter LLM token/cost at the broker (WI-493)
if bindingSvc != nil {
// Registers the coding-agent assignee trigger inside the item
@@ -1036,6 +1033,22 @@ func (s *Server) initialize() error {
s.actionService.SetApprovalService(approvalService)
workspaceRoleHandler.SetApprovalService(approvalService)
+ zammadService := services.NewZammadService(
+ s.db,
+ repository.NewZammadRepository(s.db),
+ actionCredentialService,
+ permService,
+ workflowService,
+ conditionService,
+ approvalService,
+ )
+ zammadService.SetEventCoordinator(eventCoordinator)
+ zammadService.SetOAuthEncryption(scmProviderHandler.GetEncryption())
+ zammadHandler := handlers.NewZammadHandler(repository.NewItemRepository(s.db), zammadService, permService, logger.NewAuditor(s.db))
+ zammadHandler.SetOAuthBaseURL(baseURL)
+ s.zammadSyncScheduler = scheduler.NewZammadSyncScheduler(zammadService)
+ s.zammadSyncScheduler.Start()
+
// Standard profiles execute in-process through the canonical aitools
// registry. Wiring is intentionally late because their final comments and
// approval tools use the fully configured application services above.
@@ -1615,6 +1628,7 @@ func (s *Server) initialize() error {
OAuth: integrationOAuthHandler,
ItemLinks: integrationItemLinksHandler,
TodoistSync: todoistSyncHandler,
+ Zammad: zammadHandler,
},
Pages: routes.PageHandlers{
Page: pageHandler,
@@ -1985,6 +1999,11 @@ func (s *Server) Shutdown(ctx context.Context) error {
s.todoistSyncScheduler.Stop()
}
+ if s.zammadSyncScheduler != nil {
+ slog.Info("stopping Zammad sync scheduler")
+ s.zammadSyncScheduler.Stop()
+ }
+
if s.runnerLeaseReaper != nil {
slog.Info("stopping runner lease reaper")
s.runnerLeaseReaper.Stop()
@@ -2118,6 +2137,9 @@ func (s *Server) cleanup() {
if s.globalRankMigrationScheduler != nil {
s.globalRankMigrationScheduler.Stop()
}
+ if s.zammadSyncScheduler != nil {
+ s.zammadSyncScheduler.Stop()
+ }
// Stop rate limiters
if s.loginRateLimiter != nil {
s.loginRateLimiter.Stop()
diff --git a/internal/services/action_credential_service.go b/internal/services/action_credential_service.go
index b664e5a7b..e0504819a 100644
--- a/internal/services/action_credential_service.go
+++ b/internal/services/action_credential_service.go
@@ -10,6 +10,7 @@ import (
"log/slog"
"strings"
+ "windshift/internal/database"
"windshift/internal/models"
"windshift/internal/repository"
"windshift/internal/sso"
@@ -42,6 +43,18 @@ var ErrCredentialScopeMismatch = errors.New("action credential not in scope")
// is_enabled = false.
var ErrCredentialDisabled = errors.New("action credential disabled")
+// ErrCredentialPurposeMismatch prevents provider-managed credentials from
+// being reused by generic HTTP capabilities or another provider instance.
+var ErrCredentialPurposeMismatch = errors.New("action credential purpose mismatch")
+
+type managedCredentialMetadata struct {
+ Marker string `json:"_windshift_managed_credential"`
+ ManagedBy string `json:"managed_by"`
+ OwnerID string `json:"owner_id"`
+}
+
+const managedCredentialMetadataMarker = "v1"
+
// validCredentialTypes enumerates the credential_type values the API accepts.
var validCredentialTypes = map[models.ActionCredentialType]struct{}{
models.CredentialBearerToken: {},
@@ -54,6 +67,27 @@ var validCredentialTypes = map[models.ActionCredentialType]struct{}{
// returned model has EncryptedSecret populated but never plaintext; callers
// should immediately Sanitize() before returning to clients.
func (s *ActionCredentialService) Create(req models.CreateActionCredentialRequest, createdBy *int) (*models.ActionCredential, error) {
+ if isManagedCredentialMetadata(req.SecretMetadata) {
+ return nil, errors.New("managed credential metadata is reserved")
+ }
+ return s.create(req, createdBy)
+}
+
+// CreateManaged creates a credential that only its owning provider service can
+// resolve or mutate. It is omitted from generic credential management APIs.
+func (s *ActionCredentialService) CreateManaged(req models.CreateActionCredentialRequest, createdBy *int, purpose, ownerID string) (*models.ActionCredential, error) {
+ if strings.TrimSpace(purpose) == "" || strings.TrimSpace(ownerID) == "" {
+ return nil, errors.New("managed credential purpose and owner are required")
+ }
+ metadata, err := json.Marshal(managedCredentialMetadata{Marker: managedCredentialMetadataMarker, ManagedBy: purpose, OwnerID: ownerID})
+ if err != nil {
+ return nil, err
+ }
+ req.SecretMetadata = string(metadata)
+ return s.create(req, createdBy)
+}
+
+func (s *ActionCredentialService) create(req models.CreateActionCredentialRequest, createdBy *int) (*models.ActionCredential, error) {
if strings.TrimSpace(req.Name) == "" {
return nil, errors.New("name is required")
}
@@ -124,6 +158,101 @@ func (s *ActionCredentialService) UpdateMetadata(id int, req models.UpdateAction
if err != nil {
return nil, err
}
+ if isManagedCredential(c) || (req.SecretMetadata != nil && isManagedCredentialMetadata(*req.SecretMetadata)) {
+ return nil, repository.ErrNotFound
+ }
+ return s.updateMetadata(c, req, false)
+}
+
+func (s *ActionCredentialService) UpdateManagedMetadata(id int, req models.UpdateActionCredentialRequest, purpose, ownerID string) (*models.ActionCredential, error) {
+ c, err := s.repo.GetActionCredentialByID(id)
+ if err != nil {
+ return nil, err
+ }
+ if !credentialMatchesPurpose(c, purpose, ownerID) {
+ return nil, ErrCredentialPurposeMismatch
+ }
+ return s.updateMetadata(c, req, true)
+}
+
+// PrepareManagedUpdate applies provider-owned secret and metadata changes and
+// returns a rollback closure for the caller's adjacent provider-table update.
+// This keeps the credential restorable when the second persistence step fails.
+func (s *ActionCredentialService) PrepareManagedUpdate(id int, name string, appliesToAll bool, workspaceIDs []int, secret *string, purpose, ownerID string) (func(database.Tx) error, error) {
+ existing, err := s.repo.GetActionCredentialByID(id)
+ if err != nil {
+ return nil, err
+ }
+ if !credentialMatchesPurpose(existing, purpose, ownerID) {
+ return nil, ErrCredentialPurposeMismatch
+ }
+ normalizedWorkspaceIDs, err := normalizeCredentialWorkspaceIDs(workspaceIDs)
+ if err != nil {
+ return nil, err
+ }
+ if !appliesToAll && len(normalizedWorkspaceIDs) == 0 {
+ return nil, errors.New("workspace_ids must contain at least one workspace when applies_to_all_workspaces is false")
+ }
+ if appliesToAll {
+ normalizedWorkspaceIDs = nil
+ }
+ existing.Name = name
+ existing.AppliesToAllWorkspaces = appliesToAll
+ var encryptedSecret, prefix *string
+ if secret != nil && strings.TrimSpace(*secret) != "" {
+ if err := validateCredentialSecret(existing.CredentialType, *secret); err != nil {
+ return nil, err
+ }
+ ciphertext, err := s.encryption.Encrypt(*secret)
+ if err != nil {
+ return nil, err
+ }
+ secretPrefix := models.SecretPrefixFor(*secret)
+ encryptedSecret = &ciphertext
+ prefix = &secretPrefix
+ }
+ return func(tx database.Tx) error {
+ return s.repo.UpdateManagedCredentialTx(tx, existing, normalizedWorkspaceIDs, encryptedSecret, prefix)
+ }, nil
+}
+
+// PrepareManagedSecretUpdate encrypts a replacement secret and returns a
+// transaction closure that changes only secret material. Long-running OAuth
+// operations use this so a stale connection snapshot cannot roll back a
+// concurrent credential rename or workspace-scope update.
+func (s *ActionCredentialService) PrepareManagedSecretUpdate(id int, secret, purpose, ownerID string) (func(database.Tx) error, error) {
+ existing, err := s.repo.GetActionCredentialByID(id)
+ if err != nil {
+ return nil, err
+ }
+ if !credentialMatchesPurpose(existing, purpose, ownerID) {
+ return nil, ErrCredentialPurposeMismatch
+ }
+ if strings.TrimSpace(secret) == "" {
+ return nil, errors.New("secret is required")
+ }
+ if err := validateCredentialSecret(existing.CredentialType, secret); err != nil {
+ return nil, err
+ }
+ ciphertext, err := s.encryption.Encrypt(secret)
+ if err != nil {
+ return nil, fmt.Errorf("encrypt credential: %w", err)
+ }
+ prefix := models.SecretPrefixFor(secret)
+ expectedMetadata := existing.SecretMetadata
+ return func(tx database.Tx) error {
+ updated, err := s.repo.UpdateManagedCredentialSecretTx(tx, id, expectedMetadata, ciphertext, prefix)
+ if err != nil {
+ return err
+ }
+ if !updated {
+ return ErrCredentialPurposeMismatch
+ }
+ return nil
+ }, nil
+}
+
+func (s *ActionCredentialService) updateMetadata(c *models.ActionCredential, req models.UpdateActionCredentialRequest, preserveManagedMetadata bool) (*models.ActionCredential, error) {
if req.Name != nil {
if strings.TrimSpace(*req.Name) == "" {
return nil, errors.New("name cannot be empty")
@@ -131,6 +260,9 @@ func (s *ActionCredentialService) UpdateMetadata(id int, req models.UpdateAction
c.Name = *req.Name
}
if req.SecretMetadata != nil {
+ if preserveManagedMetadata {
+ return nil, errors.New("managed credential metadata cannot be changed")
+ }
if err := validateSecretMetadata(*req.SecretMetadata); err != nil {
return nil, err
}
@@ -147,6 +279,7 @@ func (s *ActionCredentialService) UpdateMetadata(id int, req models.UpdateAction
nextAppliesAll = *req.AppliesToAllWorkspaces
}
var nextWorkspaceIDs []int
+ var err error
scopeTouched := req.AppliesToAllWorkspaces != nil || req.WorkspaceIDs != nil
if scopeTouched {
if req.WorkspaceIDs != nil {
@@ -179,13 +312,31 @@ func (s *ActionCredentialService) UpdateMetadata(id int, req models.UpdateAction
// Rotate re-encrypts the secret for an existing credential.
func (s *ActionCredentialService) Rotate(id int, req models.RotateActionCredentialRequest) (*models.ActionCredential, error) {
- if strings.TrimSpace(req.Secret) == "" {
- return nil, errors.New("secret is required")
+ existing, err := s.repo.GetActionCredentialByID(id)
+ if err != nil {
+ return nil, err
+ }
+ if isManagedCredential(existing) {
+ return nil, repository.ErrNotFound
}
+ return s.rotate(existing, req)
+}
+
+func (s *ActionCredentialService) RotateManaged(id int, req models.RotateActionCredentialRequest, purpose, ownerID string) (*models.ActionCredential, error) {
existing, err := s.repo.GetActionCredentialByID(id)
if err != nil {
return nil, err
}
+ if !credentialMatchesPurpose(existing, purpose, ownerID) {
+ return nil, ErrCredentialPurposeMismatch
+ }
+ return s.rotate(existing, req)
+}
+
+func (s *ActionCredentialService) rotate(existing *models.ActionCredential, req models.RotateActionCredentialRequest) (*models.ActionCredential, error) {
+ if strings.TrimSpace(req.Secret) == "" {
+ return nil, errors.New("secret is required")
+ }
if err := validateCredentialSecret(existing.CredentialType, req.Secret); err != nil {
return nil, err
}
@@ -194,7 +345,7 @@ func (s *ActionCredentialService) Rotate(id int, req models.RotateActionCredenti
return nil, fmt.Errorf("encrypt credential: %w", err)
}
prefix := models.SecretPrefixFor(req.Secret)
- if err := s.repo.RotateActionCredential(id, ciphertext, prefix); err != nil {
+ if err := s.repo.RotateActionCredential(existing.ID, ciphertext, prefix); err != nil {
return nil, err
}
existing.EncryptedSecret = ciphertext
@@ -211,13 +362,38 @@ func validateCredentialSecret(credentialType models.ActionCredentialType, secret
// Delete removes a credential. Callers must enforce permission/scope first.
func (s *ActionCredentialService) Delete(id int) error {
+ c, err := s.repo.GetActionCredentialByID(id)
+ if err != nil {
+ return err
+ }
+ if isManagedCredential(c) {
+ return repository.ErrNotFound
+ }
+ return s.repo.DeleteActionCredential(id)
+}
+
+func (s *ActionCredentialService) DeleteManaged(id int, purpose, ownerID string) error {
+ c, err := s.repo.GetActionCredentialByID(id)
+ if err != nil {
+ return err
+ }
+ if !credentialMatchesPurpose(c, purpose, ownerID) {
+ return ErrCredentialPurposeMismatch
+ }
return s.repo.DeleteActionCredential(id)
}
// Get returns a credential record (ciphertext + metadata). The handler is
// responsible for Sanitize() before sending to clients.
func (s *ActionCredentialService) Get(id int) (*models.ActionCredential, error) {
- return s.repo.GetActionCredentialByID(id)
+ c, err := s.repo.GetActionCredentialByID(id)
+ if err != nil {
+ return nil, err
+ }
+ if isManagedCredential(c) {
+ return nil, repository.ErrNotFound
+ }
+ return c, nil
}
// ListForWorkspace returns credentials usable in the given workspace: those
@@ -225,17 +401,20 @@ func (s *ActionCredentialService) Get(id int) (*models.ActionCredential, error)
// The execution engine uses this to validate that a credential reference is
// in-scope.
func (s *ActionCredentialService) ListForWorkspace(workspaceID int) ([]*models.ActionCredential, error) {
- return s.repo.ListActionCredentialsForWorkspace(workspaceID)
+ credentials, err := s.repo.ListActionCredentialsForWorkspace(workspaceID)
+ return filterManagedCredentials(credentials), err
}
// ListGlobal returns credentials that apply to all workspaces.
func (s *ActionCredentialService) ListGlobal() ([]*models.ActionCredential, error) {
- return s.repo.ListActionCredentialsGlobal()
+ credentials, err := s.repo.ListActionCredentialsGlobal()
+ return filterManagedCredentials(credentials), err
}
// ListAll returns every credential (system-admin view).
func (s *ActionCredentialService) ListAll() ([]*models.ActionCredential, error) {
- return s.repo.ListAllActionCredentials()
+ credentials, err := s.repo.ListAllActionCredentials()
+ return filterManagedCredentials(credentials), err
}
// Resolve loads a credential and returns the plaintext secret, but only if
@@ -250,6 +429,24 @@ func (s *ActionCredentialService) Resolve(_ context.Context, credentialID, works
if err != nil {
return "", nil, err
}
+ if isManagedCredential(c) {
+ return "", nil, ErrCredentialPurposeMismatch
+ }
+ return s.resolve(c, workspaceID)
+}
+
+func (s *ActionCredentialService) ResolveManaged(_ context.Context, credentialID, workspaceID int, purpose, ownerID string) (string, *models.ActionCredential, error) {
+ c, err := s.repo.GetActionCredentialByID(credentialID)
+ if err != nil {
+ return "", nil, err
+ }
+ if !credentialMatchesPurpose(c, purpose, ownerID) {
+ return "", nil, ErrCredentialPurposeMismatch
+ }
+ return s.resolve(c, workspaceID)
+}
+
+func (s *ActionCredentialService) resolve(c *models.ActionCredential, workspaceID int) (string, *models.ActionCredential, error) {
if !c.IsEnabled {
return "", c, ErrCredentialDisabled
}
@@ -269,6 +466,44 @@ func (s *ActionCredentialService) Resolve(_ context.Context, credentialID, works
return plaintext, c, nil
}
+func isManagedCredentialMetadata(raw string) bool {
+ var metadata managedCredentialMetadata
+ return json.Unmarshal([]byte(raw), &metadata) == nil &&
+ metadata.Marker == managedCredentialMetadataMarker &&
+ strings.TrimSpace(metadata.ManagedBy) != "" &&
+ strings.TrimSpace(metadata.OwnerID) != ""
+}
+
+func isManagedCredential(c *models.ActionCredential) bool {
+ return c != nil && isManagedCredentialMetadata(c.SecretMetadata)
+}
+
+func credentialMatchesPurpose(c *models.ActionCredential, purpose, ownerID string) bool {
+ if c == nil {
+ return false
+ }
+ var metadata managedCredentialMetadata
+ if json.Unmarshal([]byte(c.SecretMetadata), &metadata) != nil {
+ return false
+ }
+ return isManagedCredentialMetadata(c.SecretMetadata) &&
+ metadata.ManagedBy == purpose && metadata.OwnerID == ownerID &&
+ strings.TrimSpace(purpose) != "" && strings.TrimSpace(ownerID) != ""
+}
+
+func filterManagedCredentials(credentials []*models.ActionCredential) []*models.ActionCredential {
+ if len(credentials) == 0 {
+ return credentials
+ }
+ filtered := credentials[:0]
+ for _, credential := range credentials {
+ if !isManagedCredential(credential) {
+ filtered = append(filtered, credential)
+ }
+ }
+ return filtered
+}
+
// CanCapabilityReference returns whether a given capability scope is allowed
// to reference a credential of the given scope. Used by capability validation.
//
diff --git a/internal/services/action_credential_service_test.go b/internal/services/action_credential_service_test.go
new file mode 100644
index 000000000..8a9717e71
--- /dev/null
+++ b/internal/services/action_credential_service_test.go
@@ -0,0 +1,44 @@
+package services
+
+import (
+ "testing"
+
+ "windshift/internal/models"
+)
+
+func TestIsManagedCredentialMetadataRequiresReservedVersionedMarkerAndOwnership(t *testing.T) {
+ tests := []struct {
+ name string
+ metadata string
+ managed bool
+ }{
+ {name: "provider metadata", metadata: `{"_windshift_managed_credential":"v1","managed_by":"zammad","owner_id":"provider-1"}`, managed: true},
+ {name: "managed by only remains generic", metadata: `{"managed_by":"zammad"}`},
+ {name: "owner only remains generic", metadata: `{"owner_id":"provider-1"}`},
+ {name: "missing marker remains generic", metadata: `{"managed_by":"zammad","owner_id":"provider-1"}`},
+ {name: "unknown marker version remains generic", metadata: `{"_windshift_managed_credential":"v2","managed_by":"zammad","owner_id":"provider-1"}`},
+ {name: "blank ownership remains generic", metadata: `{"_windshift_managed_credential":"v1","managed_by":" ","owner_id":"provider-1"}`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isManagedCredentialMetadata(tt.metadata); got != tt.managed {
+ t.Fatalf("isManagedCredentialMetadata(%s) = %v, want %v", tt.metadata, got, tt.managed)
+ }
+ })
+ }
+}
+
+func TestCredentialMatchesPurposeRequiresManagedMetadata(t *testing.T) {
+ credential := &models.ActionCredential{
+ SecretMetadata: `{"_windshift_managed_credential":"v1","managed_by":"zammad","owner_id":"provider-1"}`,
+ }
+ if !credentialMatchesPurpose(credential, "zammad", "provider-1") {
+ t.Fatal("expected matching provider-managed metadata")
+ }
+
+ credential.SecretMetadata = `{"managed_by":"zammad","owner_id":"provider-1"}`
+ if credentialMatchesPurpose(credential, "zammad", "provider-1") {
+ t.Fatal("generic metadata must not match a managed credential purpose")
+ }
+}
diff --git a/internal/services/action_service.go b/internal/services/action_service.go
index b0304f2cf..410040e55 100644
--- a/internal/services/action_service.go
+++ b/internal/services/action_service.go
@@ -2804,9 +2804,19 @@ func matchURLComponent(component, pattern string) bool {
return err == nil && matched
}
-// doHTTPRequest enforces the URL allowlist on every redirect and blocks
-// loopback, private, and link-local destinations to prevent SSRF and rebinding.
-func doHTTPRequest(ctx context.Context, method, targetURL, body string, headers, defaultHeaders map[string]string, timeoutSecs int, allowedPatterns []string) (string, error) {
+// SafeHTTPResponse is the bounded response returned by DoSafeHTTPRequest.
+// Headers are intentionally omitted so callers cannot accidentally persist or
+// expose authentication-adjacent response metadata.
+type SafeHTTPResponse struct {
+ StatusCode int
+ Body []byte
+}
+
+// DoSafeHTTPRequest is the shared outbound HTTP boundary for integrations and
+// action nodes. It enforces the URL allowlist on every redirect, blocks
+// loopback/private/link-local destinations, applies a deadline, and caps the
+// response body at 1 MiB.
+func DoSafeHTTPRequest(ctx context.Context, method, targetURL, body string, headers, defaultHeaders map[string]string, timeoutSecs int, allowedPatterns []string) (*SafeHTTPResponse, error) {
if timeoutSecs <= 0 {
timeoutSecs = 30
}
@@ -2821,7 +2831,7 @@ func doHTTPRequest(ctx context.Context, method, targetURL, body string, headers,
req, err := http.NewRequestWithContext(httpCtx, method, targetURL, bodyReader)
if err != nil {
- return "", fmt.Errorf("failed to create request for %q", redactHTTPURLForDiagnostics(targetURL))
+ return nil, fmt.Errorf("failed to create request for %q", redactHTTPURLForDiagnostics(targetURL))
}
// Apply default headers first, then override with specific headers
@@ -2835,18 +2845,31 @@ func doHTTPRequest(ctx context.Context, method, targetURL, body string, headers,
client := newSSRFSafeClient(time.Duration(timeoutSecs)*time.Second, allowedPatterns)
resp, err := client.Do(req)
if err != nil {
- return "", fmt.Errorf("request failed: %s", redactHTTPRequestError(err))
+ return nil, fmt.Errorf("request failed: %s", redactHTTPRequestError(err))
}
defer func() { _ = resp.Body.Close() }()
- respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
+ const maxResponseBody = 1 << 20
+ respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody+1))
if err != nil {
- return "", fmt.Errorf("failed to read response: %w", err)
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+ if len(respBody) > maxResponseBody {
+ return nil, fmt.Errorf("response body exceeds 1 MiB limit")
+ }
+
+ return &SafeHTTPResponse{StatusCode: resp.StatusCode, Body: respBody}, nil
+}
+
+func doHTTPRequest(ctx context.Context, method, targetURL, body string, headers, defaultHeaders map[string]string, timeoutSecs int, allowedPatterns []string) (string, error) {
+ resp, err := DoSafeHTTPRequest(ctx, method, targetURL, body, headers, defaultHeaders, timeoutSecs, allowedPatterns)
+ if err != nil {
+ return "", err
}
result := map[string]any{
"status_code": resp.StatusCode,
- "body": string(respBody),
+ "body": string(resp.Body),
}
resultJSON, _ := json.Marshal(result)
return string(resultJSON), nil
diff --git a/internal/services/zammad_service.go b/internal/services/zammad_service.go
new file mode 100644
index 000000000..404ab9b1a
--- /dev/null
+++ b/internal/services/zammad_service.go
@@ -0,0 +1,1633 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "windshift/internal/database"
+ "windshift/internal/integrations/zammad"
+ "windshift/internal/itemevents"
+ "windshift/internal/models"
+ "windshift/internal/repository"
+ "windshift/internal/sso"
+
+ "uuid"
+)
+
+var zammadFieldNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,79}$`)
+var zammadSlugPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,79}$`)
+
+var ErrZammadReauthorizationRequired = errors.New("zammad OAuth reauthorization is required")
+var ErrZammadOAuthSuperseded = errors.New("zammad OAuth operation was superseded by a configuration change")
+
+const zammadOAuthRefreshLeaseDuration = 2 * zammadHTTPTimeout
+
+type ZammadOAuthCallbackResult struct {
+ ProviderID string
+ ProviderName string
+ Initiator *models.User
+ OAuthGeneration int64
+}
+
+// zammadOAuthCredential is encrypted as the secret of the provider-managed
+// action credential. It is deliberately not a value that can be sent as an
+// Authorization header, including while OAuth is pending.
+type zammadOAuthCredential struct {
+ Version int `json:"version"`
+ Status string `json:"status"`
+ AccessToken string `json:"access_token,omitempty"`
+ RefreshToken string `json:"refresh_token,omitempty"`
+}
+
+func pendingZammadOAuthCredential(status string) string {
+ payload, _ := json.Marshal(zammadOAuthCredential{Version: 1, Status: status})
+ return string(payload)
+}
+
+func activeZammadOAuthCredential(accessToken, refreshToken string) (string, error) {
+ payload, err := json.Marshal(zammadOAuthCredential{Version: 1, Status: "active", AccessToken: accessToken, RefreshToken: refreshToken})
+ return string(payload), err
+}
+
+func parseZammadOAuthCredential(raw string) (*zammadOAuthCredential, error) {
+ var payload zammadOAuthCredential
+ if err := json.Unmarshal([]byte(raw), &payload); err != nil || payload.Version != 1 || payload.Status != "active" || strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" {
+ return nil, ErrZammadReauthorizationRequired
+ }
+ return &payload, nil
+}
+
+type ZammadValidationError struct{ Message string }
+
+func (e *ZammadValidationError) Error() string { return e.Message }
+
+func zammadValidationError(message string) error {
+ return &ZammadValidationError{Message: message}
+}
+
+type zammadWorkflowTransitioner interface {
+ PerformTransition(context.Context, PerformTransitionRequest, *repository.ItemRepository, *ConditionService, transitionApprovalService) (*PerformTransitionResult, error)
+}
+
+type zammadPermissionChecker interface {
+ HasWorkspacePermission(userID, workspaceID int, permission string) (bool, error)
+}
+
+type ZammadService struct {
+ db database.Database
+ repo *repository.ZammadRepository
+ credentials *ActionCredentialService
+ permission zammadPermissionChecker
+ workflow zammadWorkflowTransitioner
+ condition *ConditionService
+ approval *ApprovalService
+ events *EventCoordinator
+ transportOverride zammad.Transport
+ oauthTransportOverride zammad.Transport
+ encryption *sso.SecretEncryption
+ oauthBeforeRefreshClaim func()
+}
+
+func NewZammadService(db database.Database, repo *repository.ZammadRepository, credentials *ActionCredentialService, permission zammadPermissionChecker, workflow zammadWorkflowTransitioner, condition *ConditionService, approval *ApprovalService) *ZammadService {
+ return &ZammadService{
+ db: db, repo: repo, credentials: credentials, permission: permission,
+ workflow: workflow, condition: condition, approval: approval,
+ }
+}
+
+func (s *ZammadService) SetEventCoordinator(events *EventCoordinator) {
+ s.events = events
+}
+
+// SetTransportForTesting replaces the production SSRF-safe transport. Tests
+// use it with httptest; production bootstrap never calls this method.
+func (s *ZammadService) SetTransportForTesting(transport zammad.Transport) {
+ s.transportOverride = transport
+}
+
+// SetOAuthEncryption supplies the system secret realm already used by
+// integration_providers. It is set during server bootstrap, never exposed.
+func (s *ZammadService) SetOAuthEncryption(encryption *sso.SecretEncryption) {
+ s.encryption = encryption
+}
+
+func (s *ZammadService) SetOAuthTransportForTesting(transport zammad.Transport) {
+ s.oauthTransportOverride = transport
+}
+
+func (s *ZammadService) SetOAuthBeforeRefreshClaimForTesting(hook func()) {
+ s.oauthBeforeRefreshClaim = hook
+}
+
+func (s *ZammadService) ListConnections() ([]*models.ZammadConnection, error) {
+ return s.repo.ListConnections()
+}
+
+func (s *ZammadService) ListConnectionsForWorkspace(workspaceID int) ([]*models.ZammadConnection, error) {
+ return s.repo.ListConnectionsForWorkspace(workspaceID)
+}
+
+func (s *ZammadService) GetConnection(id string) (*models.ZammadConnection, error) {
+ return s.repo.GetConnection(id)
+}
+
+func (s *ZammadService) CreateConnection(req models.CreateZammadConnectionRequest, actorID int) (*models.ZammadConnection, error) {
+ connection, err := validateNewZammadConnection(req, actorID)
+ if err != nil {
+ return nil, err
+ }
+ if connection.AuthMethod == models.ZammadAuthMethodOAuth {
+ if s.encryption == nil {
+ return nil, errors.New("zammad OAuth encryption is not configured")
+ }
+ connection.OAuthClientSecretEncrypted, err = s.encryption.Encrypt(req.OAuthClientSecret)
+ if err != nil {
+ return nil, fmt.Errorf("encrypt Zammad OAuth client secret: %w", err)
+ }
+ credential, err := s.credentials.CreateManaged(models.CreateActionCredentialRequest{
+ Name: connection.Name + " Zammad OAuth credentials", CredentialType: models.CredentialCustomHeader,
+ Secret: pendingZammadOAuthCredential("pending"), AppliesToAllWorkspaces: boolPointer(connection.AppliesToAllWorkspaces), WorkspaceIDs: connection.WorkspaceIDs,
+ }, &actorID, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return nil, err
+ }
+ connection.CredentialID = credential.ID
+ if err := s.repo.CreateConnection(connection); err != nil {
+ _ = s.credentials.DeleteManaged(credential.ID, string(models.IntegrationProviderZammad), connection.ProviderID)
+ return nil, err
+ }
+ return s.repo.GetConnection(connection.ProviderID)
+ }
+ credential, err := s.credentials.CreateManaged(models.CreateActionCredentialRequest{
+ Name: connection.Name + " Zammad API token",
+ CredentialType: models.CredentialCustomHeader,
+ Secret: req.APIToken,
+ AppliesToAllWorkspaces: boolPointer(connection.AppliesToAllWorkspaces),
+ WorkspaceIDs: connection.WorkspaceIDs,
+ }, &actorID, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return nil, err
+ }
+ connection.CredentialID = credential.ID
+ connection.HasAPIToken = true
+ if err := s.repo.CreateConnection(connection); err != nil {
+ _ = s.credentials.DeleteManaged(credential.ID, string(models.IntegrationProviderZammad), connection.ProviderID)
+ return nil, err
+ }
+ return s.repo.GetConnection(connection.ProviderID)
+}
+
+func (s *ZammadService) UpdateConnection(id string, req models.UpdateZammadConnectionRequest) (*models.ZammadConnection, error) {
+ connection, err := s.repo.GetConnection(id)
+ if err != nil {
+ return nil, err
+ }
+ originalBaseURL := connection.BaseURL
+ originalCorrelationField := connection.CorrelationField
+ originalOAuthClientID := connection.OAuthClientID
+ oauthSecretChanged := req.OAuthClientSecret != nil && strings.TrimSpace(*req.OAuthClientSecret) != ""
+ if req.Slug != nil {
+ connection.Slug = strings.TrimSpace(*req.Slug)
+ }
+ if req.Name != nil {
+ connection.Name = strings.TrimSpace(*req.Name)
+ }
+ if req.Enabled != nil {
+ connection.Enabled = *req.Enabled
+ }
+ if req.AuthMethod != nil && *req.AuthMethod != connection.AuthMethod {
+ return nil, zammadValidationError("auth_method cannot be changed after a connection is created")
+ }
+ if req.OAuthClientID != nil {
+ connection.OAuthClientID = strings.TrimSpace(*req.OAuthClientID)
+ }
+ if oauthSecretChanged {
+ if s.encryption == nil {
+ return nil, errors.New("zammad OAuth encryption is not configured")
+ }
+ connection.OAuthClientSecretEncrypted, err = s.encryption.Encrypt(*req.OAuthClientSecret)
+ if err != nil {
+ return nil, fmt.Errorf("encrypt Zammad OAuth client secret: %w", err)
+ }
+ }
+ if req.BaseURL != nil {
+ connection.BaseURL, err = NormalizeZammadBaseURL(*req.BaseURL)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if req.DefaultGroupID != nil {
+ connection.DefaultGroupID = *req.DefaultGroupID
+ }
+ if req.DefaultGroupName != nil {
+ connection.DefaultGroupName = strings.TrimSpace(*req.DefaultGroupName)
+ }
+ if req.AllowedGroupIDs != nil {
+ if hasNonPositiveIDs(*req.AllowedGroupIDs) {
+ return nil, zammadValidationError("allowed_group_ids must contain positive IDs")
+ }
+ connection.AllowedGroupIDs = normalizePositiveIDs(*req.AllowedGroupIDs)
+ }
+ if req.DefaultCustomer != nil {
+ connection.DefaultCustomer = strings.TrimSpace(*req.DefaultCustomer)
+ }
+ if req.CorrelationField != nil {
+ connection.CorrelationField = strings.TrimSpace(*req.CorrelationField)
+ }
+ if req.ClosedStateIDs != nil {
+ if hasNonPositiveIDs(*req.ClosedStateIDs) {
+ return nil, zammadValidationError("closed_state_ids must contain positive IDs")
+ }
+ connection.ClosedStateIDs = normalizePositiveIDs(*req.ClosedStateIDs)
+ }
+ if req.ClearCompletionStatus {
+ connection.CompletionStatusID = nil
+ } else if req.CompletionStatusID != nil {
+ v := *req.CompletionStatusID
+ connection.CompletionStatusID = &v
+ }
+ if req.AppliesToAllWorkspaces != nil {
+ connection.AppliesToAllWorkspaces = *req.AppliesToAllWorkspaces
+ }
+ if req.WorkspaceIDs != nil {
+ if hasNonPositiveIDs(*req.WorkspaceIDs) {
+ return nil, zammadValidationError("workspace_ids must contain positive IDs")
+ }
+ connection.WorkspaceIDs = normalizePositiveIDs(*req.WorkspaceIDs)
+ }
+ if err := validateZammadConnection(connection); err != nil {
+ return nil, err
+ }
+ if connection.BaseURL != originalBaseURL || connection.CorrelationField != originalCorrelationField {
+ hasLinks, err := s.repo.HasTicketLinks(connection.ProviderID)
+ if err != nil {
+ return nil, err
+ }
+ if hasLinks {
+ return nil, zammadValidationError("base_url and correlation_field cannot change after ticket creation has started")
+ }
+ }
+ if connection.AuthMethod == models.ZammadAuthMethodOAuth {
+ if err := validateZammadOAuthConfiguration(connection); err != nil {
+ return nil, err
+ }
+ credentialsChanged := connection.BaseURL != originalBaseURL || connection.OAuthClientID != originalOAuthClientID || oauthSecretChanged
+ var replacementSecret *string
+ if credentialsChanged {
+ pending := pendingZammadOAuthCredential("pending")
+ replacementSecret = &pending
+ }
+ credentialUpdate, err := s.credentials.PrepareManagedUpdate(connection.CredentialID,
+ connection.Name+" Zammad OAuth credentials", connection.AppliesToAllWorkspaces, connection.WorkspaceIDs, replacementSecret,
+ string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return nil, err
+ }
+ if err := database.WithTx(s.db, func(tx database.Tx) error {
+ if err := s.repo.UpdateConnectionTx(tx, connection); err != nil {
+ return err
+ }
+ if err := credentialUpdate(tx); err != nil {
+ return err
+ }
+ if replacementSecret != nil {
+ return s.repo.ResetOAuthAuthorizationTx(tx, connection.ProviderID)
+ }
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+ return s.repo.GetConnection(id)
+ }
+ if connection.CredentialID <= 0 {
+ return nil, zammadValidationError("API token is not configured")
+ }
+ credentialUpdate, err := s.credentials.PrepareManagedUpdate(connection.CredentialID,
+ connection.Name+" Zammad API token", connection.AppliesToAllWorkspaces,
+ connection.WorkspaceIDs, req.APIToken, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return nil, err
+ }
+ if err := database.WithTx(s.db, func(tx database.Tx) error {
+ if err := s.repo.UpdateConnectionTx(tx, connection); err != nil {
+ return err
+ }
+ return credentialUpdate(tx)
+ }); err != nil {
+ return nil, err
+ }
+ return s.repo.GetConnection(id)
+}
+
+func (s *ZammadService) DeleteConnection(id string) error {
+ hasLinks, err := s.repo.HasTicketLinksForConnection(id)
+ if err != nil {
+ return err
+ }
+ if hasLinks {
+ return zammadValidationError("unlink all Zammad tickets before deleting this connection")
+ }
+ return s.repo.DeleteConnection(id)
+}
+
+// StartOAuth stores a short-lived state bound to this system connection and
+// the initiating administrator. The callback URI is deliberately fixed.
+func (s *ZammadService) StartOAuth(ctx context.Context, id string, actorID int, publicBaseURL string) (string, error) {
+ connection, err := s.repo.GetConnection(id)
+ if err != nil {
+ return "", err
+ }
+ if connection.AuthMethod != models.ZammadAuthMethodOAuth {
+ return "", zammadValidationError("connection does not use OAuth")
+ }
+ if err := validateZammadOAuthConfiguration(connection); err != nil {
+ return "", err
+ }
+ redirectURI, err := zammadOAuthRedirectURI(publicBaseURL)
+ if err != nil {
+ return "", err
+ }
+ state := uuid.New().String() + uuid.New().String()
+ if err := s.repo.CreateOAuthState(state, connection.ProviderID, actorID, connection.OAuthGeneration, time.Now().Add(5*time.Minute)); err != nil {
+ return "", err
+ }
+ authorizeURL := connection.BaseURL + "/oauth/authorize?" + url.Values{"response_type": {"code"}, "client_id": {connection.OAuthClientID}, "redirect_uri": {redirectURI}, "scope": {"full"}, "state": {state}}.Encode()
+ return authorizeURL, nil
+}
+
+func (s *ZammadService) InvalidateOAuthState(state string) error {
+ if strings.TrimSpace(state) == "" {
+ return nil
+ }
+ return s.repo.InvalidateOAuthState(state)
+}
+
+func (s *ZammadService) ConsumeFailedOAuthCallback(state string) (*ZammadOAuthCallbackResult, error) {
+ if strings.TrimSpace(state) == "" {
+ return nil, repository.ErrNotFound
+ }
+ consumed, err := s.repo.ConsumeOAuthState(state)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.repo.ClearOAuthAttempt(consumed.ProviderID, consumed.OAuthGeneration, state); err != nil {
+ return s.oauthCallbackResult(consumed), err
+ }
+ return s.oauthCallbackResult(consumed), nil
+}
+
+func (s *ZammadService) CompleteOAuth(ctx context.Context, state, code, publicBaseURL string) (*ZammadOAuthCallbackResult, error) {
+ redirectURI, err := zammadOAuthRedirectURI(publicBaseURL)
+ if err != nil {
+ return nil, err
+ }
+ consumed, err := s.repo.ConsumeOAuthState(state)
+ if err != nil {
+ return nil, err
+ }
+ attemptActive := true
+ defer func() {
+ if attemptActive {
+ _ = s.repo.ClearOAuthAttempt(consumed.ProviderID, consumed.OAuthGeneration, state)
+ }
+ }()
+ result := s.oauthCallbackResult(consumed)
+ connection, err := s.repo.GetConnection(consumed.ProviderID)
+ if err != nil {
+ return result, err
+ }
+ result.ProviderName = connection.Name
+ if connection.OAuthGeneration != consumed.OAuthGeneration {
+ return result, ErrZammadOAuthSuperseded
+ }
+ if connection.AuthMethod != models.ZammadAuthMethodOAuth {
+ return result, zammadValidationError("connection OAuth configuration changed")
+ }
+ if err := validateZammadOAuthConfiguration(connection); err != nil {
+ return result, err
+ }
+ if s.encryption == nil {
+ return result, errors.New("zammad OAuth encryption is not configured")
+ }
+ secret, err := s.encryption.Decrypt(connection.OAuthClientSecretEncrypted)
+ if err != nil {
+ return result, errors.New("could not decrypt Zammad OAuth client secret")
+ }
+ tokens, err := zammad.ExchangeOAuthCode(ctx, s.oauthTransport(connection), connection.BaseURL+"/oauth/token", connection.OAuthClientID, secret, code, redirectURI)
+ if err != nil {
+ return result, err
+ }
+ bundle, err := activeZammadOAuthCredential(tokens.AccessToken, tokens.RefreshToken)
+ if err != nil {
+ return result, err
+ }
+ credentialUpdate, err := s.credentials.PrepareManagedSecretUpdate(connection.CredentialID, bundle,
+ string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return result, err
+ }
+ if err := database.WithTx(s.db, func(tx database.Tx) error {
+ current, err := s.repo.GuardOAuthCallbackTx(tx, connection.ProviderID, consumed.OAuthGeneration, state)
+ if err != nil {
+ return err
+ }
+ if !current {
+ return ErrZammadOAuthSuperseded
+ }
+ if err := credentialUpdate(tx); err != nil {
+ return err
+ }
+ return s.repo.UpsertOAuthTokenTx(tx, repository.ZammadOAuthToken{ProviderID: connection.ProviderID, OAuthGeneration: consumed.OAuthGeneration, ExpiresAt: time.Now().Add(tokens.ExpiresIn)})
+ }); err != nil {
+ return result, err
+ }
+ attemptActive = false
+ return result, nil
+}
+
+func (s *ZammadService) oauthCallbackResult(consumed *repository.ZammadOAuthState) *ZammadOAuthCallbackResult {
+ result := &ZammadOAuthCallbackResult{ProviderID: consumed.ProviderID, OAuthGeneration: consumed.OAuthGeneration}
+ initiator, err := repository.NewUserRepository(s.db).GetByID(consumed.InitiatedBy)
+ if err != nil {
+ initiator = &models.User{ID: consumed.InitiatedBy, Username: "unknown"}
+ }
+ result.Initiator = initiator
+ return result
+}
+
+func (s *ZammadService) oauthTransport(connection *models.ZammadConnection) zammad.Transport {
+ if s.oauthTransportOverride != nil {
+ return s.oauthTransportOverride
+ }
+ return newZammadSafeTransport(connection.BaseURL, "/oauth/token")
+}
+
+func (s *ZammadService) oauthAccessToken(ctx context.Context, connection *models.ZammadConnection, workspaceID int) (string, error) {
+ if s.encryption == nil {
+ return "", errors.New("zammad OAuth encryption is not configured")
+ }
+ token, err := s.repo.GetOAuthToken(connection.ProviderID)
+ if err != nil {
+ return "", ErrZammadReauthorizationRequired
+ }
+ if token.ReauthorizationRequired {
+ return "", ErrZammadReauthorizationRequired
+ }
+ if token.OAuthGeneration != connection.OAuthGeneration {
+ return "", ErrZammadOAuthSuperseded
+ }
+ if token.ExpiresAt.After(time.Now().Add(2 * time.Minute)) {
+ raw, _, err := s.credentials.ResolveManaged(ctx, connection.CredentialID, workspaceID, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return "", err
+ }
+ bundle, err := parseZammadOAuthCredential(raw)
+ if err != nil {
+ return "", err
+ }
+ return bundle.AccessToken, nil
+ }
+ if s.oauthBeforeRefreshClaim != nil {
+ s.oauthBeforeRefreshClaim()
+ }
+ claimProviderID := connection.ProviderID
+ claimGeneration := token.OAuthGeneration
+ claimOwner := uuid.New().String()
+ claimed, err := s.repo.ClaimOAuthRefresh(claimProviderID, claimGeneration, claimOwner, time.Now().Add(zammadOAuthRefreshLeaseDuration))
+ if err != nil {
+ return "", err
+ }
+ if !claimed {
+ // Another request owns the short per-connection refresh lease. Never
+ // issue a competing refresh request, which could invalidate rotation.
+ return "", errors.New("zammad OAuth refresh is already in progress")
+ }
+ claimActive := true
+ defer func() {
+ if claimActive {
+ _ = s.repo.ReleaseOAuthRefreshClaim(claimProviderID, claimGeneration, claimOwner)
+ }
+ }()
+ connection, err = s.repo.GetConnection(claimProviderID)
+ if err != nil {
+ return "", err
+ }
+ if connection.OAuthGeneration != claimGeneration {
+ return "", ErrZammadOAuthSuperseded
+ }
+ token, err = s.repo.GetOAuthTokenForRefreshClaim(claimProviderID, claimGeneration, claimOwner)
+ if err != nil {
+ return "", ErrZammadOAuthSuperseded
+ }
+ if token.ReauthorizationRequired {
+ return "", ErrZammadReauthorizationRequired
+ }
+ raw, _, err := s.credentials.ResolveManaged(ctx, connection.CredentialID, workspaceID, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return "", err
+ }
+ bundle, err := parseZammadOAuthCredential(raw)
+ if err != nil {
+ return "", err
+ }
+ if token.ExpiresAt.After(time.Now().Add(2 * time.Minute)) {
+ if err := s.repo.ReleaseOAuthRefreshClaim(claimProviderID, claimGeneration, claimOwner); err != nil {
+ return "", err
+ }
+ claimActive = false
+ return bundle.AccessToken, nil
+ }
+ clientSecret, err := s.encryption.Decrypt(connection.OAuthClientSecretEncrypted)
+ if err != nil {
+ return "", errors.New("could not decrypt Zammad OAuth client secret")
+ }
+ refreshed, err := zammad.RefreshOAuthToken(ctx, s.oauthTransport(connection), connection.BaseURL+"/oauth/token", connection.OAuthClientID, clientSecret, bundle.RefreshToken)
+ if errors.Is(err, zammad.ErrInvalidGrant) {
+ if persistErr := s.markOAuthReauthorizationRequired(connection, connection.OAuthGeneration, claimOwner); persistErr != nil {
+ return "", persistErr
+ }
+ claimActive = false
+ return "", ErrZammadReauthorizationRequired
+ }
+ if err != nil {
+ return "", err
+ }
+ updatedBundle, err := activeZammadOAuthCredential(refreshed.AccessToken, refreshed.RefreshToken)
+ if err != nil {
+ return "", err
+ }
+ credentialUpdate, err := s.credentials.PrepareManagedSecretUpdate(connection.CredentialID, updatedBundle,
+ string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return "", err
+ }
+ if err := database.WithTx(s.db, func(tx database.Tx) error {
+ current, err := s.repo.GuardOAuthGenerationTx(tx, connection.ProviderID, connection.OAuthGeneration)
+ if err != nil {
+ return err
+ }
+ if !current {
+ return ErrZammadOAuthSuperseded
+ }
+ owned, err := s.repo.GuardOAuthRefreshClaimTx(tx, connection.ProviderID, connection.OAuthGeneration, claimOwner)
+ if err != nil {
+ return err
+ }
+ if !owned {
+ return ErrZammadOAuthSuperseded
+ }
+ if err := credentialUpdate(tx); err != nil {
+ return err
+ }
+ return s.repo.UpsertOAuthTokenTx(tx, repository.ZammadOAuthToken{ProviderID: connection.ProviderID, OAuthGeneration: connection.OAuthGeneration, ExpiresAt: time.Now().Add(refreshed.ExpiresIn)})
+ }); err != nil {
+ return "", err
+ }
+ claimActive = false
+ return refreshed.AccessToken, nil
+}
+
+func (s *ZammadService) markOAuthReauthorizationRequired(connection *models.ZammadConnection, generation int64, claimOwner string) error {
+ pending := pendingZammadOAuthCredential("reauthorization_required")
+ credentialUpdate, err := s.credentials.PrepareManagedSecretUpdate(connection.CredentialID, pending,
+ string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return err
+ }
+ return database.WithTx(s.db, func(tx database.Tx) error {
+ current, err := s.repo.GuardOAuthGenerationTx(tx, connection.ProviderID, generation)
+ if err != nil {
+ return err
+ }
+ if !current {
+ return ErrZammadOAuthSuperseded
+ }
+ owned, err := s.repo.GuardOAuthRefreshClaimTx(tx, connection.ProviderID, generation, claimOwner)
+ if err != nil {
+ return err
+ }
+ if !owned {
+ return ErrZammadOAuthSuperseded
+ }
+ if err := credentialUpdate(tx); err != nil {
+ return err
+ }
+ marked, err := s.repo.MarkOAuthReauthorizationRequiredTx(tx, connection.ProviderID, generation, claimOwner)
+ if err != nil {
+ return err
+ }
+ if !marked {
+ return ErrZammadOAuthSuperseded
+ }
+ return nil
+ })
+}
+
+func (s *ZammadService) TestConnection(ctx context.Context, id string) (*models.ZammadConnectionMetadata, error) {
+ connection, err := s.repo.GetConnection(id)
+ if err != nil {
+ return nil, err
+ }
+ workspaceID := 0
+ if !connection.AppliesToAllWorkspaces && len(connection.WorkspaceIDs) > 0 {
+ workspaceID = connection.WorkspaceIDs[0]
+ }
+ client, err := s.clientForConnection(ctx, connection, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ metadata, err := client.Metadata(ctx)
+ if err == nil && connection.AuthMethod == models.ZammadAuthMethodAPIToken {
+ err = client.ValidateCorrelationField(ctx, connection.CorrelationField)
+ metadata.CorrelationFieldVerified = err == nil
+ } else if err == nil {
+ // The scoped OAuth service account intentionally need not have
+ // admin.object. It is still tested against the groups and ticket-state
+ // endpoints it actually needs; the custom correlation field is shown as
+ // explicitly unverified rather than requiring broader privileges.
+ metadata.CorrelationFieldVerified = false
+ }
+ if err == nil {
+ err = validateZammadMetadata(connection, metadata)
+ }
+ safeError := ""
+ if err != nil {
+ safeError = RedactString(err.Error())
+ }
+ _ = s.repo.SetConnectionTestResult(connection.ProviderID, time.Now(), safeError)
+ return metadata, err
+}
+
+func (s *ZammadService) MetadataForWorkspace(ctx context.Context, id string, workspaceID int) (*models.ZammadConnectionMetadata, error) {
+ connection, client, err := s.client(ctx, id, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ metadata, err := client.Metadata(ctx)
+ if err != nil {
+ return nil, err
+ }
+ metadata.Groups = allowedZammadGroups(connection, metadata.Groups)
+ return metadata, nil
+}
+
+func (s *ZammadService) OwnersForWorkspace(ctx context.Context, id string, workspaceID, groupID int) ([]models.ZammadOwner, error) {
+ if groupID <= 0 {
+ return nil, zammadValidationError("group_id must be positive")
+ }
+ connection, client, err := s.client(ctx, id, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ groups, err := client.Groups(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := allowedZammadGroup(connection, groups, groupID); err != nil {
+ return nil, err
+ }
+ owners, err := client.Owners(ctx, groupID)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]models.ZammadOwner, 0, len(owners)+1)
+ result = append(result, models.ZammadOwner{ID: 1, Name: "Unassigned"})
+ for _, owner := range owners {
+ if owner.ID != 1 {
+ result = append(result, models.ZammadOwner{ID: owner.ID, Name: owner.Name})
+ }
+ }
+ return result, nil
+}
+
+func (s *ZammadService) TicketLinksForItem(itemID int) ([]*models.ZammadTicketLink, error) {
+ return s.repo.GetTicketLinksForItem(itemID)
+}
+
+func (s *ZammadService) GetTicketLink(id string) (*models.ZammadTicketLink, error) {
+ return s.repo.GetTicketLink(id)
+}
+
+// LinkExistingTicket attaches a remote ticket without creating a second one.
+// The local reservation is written before the remote correlation field so a
+// competing item cannot claim the same provider/ticket pair.
+func (s *ZammadService) LinkExistingTicket(ctx context.Context, itemID, actorID int, req models.LinkZammadTicketRequest) (*models.ZammadTicketLink, error) {
+ req.ConnectionID = strings.TrimSpace(req.ConnectionID)
+ req.TicketNumber = strings.TrimSpace(req.TicketNumber)
+ if req.ConnectionID == "" || req.TicketNumber == "" {
+ return nil, zammadValidationError("connection_id and ticket_number are required")
+ }
+ item, err := repository.NewItemRepository(s.db).FindByIDWithDetails(itemID)
+ if err != nil {
+ return nil, err
+ }
+ connection, client, err := s.client(ctx, req.ConnectionID, item.WorkspaceID)
+ if err != nil {
+ return nil, err
+ }
+ found, err := client.FindByNumber(ctx, req.TicketNumber)
+ if err != nil {
+ return nil, err
+ }
+ if found == nil {
+ return nil, zammadValidationError("Zammad ticket was not found")
+ }
+ ticket, err := client.GetTicket(ctx, found.ID)
+ if err != nil {
+ return nil, err
+ }
+ if ticket.Number != req.TicketNumber {
+ return nil, zammadValidationError("Zammad ticket was not found")
+ }
+ metadata, err := client.Metadata(ctx)
+ if err != nil {
+ return nil, err
+ }
+ group, err := allowedZammadGroup(connection, metadata.Groups, ticket.GroupID)
+ if err != nil {
+ return nil, err
+ }
+ correlation := fmt.Sprintf("windshift:%s:%s-%d", connection.ProviderID, item.WorkspaceKey, item.WorkspaceItemNumber)
+ link, err := s.repo.GetTicketLinkForItem(itemID, connection.ProviderID)
+ if err != nil && !errors.Is(err, repository.ErrNotFound) {
+ return nil, err
+ }
+ if link != nil {
+ if link.TicketID != ticket.ID {
+ return nil, zammadValidationError("this item already has another Zammad ticket for the selected connection")
+ }
+ correlation = link.CorrelationKey
+ if link.SyncState == models.ZammadSyncLinked && link.ItemIntegrationLinkID != "" {
+ return link, nil
+ }
+ }
+ remoteCorrelation, err := zammadTicketAttributeString(ticket, connection.CorrelationField)
+ if err != nil {
+ return nil, err
+ }
+ if remoteCorrelation != "" && remoteCorrelation != correlation {
+ return nil, zammadValidationError("Zammad ticket is already linked through another correlation key")
+ }
+ if link == nil {
+ statusName := zammadStateName(metadata, ticket.StateID, ticket.StateName)
+ groupName := ticket.GroupName
+ if groupName == "" {
+ groupName = group.Name
+ }
+ link = &models.ZammadTicketLink{
+ ID: uuid.New().String(), ItemID: itemID, ProviderID: connection.ProviderID,
+ TicketID: ticket.ID, TicketNumber: ticket.Number,
+ TicketURL: connection.BaseURL + "/#ticket/zoom/" + strconv.Itoa(ticket.ID),
+ GroupID: ticket.GroupID, GroupName: groupName,
+ OwnerID: ticket.OwnerID, OwnerName: ticket.OwnerName,
+ CorrelationKey: correlation, SyncState: models.ZammadSyncCreating,
+ LastStatusID: ticket.StateID, LastStatusName: statusName, CreatedBy: &actorID,
+ }
+ if err := s.repo.ReserveExistingTicketLink(link); err != nil {
+ if !errors.Is(err, repository.ErrDuplicateEntry) {
+ return nil, err
+ }
+ existing, getErr := s.repo.GetTicketLinkForItem(itemID, connection.ProviderID)
+ if getErr != nil || existing.TicketID != ticket.ID {
+ return nil, err
+ }
+ link = existing
+ correlation = link.CorrelationKey
+ }
+ }
+
+ if remoteCorrelation == "" {
+ ticket, err = client.UpdateTicket(ctx, ticket.ID, nil, nil, nil, connection.CorrelationField, correlation)
+ if err != nil {
+ return nil, err
+ }
+ }
+ link.TicketID = ticket.ID
+ link.TicketNumber = ticket.Number
+ link.TicketURL = connection.BaseURL + "/#ticket/zoom/" + strconv.Itoa(ticket.ID)
+ link.GroupID = ticket.GroupID
+ link.GroupName = zammadGroupName(metadata, ticket.GroupID, ticket.GroupName)
+ link.OwnerID = ticket.OwnerID
+ link.OwnerName = resolveZammadOwnerName(ctx, client, ticket)
+ link.LastStatusID = ticket.StateID
+ link.LastStatusName = zammadStateName(metadata, ticket.StateID, ticket.StateName)
+ if err := s.repo.CompleteExistingTicketLink(link.ID, link.ID+"-external", link, actorID); err != nil {
+ return nil, err
+ }
+ return s.repo.GetTicketLink(link.ID)
+}
+
+func (s *ZammadService) CreateTicket(ctx context.Context, itemID, actorID int, req models.CreateZammadTicketRequest) (*models.ZammadTicketLink, error) {
+ item, err := repository.NewItemRepository(s.db).FindByIDWithDetails(itemID)
+ if err != nil {
+ return nil, err
+ }
+ connection, client, err := s.client(ctx, req.ConnectionID, item.WorkspaceID)
+ if err != nil {
+ return nil, err
+ }
+ groupID, groupName := req.GroupID, ""
+ if groupID == 0 {
+ groupID = connection.DefaultGroupID
+ }
+ groups, metadataErr := client.Groups(ctx)
+ if metadataErr != nil {
+ return nil, metadataErr
+ }
+ for _, group := range groups {
+ if group.ID == groupID || (groupID == 0 && group.Name == connection.DefaultGroupName) {
+ groupID = group.ID
+ groupName = group.Name
+ break
+ }
+ }
+ if groupID == 0 || groupName == "" {
+ return nil, zammadValidationError("selected Zammad group is missing or inactive")
+ }
+ allowed := slices.Contains(connection.AllowedGroupIDs, groupID)
+ if len(connection.AllowedGroupIDs) == 0 {
+ allowed = (connection.DefaultGroupID > 0 && connection.DefaultGroupID == groupID) ||
+ (connection.DefaultGroupID == 0 && connection.DefaultGroupName == groupName)
+ }
+ if !allowed {
+ return nil, zammadValidationError("selected Zammad group is not allowed for this connection")
+ }
+ correlation := fmt.Sprintf("windshift:%s:%s-%d", connection.ProviderID, item.WorkspaceKey, item.WorkspaceItemNumber)
+ link, err := s.repo.GetTicketLinkForItem(itemID, connection.ProviderID)
+ if err != nil && !errors.Is(err, repository.ErrNotFound) {
+ return nil, err
+ }
+ if link == nil {
+ link = &models.ZammadTicketLink{
+ ID: uuid.New().String(), ItemID: itemID, ProviderID: connection.ProviderID,
+ GroupID: groupID, GroupName: groupName, CorrelationKey: correlation,
+ SyncState: models.ZammadSyncPending, CreatedBy: &actorID,
+ }
+ if err := s.repo.CreatePendingTicketLink(link); err != nil && !errors.Is(err, repository.ErrDuplicateEntry) {
+ return nil, err
+ }
+ link, err = s.repo.GetTicketLinkForItem(itemID, connection.ProviderID)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ // Once a durable creation attempt exists, retries keep its original
+ // destination and correlation key. This also covers an item moved to a
+ // different workspace between attempts.
+ groupID = link.GroupID
+ groupName = link.GroupName
+ correlation = link.CorrelationKey
+ }
+ if link.TicketID != 0 {
+ return link, nil
+ }
+ wasUncertain := link.SyncState == models.ZammadSyncUncertain
+ claimed, err := s.repo.ClaimTicketCreation(itemID, connection.ProviderID, time.Now())
+ if err != nil {
+ return nil, err
+ }
+ if !claimed {
+ return s.repo.GetTicketLinkForItem(itemID, connection.ProviderID)
+ }
+
+ ticket, requestErr := client.FindByCorrelation(ctx, connection.CorrelationField, correlation)
+ postAttempted := false
+ if requestErr == nil && ticket == nil && wasUncertain {
+ _ = s.repo.MarkTicketLinkUncertain(link.ID, "Zammad ticket creation outcome is uncertain; retry only searches by correlation key")
+ return s.repo.GetTicketLink(link.ID)
+ }
+ if requestErr == nil && ticket == nil {
+ postAttempted = true
+ title := truncateRunes(fmt.Sprintf("[%s-%d] %s", item.WorkspaceKey, item.WorkspaceItemNumber, item.Title), 200)
+ body := truncateRunes(strings.TrimSpace(item.Description), 20000)
+ if body == "" {
+ body = title
+ }
+ ticket, requestErr = client.CreateTicket(ctx, title, body, connection.DefaultCustomer,
+ groupName, connection.CorrelationField, correlation)
+ }
+ if requestErr != nil {
+ safeError := RedactString(requestErr.Error())
+ if postAttempted && zammadCreationOutcomeUncertain(requestErr) {
+ _ = s.repo.MarkTicketLinkUncertain(link.ID, safeError)
+ } else {
+ _ = s.repo.MarkTicketLinkFailed(link.ID, safeError)
+ }
+ return nil, requestErr
+ }
+ if ticket.GroupID == 0 && !postAttempted {
+ ticket, requestErr = client.GetTicket(ctx, ticket.ID)
+ if requestErr != nil {
+ _ = s.repo.MarkTicketLinkUncertain(link.ID, RedactString(requestErr.Error()))
+ return nil, requestErr
+ }
+ }
+ ticketGroupID := ticket.GroupID
+ if ticketGroupID == 0 {
+ ticketGroupID = groupID
+ ticket.GroupID = groupID
+ }
+ allowedGroup, groupErr := s.requireAllowedTicketGroup(ctx, connection, client, ticket, groups)
+ if groupErr != nil {
+ _ = s.repo.MarkTicketLinkUncertain(link.ID, RedactString(groupErr.Error()))
+ return nil, groupErr
+ }
+ statusName := ticket.StateName
+ if statusName == "" {
+ statusName = s.resolveStateName(ctx, client, ticket.StateID)
+ }
+ ticketGroupName := ticket.GroupName
+ if ticketGroupName == "" {
+ ticketGroupName = allowedGroup.Name
+ }
+ ticketURL := connection.BaseURL + "/#ticket/zoom/" + fmt.Sprintf("%d", ticket.ID)
+ if err := s.repo.CompleteTicketCreation(link.ID, ticket.ID, ticket.Number, ticketURL,
+ ticket.StateID, statusName, ticketGroupID, ticketGroupName, ticket.OwnerID, ticket.OwnerName, actorID); err != nil {
+ // The remote ticket is known to exist. Keep retries search-only until
+ // the durable local association has been completed.
+ _ = s.repo.MarkTicketLinkUncertain(link.ID, RedactString(err.Error()))
+ return nil, err
+ }
+ return s.repo.GetTicketLink(link.ID)
+}
+
+func validateZammadMetadata(connection *models.ZammadConnection, metadata *models.ZammadConnectionMetadata) error {
+ activeGroups := make(map[int]string, len(metadata.Groups))
+ for _, group := range metadata.Groups {
+ activeGroups[group.ID] = group.Name
+ }
+ if connection.DefaultGroupID > 0 {
+ if _, ok := activeGroups[connection.DefaultGroupID]; !ok {
+ return zammadValidationError("default Zammad group is missing or inactive")
+ }
+ } else if connection.DefaultGroupName != "" {
+ found := false
+ for _, name := range activeGroups {
+ if name == connection.DefaultGroupName {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return zammadValidationError("default Zammad group is missing or inactive")
+ }
+ }
+ for _, groupID := range connection.AllowedGroupIDs {
+ if _, ok := activeGroups[groupID]; !ok {
+ return zammadValidationError(fmt.Sprintf("allowed Zammad group %d is missing or inactive", groupID))
+ }
+ }
+ activeStates := make(map[int]struct{}, len(metadata.States))
+ for _, state := range metadata.States {
+ activeStates[state.ID] = struct{}{}
+ }
+ for _, stateID := range connection.ClosedStateIDs {
+ if _, ok := activeStates[stateID]; !ok {
+ return zammadValidationError(fmt.Sprintf("closed Zammad state %d is missing or inactive", stateID))
+ }
+ }
+ return nil
+}
+
+func (s *ZammadService) UpdateTicketLink(ctx context.Context, linkID string, req models.UpdateZammadTicketLinkRequest) (*models.ZammadTicketLink, error) {
+ if req.StateID == nil && req.GroupID == nil && req.OwnerID == nil {
+ return nil, zammadValidationError("at least one of state_id, group_id, or owner_id is required")
+ }
+ if (req.StateID != nil && *req.StateID <= 0) || (req.GroupID != nil && *req.GroupID <= 0) || (req.OwnerID != nil && *req.OwnerID <= 0) {
+ return nil, zammadValidationError("state_id, group_id, and owner_id must be positive")
+ }
+ link, err := s.repo.GetTicketLink(linkID)
+ if err != nil {
+ return nil, err
+ }
+ if link.TicketID <= 0 {
+ return nil, zammadValidationError("Zammad ticket has not been created")
+ }
+ item, err := repository.NewItemRepository(s.db).FindByIDWithDetails(link.ItemID)
+ if err != nil {
+ return nil, err
+ }
+ connection, client, err := s.client(ctx, link.ProviderID, item.WorkspaceID)
+ if err != nil {
+ return nil, err
+ }
+ current, err := client.GetTicket(ctx, link.TicketID)
+ if err != nil {
+ return nil, err
+ }
+ metadata, err := client.Metadata(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ effectiveGroupID := current.GroupID
+ if req.GroupID != nil {
+ effectiveGroupID = *req.GroupID
+ }
+ if _, err := allowedZammadGroup(connection, metadata.Groups, effectiveGroupID); err != nil {
+ return nil, err
+ }
+ if req.StateID != nil && !zammadStateExists(metadata, *req.StateID) {
+ return nil, zammadValidationError("selected Zammad state is missing or inactive")
+ }
+
+ ownerID := req.OwnerID
+ if req.GroupID != nil && req.OwnerID == nil && *req.GroupID != current.GroupID {
+ // An owner may not have change access in the new group. Zammad's
+ // unassigned system user is the deterministic safe default.
+ unassigned := 1
+ ownerID = &unassigned
+ }
+ if ownerID != nil && *ownerID != 1 {
+ owners, err := client.Owners(ctx, effectiveGroupID)
+ if err != nil {
+ return nil, err
+ }
+ if !slices.ContainsFunc(owners, func(owner zammad.Owner) bool { return owner.ID == *ownerID }) {
+ return nil, zammadValidationError("selected Zammad owner cannot be assigned tickets in this group")
+ }
+ }
+
+ updated, err := client.UpdateTicket(ctx, link.TicketID, req.StateID, req.GroupID, ownerID, "", "")
+ if err != nil {
+ return nil, err
+ }
+ if err := s.persistTicketSnapshot(ctx, link, connection, client, updated, metadata); err != nil {
+ return nil, err
+ }
+ return s.repo.GetTicketLink(link.ID)
+}
+
+// UnlinkTicket removes the Windshift association but never deletes the Zammad
+// ticket. The remote correlation field is cleared only when it still contains
+// this link's exact value. Ambiguous upstream failures keep the local link so
+// the user can safely retry.
+func (s *ZammadService) UnlinkTicket(ctx context.Context, linkID string) (*models.ZammadTicketLink, error) {
+ link, err := s.repo.GetTicketLink(linkID)
+ if err != nil {
+ return nil, err
+ }
+ if link.TicketID > 0 {
+ item, err := repository.NewItemRepository(s.db).FindByIDWithDetails(link.ItemID)
+ if err != nil {
+ return nil, err
+ }
+ connection, client, err := s.client(ctx, link.ProviderID, item.WorkspaceID)
+ if err != nil {
+ return nil, err
+ }
+ ticket, getErr := client.GetTicket(ctx, link.TicketID)
+ if getErr != nil {
+ var apiErr *zammad.APIError
+ if !errors.As(getErr, &apiErr) || apiErr.StatusCode != 404 {
+ return nil, getErr
+ }
+ } else {
+ remoteCorrelation, err := zammadTicketAttributeString(ticket, connection.CorrelationField)
+ if err != nil {
+ return nil, err
+ }
+ if remoteCorrelation == link.CorrelationKey {
+ if _, err := client.UpdateTicket(ctx, ticket.ID, nil, nil, nil, connection.CorrelationField, ""); err != nil {
+ return nil, err
+ }
+ }
+ }
+ }
+ if err := s.repo.DeleteTicketLink(link.ID); err != nil {
+ return nil, err
+ }
+ return link, nil
+}
+
+func (s *ZammadService) SyncTicketLink(ctx context.Context, linkID string) (*models.ZammadTicketLink, error) {
+ link, err := s.repo.GetTicketLink(linkID)
+ if err != nil {
+ return nil, err
+ }
+ if link.TicketID == 0 {
+ return nil, zammadValidationError("Zammad ticket has not been created")
+ }
+ claimed, err := s.repo.ClaimSync(link.ID, time.Now().Add(2*time.Minute))
+ if err != nil {
+ return nil, err
+ }
+ if !claimed {
+ return link, nil
+ }
+ return s.syncClaimedTicketLink(ctx, link)
+}
+
+// RetryUncertainTicketCreation is an explicit administrator override after
+// the remote system has been checked and confirmed not to contain the ticket.
+func (s *ZammadService) RetryUncertainTicketCreation(ctx context.Context, linkID string, actorID int) (*models.ZammadTicketLink, error) {
+ link, err := s.repo.GetTicketLink(linkID)
+ if err != nil {
+ return nil, err
+ }
+ reset, err := s.repo.ResetUncertainTicketCreation(linkID)
+ if err != nil {
+ return nil, err
+ }
+ if !reset {
+ return nil, zammadValidationError("ticket creation is not awaiting an administrator decision")
+ }
+ return s.CreateTicket(ctx, link.ItemID, actorID, models.CreateZammadTicketRequest{
+ ConnectionID: link.ProviderID,
+ GroupID: link.GroupID,
+ })
+}
+
+func (s *ZammadService) syncClaimedTicketLink(ctx context.Context, link *models.ZammadTicketLink) (*models.ZammadTicketLink, error) {
+ item, itemErr := repository.NewItemRepository(s.db).FindByID(link.ItemID)
+ if itemErr != nil {
+ s.recordZammadSyncError(link, itemErr)
+ return nil, itemErr
+ }
+ connection, client, err := s.client(ctx, link.ProviderID, item.WorkspaceID)
+ if err != nil {
+ s.recordZammadSyncError(link, err)
+ return nil, err
+ }
+ if !connection.Enabled {
+ _ = s.repo.UpdateTicketLinkSync(link.ID, link.LastStatusID, link.LastStatusName,
+ link.GroupID, link.GroupName, link.OwnerID, link.OwnerName, "", time.Now(), false, false)
+ return s.repo.GetTicketLink(link.ID)
+ }
+ ticket, err := client.GetTicket(ctx, link.TicketID)
+ if err != nil {
+ s.recordZammadSyncError(link, err)
+ return nil, err
+ }
+ allowedGroup, err := s.requireAllowedTicketGroup(ctx, connection, client, ticket, nil)
+ if err != nil {
+ s.recordZammadSyncError(link, err)
+ return nil, err
+ }
+ if ticket.GroupName == "" {
+ ticket.GroupName = allowedGroup.Name
+ }
+ var metadata *models.ZammadConnectionMetadata
+ needsStateName := ticket.StateName == "" && (ticket.StateID != link.LastStatusID || link.LastStatusName == "")
+ needsGroupName := ticket.GroupName == "" && (ticket.GroupID != link.GroupID || link.GroupName == "")
+ if needsStateName || needsGroupName {
+ metadata, err = client.Metadata(ctx)
+ if err != nil {
+ s.recordZammadSyncError(link, err)
+ return nil, err
+ }
+ }
+ if err := s.persistTicketSnapshot(ctx, link, connection, client, ticket, metadata); err != nil {
+ return nil, err
+ }
+ return s.repo.GetTicketLink(link.ID)
+}
+
+func (s *ZammadService) recordZammadSyncError(link *models.ZammadTicketLink, err error) {
+ _ = s.repo.UpdateTicketLinkSync(link.ID, link.LastStatusID, link.LastStatusName,
+ link.GroupID, link.GroupName, link.OwnerID, link.OwnerName,
+ RedactString(err.Error()), time.Now(), false, false)
+}
+
+func (s *ZammadService) persistTicketSnapshot(ctx context.Context, link *models.ZammadTicketLink, connection *models.ZammadConnection, client *zammad.Client, ticket *zammad.Ticket, metadata *models.ZammadConnectionMetadata) error {
+ statusName := zammadStateName(metadata, ticket.StateID, ticket.StateName)
+ if statusName == "" && ticket.StateID == link.LastStatusID {
+ statusName = link.LastStatusName
+ }
+ groupName := zammadGroupName(metadata, ticket.GroupID, ticket.GroupName)
+ if groupName == "" && ticket.GroupID == link.GroupID {
+ groupName = link.GroupName
+ }
+ ownerName := resolveZammadOwnerName(ctx, client, ticket)
+ isClosed := slices.Contains(connection.ClosedStateIDs, ticket.StateID)
+ completionApplied := false
+ if connection.CompletionStatusID != nil && isClosed && !link.CompletionApplied {
+ if err := s.completeWindshiftItem(ctx, link, connection); err != nil {
+ safeError := RedactString(err.Error())
+ _ = s.repo.UpdateTicketLinkSync(link.ID, ticket.StateID, statusName,
+ ticket.GroupID, groupName, ticket.OwnerID, ownerName,
+ safeError, time.Now(), false, false)
+ return err
+ }
+ completionApplied = true
+ }
+ setCompletionApplied := !isClosed || completionApplied
+ return s.repo.UpdateTicketLinkSync(link.ID, ticket.StateID, statusName,
+ ticket.GroupID, groupName, ticket.OwnerID, ownerName,
+ "", time.Now(), setCompletionApplied, completionApplied)
+}
+
+func resolveZammadOwnerName(ctx context.Context, client *zammad.Client, ticket *zammad.Ticket) string {
+ if ticket == nil || ticket.OwnerID <= 0 {
+ return ""
+ }
+ if ticket.OwnerName != "" {
+ return ticket.OwnerName
+ }
+ if ticket.OwnerID == 1 {
+ return "Unassigned"
+ }
+ owners, err := client.Owners(ctx, ticket.GroupID)
+ if err != nil {
+ return ""
+ }
+ for _, owner := range owners {
+ if owner.ID == ticket.OwnerID {
+ return owner.Name
+ }
+ }
+ return ""
+}
+
+func allowedZammadGroups(connection *models.ZammadConnection, groups []models.ZammadGroup) []models.ZammadGroup {
+ allowed := make([]models.ZammadGroup, 0, len(groups))
+ for _, group := range groups {
+ if slices.Contains(connection.AllowedGroupIDs, group.ID) ||
+ (len(connection.AllowedGroupIDs) == 0 && (group.ID == connection.DefaultGroupID || group.Name == connection.DefaultGroupName)) {
+ allowed = append(allowed, group)
+ }
+ }
+ return allowed
+}
+
+func allowedZammadGroup(connection *models.ZammadConnection, groups []models.ZammadGroup, groupID int) (models.ZammadGroup, error) {
+ for _, group := range allowedZammadGroups(connection, groups) {
+ if group.ID == groupID {
+ return group, nil
+ }
+ }
+ return models.ZammadGroup{}, zammadValidationError("selected Zammad group is missing, inactive, or not allowed for this connection")
+}
+
+func (s *ZammadService) requireAllowedTicketGroup(ctx context.Context, connection *models.ZammadConnection, client *zammad.Client, ticket *zammad.Ticket, knownGroups []models.ZammadGroup) (models.ZammadGroup, error) {
+ if ticket == nil || ticket.GroupID <= 0 {
+ return models.ZammadGroup{}, zammadValidationError("Zammad ticket group is missing or invalid")
+ }
+ if len(knownGroups) > 0 {
+ return allowedZammadGroup(connection, knownGroups, ticket.GroupID)
+ }
+ if len(connection.AllowedGroupIDs) > 0 {
+ if slices.Contains(connection.AllowedGroupIDs, ticket.GroupID) {
+ return models.ZammadGroup{ID: ticket.GroupID, Name: ticket.GroupName, Active: true}, nil
+ }
+ return models.ZammadGroup{}, zammadValidationError("Zammad ticket group is not allowed for this connection")
+ }
+ if connection.DefaultGroupID > 0 {
+ if connection.DefaultGroupID == ticket.GroupID {
+ return models.ZammadGroup{ID: ticket.GroupID, Name: ticket.GroupName, Active: true}, nil
+ }
+ return models.ZammadGroup{}, zammadValidationError("Zammad ticket group is not allowed for this connection")
+ }
+ if ticket.GroupName != "" {
+ if ticket.GroupName == connection.DefaultGroupName {
+ return models.ZammadGroup{ID: ticket.GroupID, Name: ticket.GroupName, Active: true}, nil
+ }
+ return models.ZammadGroup{}, zammadValidationError("Zammad ticket group is not allowed for this connection")
+ }
+ groups, err := client.Groups(ctx)
+ if err != nil {
+ return models.ZammadGroup{}, err
+ }
+ return allowedZammadGroup(connection, groups, ticket.GroupID)
+}
+
+func zammadCreationOutcomeUncertain(err error) bool {
+ var upstreamErr *zammad.UpstreamError
+ if errors.As(err, &upstreamErr) {
+ return true
+ }
+ var apiErr *zammad.APIError
+ if !errors.As(err, &apiErr) {
+ return false
+ }
+ return apiErr.StatusCode == http.StatusRequestTimeout || apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
+}
+
+func zammadStateExists(metadata *models.ZammadConnectionMetadata, stateID int) bool {
+ return metadata != nil && slices.ContainsFunc(metadata.States, func(state models.ZammadState) bool {
+ return state.ID == stateID && state.Active
+ })
+}
+
+func zammadStateName(metadata *models.ZammadConnectionMetadata, stateID int, fallback string) string {
+ if fallback != "" {
+ return fallback
+ }
+ if metadata != nil {
+ for _, state := range metadata.States {
+ if state.ID == stateID {
+ return state.Name
+ }
+ }
+ }
+ return ""
+}
+
+func zammadGroupName(metadata *models.ZammadConnectionMetadata, groupID int, fallback string) string {
+ if fallback != "" {
+ return fallback
+ }
+ if metadata != nil {
+ for _, group := range metadata.Groups {
+ if group.ID == groupID {
+ return group.Name
+ }
+ }
+ }
+ return ""
+}
+
+func zammadTicketAttributeString(ticket *zammad.Ticket, field string) (string, error) {
+ if ticket == nil || ticket.Attributes == nil {
+ return "", nil
+ }
+ raw, ok := ticket.Attributes[field]
+ if !ok || string(raw) == "null" {
+ return "", nil
+ }
+ var value string
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return "", &zammad.UpstreamError{Cause: errors.New("zammad correlation field is not a string")}
+ }
+ return strings.TrimSpace(value), nil
+}
+
+func (s *ZammadService) SyncDue(ctx context.Context, limit int) error {
+ if limit <= 0 {
+ limit = 50
+ }
+ links, err := s.repo.ListDueTicketLinks(time.Now().Add(-2*time.Minute), limit)
+ if err != nil {
+ return err
+ }
+ var firstError error
+ for _, link := range links {
+ claimed, claimErr := s.repo.ClaimSync(link.ID, time.Now().Add(2*time.Minute))
+ if claimErr != nil || !claimed {
+ if claimErr != nil && firstError == nil {
+ firstError = claimErr
+ }
+ continue
+ }
+ if _, syncErr := s.syncClaimedTicketLink(ctx, link); syncErr != nil && firstError == nil {
+ firstError = syncErr
+ }
+ }
+ return firstError
+}
+
+func (s *ZammadService) completeWindshiftItem(ctx context.Context, link *models.ZammadTicketLink, connection *models.ZammadConnection) error {
+ if connection.CompletionStatusID == nil {
+ return nil
+ }
+ if connection.CreatedBy == nil {
+ return errors.New("configured Zammad actor no longer exists")
+ }
+ itemRepo := repository.NewItemRepository(s.db)
+ item, err := itemRepo.FindByIDWithDetails(link.ItemID)
+ if err != nil {
+ return err
+ }
+ allowed, err := s.permission.HasWorkspacePermission(*connection.CreatedBy, item.WorkspaceID, models.PermissionItemEdit)
+ if err != nil {
+ return err
+ }
+ if !allowed {
+ return errors.New("configured Zammad actor no longer has item edit permission")
+ }
+ result, err := s.workflow.PerformTransition(ctx, PerformTransitionRequest{
+ ItemID: link.ItemID, ToStatusID: *connection.CompletionStatusID,
+ ActorUserID: *connection.CreatedBy,
+ EventMetadata: func() itemevents.Metadata {
+ metadata := itemevents.Integration(connection.ProviderID, "zammad_sync")
+ metadata.SourceRef = fmt.Sprintf("ticket:%d/link:%s", link.TicketID, link.ID)
+ metadata.CorrelationID = link.CorrelationKey
+ return metadata
+ }(),
+ }, itemRepo, s.condition, s.approval)
+ if err != nil {
+ return err
+ }
+ if !result.NoOp && s.events != nil {
+ s.events.EmitStatusChanged(result.Item, result.OldStatusID, result.NewStatusID, *connection.CreatedBy, "Zammad")
+ }
+ return nil
+}
+
+func (s *ZammadService) client(ctx context.Context, id string, workspaceID int) (*models.ZammadConnection, *zammad.Client, error) {
+ connection, err := s.repo.GetConnection(id)
+ if err != nil {
+ return nil, nil, err
+ }
+ available, err := s.repo.IsConnectionAvailableToWorkspace(id, workspaceID)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !available {
+ return nil, nil, ErrCredentialScopeMismatch
+ }
+ client, err := s.clientForConnection(ctx, connection, workspaceID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return connection, client, nil
+}
+
+func (s *ZammadService) clientForConnection(ctx context.Context, connection *models.ZammadConnection, workspaceID int) (*zammad.Client, error) {
+ transport := s.transportOverride
+ if transport == nil {
+ transport = newZammadSafeTransport(connection.BaseURL, "/api/v1/")
+ }
+ if connection.AuthMethod == models.ZammadAuthMethodOAuth {
+ token, err := s.oauthAccessToken(ctx, connection, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ return zammad.NewOAuthClient(connection.BaseURL, token, transport), nil
+ }
+ if connection.CredentialID <= 0 {
+ return nil, zammadValidationError("API token is not configured")
+ }
+ token, _, err := s.credentials.ResolveManaged(ctx, connection.CredentialID, workspaceID, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return nil, err
+ }
+ return zammad.NewClient(connection.BaseURL, token, transport), nil
+}
+
+func (s *ZammadService) resolveStateName(ctx context.Context, client *zammad.Client, stateID int) string {
+ metadata, err := client.Metadata(ctx)
+ if err != nil {
+ return ""
+ }
+ for _, state := range metadata.States {
+ if state.ID == stateID {
+ return state.Name
+ }
+ }
+ return ""
+}
+
+func validateNewZammadConnection(req models.CreateZammadConnectionRequest, actorID int) (*models.ZammadConnection, error) {
+ if hasNonPositiveIDs(req.ClosedStateIDs) {
+ return nil, zammadValidationError("closed_state_ids must contain positive IDs")
+ }
+ if hasNonPositiveIDs(req.AllowedGroupIDs) {
+ return nil, zammadValidationError("allowed_group_ids must contain positive IDs")
+ }
+ if hasNonPositiveIDs(req.WorkspaceIDs) {
+ return nil, zammadValidationError("workspace_ids must contain positive IDs")
+ }
+ baseURL, err := NormalizeZammadBaseURL(req.BaseURL)
+ if err != nil {
+ return nil, err
+ }
+ enabled := true
+ if req.Enabled != nil {
+ enabled = *req.Enabled
+ }
+ appliesAll := false
+ if req.AppliesToAllWorkspaces != nil {
+ appliesAll = *req.AppliesToAllWorkspaces
+ }
+ correlationField := strings.TrimSpace(req.CorrelationField)
+ if correlationField == "" {
+ correlationField = "windshift_item_key"
+ }
+ connection := &models.ZammadConnection{
+ ProviderID: uuid.New().String(), Slug: strings.TrimSpace(req.Slug),
+ Name: strings.TrimSpace(req.Name), Enabled: enabled, BaseURL: baseURL,
+ DefaultGroupID: req.DefaultGroupID, DefaultGroupName: strings.TrimSpace(req.DefaultGroupName),
+ AllowedGroupIDs: normalizePositiveIDs(req.AllowedGroupIDs),
+ DefaultCustomer: strings.TrimSpace(req.DefaultCustomer), CorrelationField: correlationField,
+ ClosedStateIDs: normalizePositiveIDs(req.ClosedStateIDs), CompletionStatusID: req.CompletionStatusID,
+ AppliesToAllWorkspaces: appliesAll, WorkspaceIDs: normalizePositiveIDs(req.WorkspaceIDs),
+ CreatedBy: &actorID,
+ }
+ connection.AuthMethod = req.AuthMethod
+ if connection.AuthMethod == "" {
+ connection.AuthMethod = models.ZammadAuthMethodAPIToken
+ }
+ switch connection.AuthMethod {
+ case models.ZammadAuthMethodOAuth:
+ connection.OAuthClientID = strings.TrimSpace(req.OAuthClientID)
+ if connection.OAuthClientID == "" || strings.TrimSpace(req.OAuthClientSecret) == "" {
+ return nil, zammadValidationError("oauth_client_id and oauth_client_secret are required for OAuth")
+ }
+ case models.ZammadAuthMethodAPIToken:
+ if strings.TrimSpace(req.APIToken) == "" {
+ return nil, zammadValidationError("Zammad API token is required")
+ }
+ default:
+ return nil, zammadValidationError("auth_method must be api_token or oauth")
+ }
+ if err := validateZammadConnection(connection); err != nil {
+ return nil, err
+ }
+ return connection, nil
+}
+
+func validateZammadConnection(connection *models.ZammadConnection) error {
+ if connection.Name == "" || connection.Slug == "" {
+ return zammadValidationError("name and slug are required")
+ }
+ if !zammadSlugPattern.MatchString(connection.Slug) {
+ return zammadValidationError("slug must start with a letter and contain only lowercase letters, numbers, hyphens, and underscores")
+ }
+ if connection.DefaultCustomer == "" {
+ return zammadValidationError("default_customer is required")
+ }
+ if connection.DefaultGroupID < 0 {
+ return zammadValidationError("default_group_id must be positive")
+ }
+ if connection.CompletionStatusID != nil && *connection.CompletionStatusID <= 0 {
+ return zammadValidationError("completion_status_id must be positive")
+ }
+ if !zammadFieldNamePattern.MatchString(connection.CorrelationField) {
+ return zammadValidationError("correlation_field is not a valid Zammad object field name")
+ }
+ if !connection.AppliesToAllWorkspaces && len(connection.WorkspaceIDs) == 0 {
+ return zammadValidationError("at least one workspace is required")
+ }
+ return nil
+}
+
+func validateZammadOAuthConfiguration(connection *models.ZammadConnection) error {
+ if connection.AuthMethod != models.ZammadAuthMethodOAuth {
+ return nil
+ }
+ if strings.TrimSpace(connection.OAuthClientID) == "" || strings.TrimSpace(connection.OAuthClientSecretEncrypted) == "" {
+ return zammadValidationError("OAuth client credentials are not configured")
+ }
+ return nil
+}
+
+func NormalizeZammadBaseURL(raw string) (string, error) {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.Opaque != "" {
+ return "", zammadValidationError("invalid Zammad base URL")
+ }
+ if parsed.Scheme != "https" {
+ return "", zammadValidationError("Zammad base URL must use HTTPS")
+ }
+ if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return "", zammadValidationError("Zammad base URL must not contain credentials, a query, or a fragment")
+ }
+ parsed.Path = strings.TrimRight(parsed.Path, "/")
+ parsed.RawPath = strings.TrimRight(parsed.RawPath, "/")
+ return strings.TrimRight(parsed.String(), "/"), nil
+}
+
+func zammadOAuthRedirectURI(publicBaseURL string) (string, error) {
+ parsed, err := url.Parse(strings.TrimSpace(publicBaseURL))
+ if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Opaque != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return "", zammadValidationError("Zammad OAuth requires an absolute HTTPS public base URL")
+ }
+ parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/integrations/zammad/oauth/callback"
+ parsed.RawPath = ""
+ return parsed.String(), nil
+}
+
+func normalizePositiveIDs(ids []int) []int {
+ seen := map[int]struct{}{}
+ out := make([]int, 0, len(ids))
+ for _, id := range ids {
+ if id <= 0 {
+ continue
+ }
+ if _, exists := seen[id]; exists {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ return out
+}
+
+func hasNonPositiveIDs(ids []int) bool {
+ for _, id := range ids {
+ if id <= 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func truncateRunes(value string, limit int) string {
+ runes := []rune(value)
+ if len(runes) <= limit {
+ return value
+ }
+ return string(runes[:limit])
+}
+
+func boolPointer(value bool) *bool { return &value }
diff --git a/internal/services/zammad_service_test.go b/internal/services/zammad_service_test.go
new file mode 100644
index 000000000..7385ddb45
--- /dev/null
+++ b/internal/services/zammad_service_test.go
@@ -0,0 +1,1719 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "windshift/internal/database"
+ "windshift/internal/integrations/zammad"
+ "windshift/internal/models"
+ "windshift/internal/repository"
+ "windshift/internal/sso"
+ "windshift/internal/utils"
+)
+
+type fakeZammadTransport struct {
+ mu sync.Mutex
+ requests int
+ posts int
+ puts int
+ ticket map[string]any
+ postErrorAfterCreate error
+ postStatusAfterCreate int
+ putError error
+ getStatus int
+ getTicket map[string]any
+ hideSearch bool
+ groups []map[string]any
+ states []map[string]any
+ users []map[string]any
+ putPayloads []map[string]any
+}
+
+func (f *fakeZammadTransport) Do(_ context.Context, method, targetURL string, body []byte, headers map[string]string) (*zammad.Response, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.requests++
+ if headers["Authorization"] != "Token token=synthetic-zammad-token" {
+ return nil, errors.New("unexpected authorization header")
+ }
+ parsed, err := url.Parse(targetURL)
+ if err != nil {
+ return nil, err
+ }
+ switch {
+ case parsed.Path == "/api/v1/tickets/search":
+ if f.ticket == nil || f.hideSearch {
+ return jsonResponse(http.StatusOK, []any{}), nil
+ }
+ return jsonResponse(http.StatusOK, []any{f.ticket}), nil
+ case parsed.Path == "/api/v1/tickets" && method == http.MethodPost:
+ f.posts++
+ var payload map[string]any
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, err
+ }
+ f.ticket = map[string]any{
+ "id": 901, "number": "420901", "group_id": 7,
+ "state_id": 2, "state": "open",
+ "windshift_item_key": payload["windshift_item_key"],
+ }
+ if f.postErrorAfterCreate != nil {
+ err := f.postErrorAfterCreate
+ f.postErrorAfterCreate = nil
+ return nil, err
+ }
+ if f.postStatusAfterCreate != 0 {
+ status := f.postStatusAfterCreate
+ f.postStatusAfterCreate = 0
+ return jsonResponse(status, map[string]string{"error": "synthetic create failure"}), nil
+ }
+ return jsonResponse(http.StatusCreated, f.ticket), nil
+ case strings.HasPrefix(parsed.Path, "/api/v1/tickets/") && method == http.MethodPut:
+ f.puts++
+ if f.putError != nil {
+ return nil, f.putError
+ }
+ payload := map[string]any{}
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, err
+ }
+ f.putPayloads = append(f.putPayloads, payload)
+ if f.ticket != nil {
+ for key, value := range payload {
+ f.ticket[key] = value
+ }
+ }
+ return jsonResponse(http.StatusOK, f.ticket), nil
+ case strings.HasPrefix(parsed.Path, "/api/v1/tickets/"):
+ if f.getStatus != 0 {
+ return jsonResponse(f.getStatus, map[string]string{"error": "synthetic remote detail"}), nil
+ }
+ if f.getTicket != nil {
+ return jsonResponse(http.StatusOK, f.getTicket), nil
+ }
+ return jsonResponse(http.StatusOK, f.ticket), nil
+ case parsed.Path == "/api/v1/groups":
+ if f.groups != nil {
+ return jsonResponse(http.StatusOK, f.groups), nil
+ }
+ return jsonResponse(http.StatusOK, []map[string]any{{"id": 7, "name": "Support", "active": true}}), nil
+ case parsed.Path == "/api/v1/ticket_states":
+ if f.states != nil {
+ return jsonResponse(http.StatusOK, f.states), nil
+ }
+ return jsonResponse(http.StatusOK, []map[string]any{{"id": 2, "name": "open", "active": true}, {"id": 4, "name": "closed", "active": true}}), nil
+ case parsed.Path == "/api/v1/users/search":
+ return jsonResponse(http.StatusOK, f.users), nil
+ case parsed.Path == "/api/v1/object_manager_attributes":
+ return jsonResponse(http.StatusOK, []map[string]any{{"name": "windshift_item_key", "object": "Ticket", "active": true}}), nil
+ default:
+ return jsonResponse(http.StatusNotFound, map[string]string{"error": "not found"}), nil
+ }
+}
+
+func (f *fakeZammadTransport) counts() (requests, posts int) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.requests, f.posts
+}
+
+func (f *fakeZammadTransport) putCount() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.puts
+}
+
+func (f *fakeZammadTransport) lastPutPayload() map[string]any {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if len(f.putPayloads) == 0 {
+ return nil
+ }
+ return f.putPayloads[len(f.putPayloads)-1]
+}
+
+func (f *fakeZammadTransport) resetRequests() {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.requests = 0
+}
+
+func jsonResponse(status int, value any) *zammad.Response {
+ body, _ := json.Marshal(value)
+ return &zammad.Response{StatusCode: status, Body: body}
+}
+
+type allowZammadPermission struct{}
+
+func (allowZammadPermission) HasWorkspacePermission(_, _ int, _ string) (bool, error) {
+ return true, nil
+}
+
+type fakeZammadWorkflow struct {
+ db database.Database
+ writes int
+}
+
+func (f *fakeZammadWorkflow) PerformTransition(_ context.Context, req PerformTransitionRequest, itemRepo *repository.ItemRepository, _ *ConditionService, _ transitionApprovalService) (*PerformTransitionResult, error) {
+ item, err := itemRepo.FindByIDWithDetails(req.ItemID)
+ if err != nil {
+ return nil, err
+ }
+ if item.StatusID != nil && *item.StatusID == req.ToStatusID {
+ return &PerformTransitionResult{Item: item, OldStatusID: item.StatusID, NewStatusID: item.StatusID, NoOp: true}, nil
+ }
+ oldStatusID := item.StatusID
+ if _, err := f.db.ExecWrite("UPDATE items SET status_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", req.ToStatusID, req.ItemID); err != nil {
+ return nil, err
+ }
+ f.writes++
+ item, err = itemRepo.FindByIDWithDetails(req.ItemID)
+ if err != nil {
+ return nil, err
+ }
+ newStatusID := req.ToStatusID
+ return &PerformTransitionResult{Item: item, OldStatusID: oldStatusID, NewStatusID: &newStatusID}, nil
+}
+
+type zammadServiceFixture struct {
+ t *testing.T
+ db database.Database
+ service *ZammadService
+ credentials *ActionCredentialService
+ transport *fakeZammadTransport
+ connection *models.ZammadConnection
+ workspace1 int
+ workspace2 int
+ item1 int
+ item2 int
+ actorID int
+ openStatus int
+ doneStatus int
+}
+
+func newZammadServiceFixture(t *testing.T, workflow zammadWorkflowTransitioner) *zammadServiceFixture {
+ t.Helper()
+ db, err := database.NewSQLiteDB(t.TempDir() + "/windshift.db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ if err := db.Initialize(); err != nil {
+ t.Fatal(err)
+ }
+ f := &zammadServiceFixture{t: t, db: db, transport: &fakeZammadTransport{}}
+ f.actorID = mustInsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES (?, ?, ?, ?)`, "agent@example.test", "zammad-agent", "Zammad", "Agent")
+ f.workspace1 = mustInsertID(t, db, `INSERT INTO workspaces (name, key) VALUES (?, ?)`, "Primary", "PRI")
+ f.workspace2 = mustInsertID(t, db, `INSERT INTO workspaces (name, key) VALUES (?, ?)`, "Other", "OTH")
+ if err := db.QueryRow("SELECT id FROM statuses ORDER BY id LIMIT 1").Scan(&f.openStatus); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.QueryRow("SELECT id FROM statuses ORDER BY id DESC LIMIT 1").Scan(&f.doneStatus); err != nil {
+ t.Fatal(err)
+ }
+ f.item1 = mustInsertID(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index, status_id, creator_id, last_active_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, f.workspace1, 49, "Synthetic ticket source", "Synthetic description", "a0", f.openStatus, f.actorID)
+ f.item2 = mustInsertID(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index, status_id, creator_id, last_active_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, f.workspace2, 1, "Out of scope", "Synthetic description", "a1", f.openStatus, f.actorID)
+ credentialService := NewActionCredentialService(repository.NewActionCredentialRepository(db), "synthetic-server-secret-for-zammad-tests")
+ f.credentials = credentialService
+ if workflow == nil {
+ workflow = &fakeZammadWorkflow{db: db}
+ }
+ f.service = NewZammadService(db, repository.NewZammadRepository(db), credentialService, allowZammadPermission{}, workflow, nil, nil)
+ f.service.SetTransportForTesting(f.transport)
+ f.connection, err = f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "helpdesk", Name: "Synthetic helpdesk", BaseURL: "https://zammad.example.test",
+ APIToken: "synthetic-zammad-token", DefaultGroupID: 7, DefaultGroupName: "Support",
+ DefaultCustomer: "robot@example.test", CorrelationField: "windshift_item_key",
+ WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return f
+}
+
+func mustInsertID(t *testing.T, db database.Database, query string, args ...any) int {
+ t.Helper()
+ result, err := db.ExecWrite(query, args...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, err := result.LastInsertId()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return int(id)
+}
+
+func TestZammadCreateTicketIsIdempotentAndPersistsGenericLink(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ first, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, posts := f.transport.counts()
+ if posts != 1 || first.TicketID != 901 || second.ID != first.ID {
+ t.Fatalf("unexpected idempotency result: first=%#v second=%#v posts=%d", first, second, posts)
+ }
+ var genericID string
+ if err := f.db.QueryRow("SELECT item_integration_link_id FROM zammad_ticket_links WHERE id = ?", first.ID).Scan(&genericID); err != nil {
+ t.Fatal(err)
+ }
+ var count int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE id = ? AND external_id = ?", genericID, strconv.Itoa(first.TicketID)).Scan(&count); err != nil || count != 1 {
+ t.Fatalf("generic link was not persisted: count=%d err=%v", count, err)
+ }
+}
+
+func TestZammadLinkExistingTicketPinsExactTicketAndIsIdempotent(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.ticket = map[string]any{
+ "id": 711, "number": "420711", "group_id": 7, "group": "Support",
+ "state_id": 2, "state": "open", "owner_id": 99,
+ }
+ f.transport.users = []map[string]any{{"id": 99, "active": true, "firstname": "Grace", "lastname": "Hopper", "group_ids": map[string][]string{"7": {"full"}}}}
+
+ first, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{
+ ConnectionID: f.connection.ProviderID, TicketNumber: "420711",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{
+ ConnectionID: f.connection.ProviderID, TicketNumber: "420711",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first.ID != second.ID || first.TicketID != 711 || first.SyncState != models.ZammadSyncLinked || f.transport.putCount() != 1 {
+ t.Fatalf("existing-ticket link was not idempotent: first=%#v second=%#v puts=%d", first, second, f.transport.putCount())
+ }
+ if first.OwnerID != 99 || first.OwnerName != "Grace Hopper" {
+ t.Fatalf("existing-ticket owner was not resolved: %#v", first)
+ }
+ if got := f.transport.lastPutPayload()["windshift_item_key"]; got != first.CorrelationKey {
+ t.Fatalf("remote correlation was not pinned to the local link: %#v", f.transport.lastPutPayload())
+ }
+}
+
+func TestZammadLinkExistingTicketRejectsDisallowedGroupAndConflictingCorrelation(t *testing.T) {
+ t.Run("disallowed group", func(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.ticket = map[string]any{"id": 712, "number": "420712", "group_id": 99, "state_id": 2, "state": "open"}
+ _, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ConnectionID: f.connection.ProviderID, TicketNumber: "420712"})
+ var validationErr *ZammadValidationError
+ if !errors.As(err, &validationErr) || f.transport.putCount() != 0 {
+ t.Fatalf("disallowed ticket group was accepted: err=%v puts=%d", err, f.transport.putCount())
+ }
+ })
+ t.Run("conflicting correlation", func(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.ticket = map[string]any{
+ "id": 713, "number": "420713", "group_id": 7, "state_id": 2, "state": "open",
+ "windshift_item_key": "windshift:other-connection:PRI-49",
+ }
+ _, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ConnectionID: f.connection.ProviderID, TicketNumber: "420713"})
+ var validationErr *ZammadValidationError
+ if !errors.As(err, &validationErr) || f.transport.putCount() != 0 {
+ t.Fatalf("conflicting remote correlation was accepted: err=%v puts=%d", err, f.transport.putCount())
+ }
+ var links int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_ticket_links").Scan(&links); err != nil || links != 0 {
+ t.Fatalf("conflicting ticket left a local link behind: links=%d err=%v", links, err)
+ }
+ })
+}
+
+func TestZammadLinkExistingTicketDoesNotStealAlreadyReservedRemoteTicket(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.ticket = map[string]any{"id": 714, "number": "420714", "group_id": 7, "state_id": 2, "state": "open"}
+ first, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ConnectionID: f.connection.ProviderID, TicketNumber: "420714"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ secondItem := mustInsertID(t, f.db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index, status_id, creator_id, last_active_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, f.workspace1, 50, "Second ticket source", "Synthetic description", "a2", f.openStatus, f.actorID)
+ // Simulate a remote-side correlation edit between attempts. The local unique
+ // provider/ticket reservation must still prevent the second item from taking
+ // over this ticket.
+ f.transport.ticket["windshift_item_key"] = ""
+ puts := f.transport.putCount()
+ _, err = f.service.LinkExistingTicket(context.Background(), secondItem, f.actorID, models.LinkZammadTicketRequest{ConnectionID: f.connection.ProviderID, TicketNumber: "420714"})
+ if !errors.Is(err, repository.ErrDuplicateEntry) || f.transport.putCount() != puts {
+ t.Fatalf("second item stole an already reserved ticket: err=%v puts=%d", err, f.transport.putCount())
+ }
+ stored, err := f.service.GetTicketLink(first.ID)
+ if err != nil || stored.ItemID != f.item1 {
+ t.Fatalf("original local reservation changed: link=%#v err=%v", stored, err)
+ }
+}
+
+func TestZammadUpdateTicketLinkValidatesOwnerForEffectiveGroupAndPersistsSnapshot(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.transport.ticket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 2, "owner_id": 1}
+ f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}}
+ f.transport.states = []map[string]any{{"id": 2, "name": "open", "active": true}, {"id": 4, "name": "closed", "active": true}}
+ f.transport.users = []map[string]any{{"id": 99, "active": true, "firstname": "Grace", "lastname": "Hopper", "group_ids": map[string][]string{"8": {"full"}}}}
+ allowedGroups := []int{7, 8}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &allowedGroups}); err != nil {
+ t.Fatal(err)
+ }
+ stateID, groupID, ownerID := 4, 8, 99
+ updated, err := f.service.UpdateTicketLink(context.Background(), link.ID, models.UpdateZammadTicketLinkRequest{StateID: &stateID, GroupID: &groupID, OwnerID: &ownerID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.LastStatusID != 4 || updated.LastStatusName != "closed" || updated.GroupID != 8 || updated.GroupName != "Escalations" || updated.OwnerID != 99 || updated.OwnerName != "Grace Hopper" {
+ t.Fatalf("remote update snapshot was not persisted: %#v", updated)
+ }
+ puts := f.transport.putCount()
+ invalidOwner := 100
+ _, err = f.service.UpdateTicketLink(context.Background(), link.ID, models.UpdateZammadTicketLinkRequest{OwnerID: &invalidOwner})
+ var validationErr *ZammadValidationError
+ if !errors.As(err, &validationErr) || f.transport.putCount() != puts {
+ t.Fatalf("owner without group access reached remote update: err=%v puts=%d", err, f.transport.putCount())
+ }
+}
+
+func TestZammadUnlinkClearsOnlyExactRemoteCorrelationWithoutDeletingTicket(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.UnlinkTicket(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if f.transport.ticket == nil || f.transport.ticket["id"] != 901 || f.transport.ticket["windshift_item_key"] != "" {
+ t.Fatalf("unlink changed or deleted the remote ticket: %#v", f.transport.ticket)
+ }
+ if _, err := f.service.GetTicketLink(link.ID); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("unlink retained local association: %v", err)
+ }
+}
+
+func TestZammadUnlinkUpstreamFailurePreservesLocalLink(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.transport.putError = context.DeadlineExceeded
+ if _, err := f.service.UnlinkTicket(context.Background(), link.ID); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected remote unlink failure, got %v", err)
+ }
+ if stored, err := f.service.GetTicketLink(link.ID); err != nil || stored.CorrelationKey != link.CorrelationKey {
+ t.Fatalf("unlink failure removed or corrupted the local link: link=%#v err=%v", stored, err)
+ }
+}
+
+func TestZammadUnlinkDoesNotClearAnotherCorrelation(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.transport.ticket["windshift_item_key"] = "windshift:another-link:OTHER-8"
+ puts := f.transport.putCount()
+ if _, err := f.service.UnlinkTicket(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if f.transport.putCount() != puts || f.transport.ticket["windshift_item_key"] != "windshift:another-link:OTHER-8" {
+ t.Fatalf("unlink cleared a foreign correlation: %#v", f.transport.ticket)
+ }
+ if _, err := f.service.GetTicketLink(link.ID); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("stale local link was not removed: %v", err)
+ }
+}
+
+func TestZammadCreateTicketMarksLocalCompletionFailureUncertainAndKeepsCorrelationPinned(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ if _, err := f.db.ExecWrite(`CREATE TRIGGER fail_zammad_ticket_completion
+ BEFORE UPDATE OF ticket_id ON zammad_ticket_links
+ WHEN NEW.ticket_id IS NOT NULL
+ BEGIN SELECT RAISE(ABORT, 'synthetic completion failure'); END`); err != nil {
+ t.Fatal(err)
+ }
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err == nil {
+ t.Fatal("expected local completion failure")
+ }
+ link, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(link) != 1 || link[0].SyncState != models.ZammadSyncUncertain || f.transport.ticket["windshift_item_key"] != link[0].CorrelationKey {
+ t.Fatalf("known remote ticket was not preserved as uncertain: links=%#v ticket=%#v err=%v", link, f.transport.ticket, err)
+ }
+ if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err == nil {
+ t.Fatal("expected retry to surface the retained local completion failure")
+ }
+ _, posts := f.transport.counts()
+ if posts != 1 {
+ t.Fatalf("retry created a second ticket after local completion failure: posts=%d", posts)
+ }
+}
+
+func TestZammadCreateTicketMarksAmbiguousHTTPFailuresUncertainWithoutRetryingPOST(t *testing.T) {
+ for _, status := range []int{http.StatusInternalServerError, http.StatusRequestTimeout, http.StatusTooManyRequests} {
+ t.Run(http.StatusText(status), func(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.hideSearch = true
+ f.transport.postStatusAfterCreate = status
+
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ var apiErr *zammad.APIError
+ if !errors.As(err, &apiErr) || apiErr.StatusCode != status {
+ t.Fatalf("expected HTTP %d create failure, got %v", status, err)
+ }
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 1 || links[0].SyncState != models.ZammadSyncUncertain {
+ t.Fatalf("ambiguous HTTP %d result was not retained as uncertain: links=%#v err=%v", status, links, err)
+ }
+
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatalf("normal retry after ambiguous HTTP %d failed: %v", status, err)
+ }
+ _, posts := f.transport.counts()
+ if posts != 1 || link.SyncState != models.ZammadSyncUncertain {
+ t.Fatalf("normal retry after ambiguous HTTP %d sent another POST: link=%#v posts=%d", status, link, posts)
+ }
+ })
+ }
+}
+
+func TestZammadCreateTicketMarksRejectingHTTP400Failed(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.hideSearch = true
+ f.transport.postStatusAfterCreate = http.StatusBadRequest
+
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ var apiErr *zammad.APIError
+ if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadRequest {
+ t.Fatalf("expected HTTP 400 create failure, got %v", err)
+ }
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 1 || links[0].SyncState != models.ZammadSyncFailed {
+ t.Fatalf("rejecting HTTP 400 result was not retained as failed: links=%#v err=%v", links, err)
+ }
+}
+
+func TestZammadCreateTicketRejectsCorrelationMatchInDisallowedGroup(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.ticket = map[string]any{
+ "id": 715, "number": "420715", "group_id": 99, "state_id": 2, "state": "open",
+ "windshift_item_key": "windshift:" + f.connection.ProviderID + ":PRI-49",
+ }
+
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ var validationErr *ZammadValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("correlation match in a disallowed group was accepted: %v", err)
+ }
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 1 || links[0].TicketID != 0 || links[0].SyncState == models.ZammadSyncLinked {
+ t.Fatalf("disallowed correlation match completed a local link: links=%#v err=%v", links, err)
+ }
+ var genericLinks int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links").Scan(&genericLinks); err != nil || genericLinks != 0 {
+ t.Fatalf("disallowed correlation match created a generic link: count=%d err=%v", genericLinks, err)
+ }
+}
+
+func TestZammadSyncPersistsRemoteGroupAndOwner(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 8, "state_id": 3, "state": "pending", "owner_id": 99}
+ f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}}
+ f.transport.users = []map[string]any{{"id": 99, "active": true, "firstname": "Grace", "lastname": "Hopper", "group_ids": map[string][]string{"8": {"full"}}}}
+ allowedGroups := []int{7, 8}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &allowedGroups}); err != nil {
+ t.Fatal(err)
+ }
+ synced, err := f.service.SyncTicketLink(context.Background(), link.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if synced.GroupID != 8 || synced.GroupName != "Escalations" || synced.OwnerID != 99 || synced.OwnerName != "Grace Hopper" || synced.LastSyncedAt == nil {
+ t.Fatalf("sync did not retain remote group/owner state: %#v", synced)
+ }
+}
+
+func TestZammadSyncRejectsTicketMovedToDisallowedGroupBeforeSnapshotOrCompletion(t *testing.T) {
+ workflow := &fakeZammadWorkflow{}
+ f := newZammadServiceFixture(t, workflow)
+ workflow.db = f.db
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ allowedGroups := []int{7}
+ closedStates := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
+ AllowedGroupIDs: &allowedGroups,
+ ClosedStateIDs: &closedStates,
+ CompletionStatusID: &f.doneStatus,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 99, "state_id": 4, "state": "closed"}
+ f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 99, "name": "Restricted", "active": true}}
+
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil {
+ t.Fatal("sync accepted a ticket moved to a disallowed group")
+ }
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.LastStatusID != link.LastStatusID || stored.GroupID != link.GroupID || stored.CompletionApplied || workflow.writes != 0 {
+ t.Fatalf("disallowed remote group changed local snapshot or completed item: link=%#v writes=%d", stored, workflow.writes)
+ }
+ var statusID int
+ if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
+ t.Fatal(err)
+ }
+ if statusID != f.openStatus {
+ t.Fatalf("disallowed remote group completed the item: status=%d", statusID)
+ }
+}
+
+func TestZammadDueLinksRespectRetryDelayAndOAuthReauthorization(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldSync := time.Now().Add(-10 * time.Minute)
+ if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET last_synced_at = ? WHERE id = ?", oldSync, link.ID); err != nil {
+ t.Fatal(err)
+ }
+ attemptedAt := time.Now()
+ if err := f.service.repo.UpdateTicketLinkSync(link.ID, link.LastStatusID, link.LastStatusName,
+ link.GroupID, link.GroupName, link.OwnerID, link.OwnerName,
+ "synthetic safe failure", attemptedAt, false, false); err != nil {
+ t.Fatal(err)
+ }
+ due, err := f.service.repo.ListDueTicketLinks(time.Now().Add(-2*time.Minute), 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(due) != 0 {
+ t.Fatalf("failed link ignored retry delay: %#v", due)
+ }
+ if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET next_attempt_at = ? WHERE id = ?", time.Now().Add(-time.Minute), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ due, err = f.service.repo.ListDueTicketLinks(time.Now().Add(-2*time.Minute), 10)
+ if err != nil || len(due) != 1 || due[0].ID != link.ID {
+ t.Fatalf("eligible retry was not scheduled: due=%#v err=%v", due, err)
+ }
+ if _, err := f.db.ExecWrite("UPDATE zammad_connections SET auth_method = 'oauth' WHERE provider_id = ?", f.connection.ProviderID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite(`INSERT INTO zammad_oauth_tokens(provider_id, oauth_generation, expires_at, reauthorization_required)
+ VALUES (?, 1, ?, true)`, f.connection.ProviderID, time.Now().Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ due, err = f.service.repo.ListDueTicketLinks(time.Now().Add(-2*time.Minute), 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(due) != 0 {
+ t.Fatalf("reauthorization-required OAuth connection remained due: %#v", due)
+ }
+}
+
+func TestZammadRetryAfterAmbiguousTimeoutFindsExistingTicket(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.postErrorAfterCreate = context.DeadlineExceeded
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected timeout, got %v", err)
+ }
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, posts := f.transport.counts()
+ if posts != 1 || link.TicketID != 901 || link.SyncState != models.ZammadSyncLinked {
+ t.Fatalf("retry created a duplicate or failed to recover: link=%#v posts=%d", link, posts)
+ }
+}
+
+func TestZammadAmbiguousCreateNeverPostsAgainWhileSearchIsEmpty(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.hideSearch = true
+ f.transport.postErrorAfterCreate = context.DeadlineExceeded
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected timeout, got %v", err)
+ }
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, posts := f.transport.counts()
+ if posts != 1 || link.SyncState != models.ZammadSyncUncertain {
+ t.Fatalf("uncertain retry sent another POST: state=%s posts=%d", link.SyncState, posts)
+ }
+ f.transport.postErrorAfterCreate = nil
+ link, err = f.service.RetryUncertainTicketCreation(context.Background(), link.ID, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, posts = f.transport.counts()
+ if posts != 2 || link.TicketID != 901 {
+ t.Fatalf("explicit administrator override did not retry creation: ticket=%d posts=%d", link.TicketID, posts)
+ }
+}
+
+func TestZammadRejectsUnconfiguredGroupBeforeCreate(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID, GroupID: 99})
+ var validationErr *ZammadValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("expected group validation error, got %v", err)
+ }
+ _, posts := f.transport.counts()
+ if posts != 0 {
+ t.Fatalf("unconfigured group reached ticket create endpoint %d times", posts)
+ }
+}
+
+func TestZammadWorkspaceScopeBlocksHTTPAndSecretIsEncrypted(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ _, err := f.service.CreateTicket(context.Background(), f.item2, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if !errors.Is(err, ErrCredentialScopeMismatch) {
+ t.Fatalf("expected scope mismatch, got %v", err)
+ }
+ requests, _ := f.transport.counts()
+ if requests != 0 {
+ t.Fatalf("out-of-scope request reached transport %d times", requests)
+ }
+ encoded, err := json.Marshal(f.connection)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(encoded), "synthetic-zammad-token") || strings.Contains(string(encoded), "credential_id") {
+ t.Fatalf("connection API model disclosed credential material: %s", encoded)
+ }
+ var encrypted string
+ if err := f.db.QueryRow("SELECT encrypted_secret FROM action_credentials WHERE id = ?", f.connection.CredentialID).Scan(&encrypted); err != nil {
+ t.Fatal(err)
+ }
+ if encrypted == "synthetic-zammad-token" || strings.Contains(encrypted, "synthetic-zammad-token") {
+ t.Fatal("Zammad token was stored in plaintext")
+ }
+ listed, err := f.credentials.ListForWorkspace(f.workspace1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(listed) != 0 {
+ t.Fatalf("managed Zammad credential leaked into generic list: %#v", listed)
+ }
+ if _, err := f.credentials.Get(f.connection.CredentialID); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("managed Zammad credential was addressable through generic CRUD: %v", err)
+ }
+ if _, _, err := f.credentials.Resolve(context.Background(), f.connection.CredentialID, f.workspace1); !errors.Is(err, ErrCredentialPurposeMismatch) {
+ t.Fatalf("managed Zammad credential resolved through generic action path: %v", err)
+ }
+}
+
+func TestZammadDeleteConnectionRejectsExistingTicketLinks(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.service.DeleteConnection(f.connection.ProviderID); err == nil {
+ t.Fatal("connection deletion succeeded despite existing ticket link")
+ }
+ var providerCount, credentialCount, linkCount int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM integration_providers WHERE id = ?", f.connection.ProviderID).Scan(&providerCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials WHERE id = ?", f.connection.CredentialID).Scan(&credentialCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_ticket_links WHERE id = ?", link.ID).Scan(&linkCount); err != nil {
+ t.Fatal(err)
+ }
+ if providerCount != 1 || credentialCount != 1 || linkCount != 1 {
+ t.Fatalf("rejected connection delete changed persisted data: providers=%d credentials=%d links=%d", providerCount, credentialCount, linkCount)
+ }
+}
+
+func TestZammadDeleteConnectionWithoutTicketLinksRemovesOwnedCredentialAtomically(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ credentialID := f.connection.CredentialID
+ if err := f.service.DeleteConnection(f.connection.ProviderID); err != nil {
+ t.Fatal(err)
+ }
+ var providerCount, credentialCount int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM integration_providers WHERE id = ?", f.connection.ProviderID).Scan(&providerCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials WHERE id = ?", credentialID).Scan(&credentialCount); err != nil {
+ t.Fatal(err)
+ }
+ if providerCount != 0 || credentialCount != 0 {
+ t.Fatalf("connection delete left data behind: providers=%d credentials=%d", providerCount, credentialCount)
+ }
+}
+
+func TestZammadConnectionUpdateRollsBackManagedCredentialOnProviderConflict(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ other, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "other-helpdesk", Name: "Other helpdesk", BaseURL: "https://other.example.test",
+ APIToken: "other-token", DefaultGroupID: 7, DefaultGroupName: "Support",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ newToken := "must-be-rolled-back"
+ newScope := []int{f.workspace2}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Slug: &other.Slug, APIToken: &newToken, WorkspaceIDs: &newScope}); !errors.Is(err, repository.ErrDuplicateEntry) {
+ t.Fatalf("expected duplicate provider slug, got %v", err)
+ }
+ secret, _, err := f.credentials.ResolveManaged(context.Background(), f.connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), f.connection.ProviderID)
+ if err != nil || secret != "synthetic-zammad-token" {
+ t.Fatalf("managed credential was not restored: secret_matches=%v err=%v", secret == "synthetic-zammad-token", err)
+ }
+ if _, _, err := f.credentials.ResolveManaged(context.Background(), f.connection.CredentialID, f.workspace2, string(models.IntegrationProviderZammad), f.connection.ProviderID); !errors.Is(err, ErrCredentialScopeMismatch) {
+ t.Fatalf("managed credential scope was not restored: %v", err)
+ }
+}
+
+func TestZammadSyncUpdatesStatusAndDisabledConnectionsAreSkipped(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 3, "state": "pending"}
+ link, err = f.service.SyncTicketLink(context.Background(), link.ID)
+ if err != nil || link.LastStatusID != 3 || link.LastStatusName != "pending" {
+ t.Fatalf("unexpected synced link: link=%#v err=%v", link, err)
+ }
+ disabled := false
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.resetRequests()
+ if err := f.service.SyncDue(context.Background(), 50); err != nil {
+ t.Fatal(err)
+ }
+ requests, _ := f.transport.counts()
+ if requests != 0 {
+ t.Fatalf("disabled connection was polled %d times", requests)
+ }
+}
+
+func TestZammadDisabledConnectionCanStillBeTestedByAdmin(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ disabled := false
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil {
+ t.Fatal(err)
+ }
+ metadata, err := f.service.TestConnection(context.Background(), f.connection.ProviderID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(metadata.Groups) != 1 || metadata.Groups[0].Name != "Support" {
+ t.Fatalf("unexpected connection test metadata: %#v", metadata)
+ }
+}
+
+func TestZammadUnknownTicketDoesNotChangeItem(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getStatus = http.StatusNotFound
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil {
+ t.Fatal("expected remote not-found error")
+ }
+ var statusID int
+ if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
+ t.Fatal(err)
+ }
+ if statusID != f.openStatus {
+ t.Fatalf("unknown remote ticket changed item status to %d", statusID)
+ }
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(stored.LastError, "synthetic remote detail") {
+ t.Fatalf("remote response body leaked into stored error: %q", stored.LastError)
+ }
+}
+
+func TestZammadClosedStateCompletesItemOnce(t *testing.T) {
+ workflow := &fakeZammadWorkflow{}
+ f := newZammadServiceFixture(t, workflow)
+ workflow.db = f.db
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ closed := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 4, "state": "closed"}
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ var statusID int
+ if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
+ t.Fatal(err)
+ }
+ if statusID != f.doneStatus || workflow.writes != 1 {
+ t.Fatalf("closed-state mapping was not idempotent: status=%d writes=%d", statusID, workflow.writes)
+ }
+ if _, err := f.db.ExecWrite("UPDATE items SET status_id = ? WHERE id = ?", f.openStatus, f.item1); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
+ t.Fatal(err)
+ }
+ if statusID != f.openStatus || workflow.writes != 1 {
+ t.Fatalf("same remote closed episode re-completed a reopened item: status=%d writes=%d", statusID, workflow.writes)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 2, "state": "open"}
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 4, "state": "closed"}
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if workflow.writes != 2 {
+ t.Fatalf("new remote closed episode did not complete the item: writes=%d", workflow.writes)
+ }
+}
+
+func TestNormalizeZammadBaseURL(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ err bool
+ }{
+ {input: "https://support.example.test/desk/", want: "https://support.example.test/desk"},
+ {input: "http://support.example.test", err: true},
+ {input: "https://user:secret@support.example.test", err: true},
+ {input: "https://support.example.test?token=secret", err: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got, err := NormalizeZammadBaseURL(tt.input)
+ if (err != nil) != tt.err || got != tt.want {
+ t.Fatalf("NormalizeZammadBaseURL(%q) = %q, %v", tt.input, got, err)
+ }
+ })
+ }
+}
+
+func TestZammadDueSyncUsesAgeThreshold(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET last_synced_at = ? WHERE id = ?", time.Now().Add(-10*time.Minute), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.resetRequests()
+ if err := f.service.SyncDue(context.Background(), 10); err != nil {
+ t.Fatal(err)
+ }
+ requests, _ := f.transport.counts()
+ if requests != 1 {
+ t.Fatalf("expected one ticket refresh, got %d transport requests", requests)
+ }
+}
+
+func TestZammadOAuthConnectionStoresConnectionTokensAndConsumesStateOnce(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests"))
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, method, targetURL string, body []byte, headers map[string]string) (*zammad.Response, error) {
+ if method != http.MethodPost || targetURL != "https://oauth.example.test/oauth/token" || headers["Content-Type"] != "application/x-www-form-urlencoded" || strings.Contains(string(body), "synthetic-client-secret") == false {
+ t.Fatalf("unexpected OAuth token request: %s %s %q %#v", method, targetURL, body, headers)
+ }
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "oauth-access-token", "refresh_token": "oauth-refresh-token", "expires_in": 7200}), nil
+ }))
+ connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "oauth-helpdesk", Name: "OAuth helpdesk", BaseURL: "https://oauth.example.test",
+ AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "synthetic-client", OAuthClientSecret: "synthetic-client-secret",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if connection.CredentialID <= 0 || connection.OAuthConnected || connection.HasAPIToken {
+ t.Fatalf("OAuth connection should have a non-token pending credential before callback: %#v", connection)
+ }
+ pending, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil || strings.Contains(pending, "token") || !strings.Contains(pending, "pending") {
+ t.Fatalf("OAuth pending credential is unsafe: %q, %v", pending, err)
+ }
+ var credentialsBefore int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials").Scan(&credentialsBefore); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "oauth-helpdesk", Name: "Duplicate OAuth helpdesk", BaseURL: "https://duplicate.example.test",
+ AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID); !errors.Is(err, repository.ErrDuplicateEntry) {
+ t.Fatalf("expected duplicate OAuth connection error, got %v", err)
+ }
+ var credentialsAfter int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials").Scan(&credentialsAfter); err != nil || credentialsAfter != credentialsBefore {
+ t.Fatalf("failed OAuth creation left a managed credential behind: before=%d after=%d err=%v", credentialsBefore, credentialsAfter, err)
+ }
+ authURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := url.Parse(authURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if parsed.Path != "/oauth/authorize" || parsed.Query().Get("scope") != "full" || parsed.Query().Get("redirect_uri") != "https://windshift.example.test/api/integrations/zammad/oauth/callback" {
+ t.Fatalf("unexpected authorization URL: %s", authURL)
+ }
+ if _, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "http://windshift.example.test"); err == nil {
+ t.Fatal("OAuth start accepted a non-HTTPS public base URL")
+ }
+ abortedURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ abortedState, err := url.Parse(abortedURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.service.InvalidateOAuthState(abortedState.Query().Get("state")); err != nil {
+ t.Fatal(err)
+ }
+ invalidated, err := f.service.GetConnection(connection.ProviderID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if invalidated.OAuthAttemptID != "" {
+ t.Fatalf("invalidated OAuth state retained connection attempt %q", invalidated.OAuthAttemptID)
+ }
+ if _, err := f.service.CompleteOAuth(context.Background(), abortedState.Query().Get("state"), "aborted-code", "https://windshift.example.test"); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("aborted OAuth state remained usable: %v", err)
+ }
+ usableURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ usableState, err := url.Parse(usableURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ state := usableState.Query().Get("state")
+ if _, err := f.service.CompleteOAuth(context.Background(), state, "authorization-code", "https://windshift.example.test"); err != nil {
+ t.Fatal(err)
+ }
+ completed, err := f.service.GetConnection(connection.ProviderID)
+ if err != nil || !completed.OAuthConnected || completed.HasAPIToken {
+ t.Fatalf("unexpected OAuth connection status after callback: %#v, %v", completed, err)
+ }
+ if _, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test"); err != nil {
+ t.Fatal(err)
+ }
+ newClientID := "rotated-client"
+ reset, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{OAuthClientID: &newClientID})
+ if err != nil || reset.OAuthConnected || reset.ReauthorizationRequired || reset.HasAPIToken {
+ t.Fatalf("OAuth credential change did not reset authorization: %#v, %v", reset, err)
+ }
+ var stateCount, tokenCount int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_oauth_state WHERE provider_id = ?", connection.ProviderID).Scan(&stateCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_oauth_tokens WHERE provider_id = ?", connection.ProviderID).Scan(&tokenCount); err != nil {
+ t.Fatal(err)
+ }
+ if stateCount != 0 || tokenCount != 0 {
+ t.Fatalf("OAuth reset retained state=%d token=%d", stateCount, tokenCount)
+ }
+ bundle, err := activeZammadOAuthCredential("access", "refresh")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.credentials.RotateManaged(connection.CredentialID, models.RotateActionCredentialRequest{Secret: bundle}, string(models.IntegrationProviderZammad), connection.ProviderID); err != nil {
+ t.Fatal(err)
+ }
+ if err := repository.NewZammadRepository(f.db).UpsertOAuthToken(repository.ZammadOAuthToken{ProviderID: connection.ProviderID, ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test"); err != nil {
+ t.Fatal(err)
+ }
+ newBaseURL := "https://other-zammad.example.test"
+ reset, err = f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{BaseURL: &newBaseURL})
+ if err != nil || reset.OAuthConnected || reset.ReauthorizationRequired {
+ t.Fatalf("OAuth base URL change did not reset authorization: %#v, %v", reset, err)
+ }
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_oauth_state WHERE provider_id = ?", connection.ProviderID).Scan(&stateCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_oauth_tokens WHERE provider_id = ?", connection.ProviderID).Scan(&tokenCount); err != nil {
+ t.Fatal(err)
+ }
+ if stateCount != 0 || tokenCount != 0 {
+ t.Fatalf("OAuth base URL reset retained state=%d token=%d", stateCount, tokenCount)
+ }
+ if _, err := f.service.CompleteOAuth(context.Background(), state, "authorization-code", "https://windshift.example.test"); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("OAuth state was not consumed exactly once: %v", err)
+ }
+ var encryptedBundle string
+ if err := f.db.QueryRow("SELECT encrypted_secret FROM action_credentials WHERE id = ?", connection.CredentialID).Scan(&encryptedBundle); err != nil {
+ t.Fatal(err)
+ }
+ if encryptedBundle == "oauth-access-token" || strings.Contains(encryptedBundle, "oauth-access-token") || strings.Contains(encryptedBundle, "oauth-refresh-token") {
+ t.Fatal("OAuth tokens were not encrypted at rest")
+ }
+}
+
+func createActiveZammadOAuthConnection(t *testing.T, f *zammadServiceFixture, slug string, expiresAt time.Time) *models.ZammadConnection {
+ t.Helper()
+ f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests"))
+ connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: slug, Name: "OAuth " + slug, BaseURL: "https://" + slug + ".example.test",
+ AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bundle, err := activeZammadOAuthCredential("old-access", "old-refresh")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.credentials.RotateManaged(connection.CredentialID, models.RotateActionCredentialRequest{Secret: bundle}, string(models.IntegrationProviderZammad), connection.ProviderID); err != nil {
+ t.Fatal(err)
+ }
+ if err := repository.NewZammadRepository(f.db).UpsertOAuthToken(repository.ZammadOAuthToken{
+ ProviderID: connection.ProviderID, OAuthGeneration: connection.OAuthGeneration, ExpiresAt: expiresAt,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ connection, err = f.service.GetConnection(connection.ProviderID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return connection
+}
+
+func assertZammadManagedCredentialMetadata(t *testing.T, f *zammadServiceFixture, connection *models.ZammadConnection, wantName string, wantWorkspaceID int) {
+ t.Helper()
+ credential, err := repository.NewActionCredentialRepository(f.db).GetActionCredentialByID(connection.CredentialID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if credential.Name != wantName || credential.AppliesToAllWorkspaces || len(credential.WorkspaceIDs) != 1 || credential.WorkspaceIDs[0] != wantWorkspaceID {
+ t.Fatalf("OAuth secret write overwrote concurrent credential metadata: %#v", credential)
+ }
+}
+
+func TestZammadOAuthCallbackCannotCommitAfterConfigurationReset(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests"))
+ connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "callback-race", Name: "OAuth callback race", BaseURL: "https://callback-race.example.test",
+ AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ authURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := url.Parse(authURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entered, release := make(chan struct{}), make(chan struct{})
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ close(entered)
+ <-release
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "stale-access", "refresh_token": "stale-refresh", "expires_in": 3600}), nil
+ }))
+ type callbackResult struct {
+ err error
+ }
+ done := make(chan callbackResult, 1)
+ go func() {
+ _, err := f.service.CompleteOAuth(context.Background(), parsed.Query().Get("state"), "code", "https://windshift.example.test")
+ done <- callbackResult{err: err}
+ }()
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("OAuth callback did not reach token exchange")
+ }
+ newClientID := "replacement-client"
+ reset, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{OAuthClientID: &newClientID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ close(release)
+ result := <-done
+ if !errors.Is(result.err, ErrZammadOAuthSuperseded) {
+ t.Fatalf("stale callback commit error = %v", result.err)
+ }
+ if reset.OAuthGeneration <= connection.OAuthGeneration || reset.OAuthConnected {
+ t.Fatalf("reset did not advance generation and clear authorization: %#v", reset)
+ }
+ raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil || !strings.Contains(raw, `"status":"pending"`) || strings.Contains(raw, "stale-access") {
+ t.Fatalf("stale callback reactivated credential: raw=%q err=%v", raw, err)
+ }
+}
+
+func TestZammadOAuthCallbackPreservesConcurrentNameAndScopeUpdate(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests"))
+ connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "callback-metadata-race", Name: "Original callback name", BaseURL: "https://callback-metadata-race.example.test",
+ AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ authURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := url.Parse(authURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entered, release := make(chan struct{}), make(chan struct{})
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ close(entered)
+ <-release
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "callback-access", "refresh_token": "callback-refresh", "expires_in": 3600}), nil
+ }))
+ done := make(chan error, 1)
+ go func() {
+ _, err := f.service.CompleteOAuth(context.Background(), parsed.Query().Get("state"), "code", "https://windshift.example.test")
+ done <- err
+ }()
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("OAuth callback did not reach token exchange")
+ }
+ updatedName := "Renamed during callback"
+ updatedScope := []int{f.workspace2}
+ if _, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{Name: &updatedName, WorkspaceIDs: &updatedScope}); err != nil {
+ t.Fatal(err)
+ }
+ close(release)
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ assertZammadManagedCredentialMetadata(t, f, connection, updatedName+" Zammad OAuth credentials", f.workspace2)
+ raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace2, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bundle, err := parseZammadOAuthCredential(raw)
+ if err != nil || bundle.AccessToken != "callback-access" {
+ t.Fatalf("callback secret was not committed: bundle=%#v err=%v", bundle, err)
+ }
+}
+
+func TestZammadOAuthParallelStartsLeaveOnlyNewestUsableState(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests"))
+ connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "parallel-state", Name: "Parallel OAuth state", BaseURL: "https://parallel-state.example.test",
+ AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "parallel-access", "refresh_token": "parallel-refresh", "expires_in": 3600}), nil
+ }))
+ type startResult struct {
+ state string
+ err error
+ }
+ results := make(chan startResult, 2)
+ start := func() {
+ authURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test")
+ if err != nil {
+ results <- startResult{err: err}
+ return
+ }
+ parsed, parseErr := url.Parse(authURL)
+ if parseErr != nil {
+ results <- startResult{err: parseErr}
+ return
+ }
+ results <- startResult{state: parsed.Query().Get("state")}
+ }
+ go start()
+ go start()
+ first, second := <-results, <-results
+ if first.err != nil || second.err != nil || first.state == "" || second.state == "" || first.state == second.state {
+ t.Fatalf("parallel OAuth starts failed: first=%#v second=%#v", first, second)
+ }
+ var persistedState string
+ var stateCount int
+ if err := f.db.QueryRow("SELECT state FROM zammad_oauth_state WHERE provider_id = ?", connection.ProviderID).Scan(&persistedState); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_oauth_state WHERE provider_id = ?", connection.ProviderID).Scan(&stateCount); err != nil || stateCount != 1 {
+ t.Fatalf("parallel starts retained %d states: %v", stateCount, err)
+ }
+ staleState := first.state
+ if persistedState == first.state {
+ staleState = second.state
+ }
+ if _, err := f.service.CompleteOAuth(context.Background(), staleState, "stale-code", "https://windshift.example.test"); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("superseded parallel state remained usable: %v", err)
+ }
+ if _, err := f.service.CompleteOAuth(context.Background(), persistedState, "winning-code", "https://windshift.example.test"); err != nil {
+ t.Fatalf("winning parallel state failed: %v", err)
+ }
+}
+
+func TestZammadOAuthNewStartSupersedesAlreadyRunningCallback(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests"))
+ connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "running-callback", Name: "Running OAuth callback", BaseURL: "https://running-callback.example.test",
+ AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldParsed, err := url.Parse(oldURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entered, release := make(chan struct{}), make(chan struct{})
+ var transportMu sync.Mutex
+ transportCalls := 0
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ transportMu.Lock()
+ transportCalls++
+ call := transportCalls
+ transportMu.Unlock()
+ if call == 1 {
+ close(entered)
+ <-release
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "superseded-access", "refresh_token": "superseded-refresh", "expires_in": 3600}), nil
+ }
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "winning-access", "refresh_token": "winning-refresh", "expires_in": 3600}), nil
+ }))
+ oldDone := make(chan error, 1)
+ go func() {
+ _, err := f.service.CompleteOAuth(context.Background(), oldParsed.Query().Get("state"), "old-code", "https://windshift.example.test")
+ oldDone <- err
+ }()
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("old callback did not reach token exchange")
+ }
+ newURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ newParsed, err := url.Parse(newURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ close(release)
+ if err := <-oldDone; !errors.Is(err, ErrZammadOAuthSuperseded) {
+ t.Fatalf("already-running old callback commit error = %v", err)
+ }
+ if _, err := f.service.CompleteOAuth(context.Background(), newParsed.Query().Get("state"), "new-code", "https://windshift.example.test"); err != nil {
+ t.Fatalf("new callback failed after superseding running callback: %v", err)
+ }
+ raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bundle, err := parseZammadOAuthCredential(raw)
+ if err != nil || bundle.AccessToken != "winning-access" || bundle.RefreshToken != "winning-refresh" {
+ t.Fatalf("superseded callback overwrote winning tokens: bundle=%#v err=%v", bundle, err)
+ }
+}
+
+func TestZammadOAuthRefreshCannotCommitAfterConfigurationReset(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ connection := createActiveZammadOAuthConnection(t, f, "refresh-race", time.Now().Add(-time.Minute))
+ entered, release := make(chan struct{}), make(chan struct{})
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ close(entered)
+ <-release
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "stale-refreshed-access", "refresh_token": "stale-refreshed-refresh", "expires_in": 3600}), nil
+ }))
+ done := make(chan error, 1)
+ go func() {
+ _, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1)
+ done <- err
+ }()
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("OAuth refresh did not reach token exchange")
+ }
+ newClientID := "replacement-client"
+ reset, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{OAuthClientID: &newClientID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ close(release)
+ if err := <-done; !errors.Is(err, ErrZammadOAuthSuperseded) {
+ t.Fatalf("stale refresh commit error = %v", err)
+ }
+ if reset.OAuthGeneration <= connection.OAuthGeneration || reset.OAuthConnected {
+ t.Fatalf("reset did not advance generation and clear authorization: %#v", reset)
+ }
+ raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil || !strings.Contains(raw, `"status":"pending"`) || strings.Contains(raw, "stale-refreshed-access") {
+ t.Fatalf("stale refresh reactivated credential: raw=%q err=%v", raw, err)
+ }
+}
+
+func TestZammadOAuthRefreshPreservesConcurrentNameAndScopeUpdate(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ connection := createActiveZammadOAuthConnection(t, f, "refresh-metadata-race", time.Now().Add(-time.Minute))
+ entered, release := make(chan struct{}), make(chan struct{})
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ close(entered)
+ <-release
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "refreshed-access", "refresh_token": "refreshed-refresh", "expires_in": 3600}), nil
+ }))
+ done := make(chan struct {
+ token string
+ err error
+ }, 1)
+ go func() {
+ token, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1)
+ done <- struct {
+ token string
+ err error
+ }{token: token, err: err}
+ }()
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("OAuth refresh did not reach token exchange")
+ }
+ updatedName := "Renamed during refresh"
+ updatedScope := []int{f.workspace2}
+ if _, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{Name: &updatedName, WorkspaceIDs: &updatedScope}); err != nil {
+ t.Fatal(err)
+ }
+ close(release)
+ result := <-done
+ if result.err != nil || result.token != "refreshed-access" {
+ t.Fatalf("refresh result: token=%q err=%v", result.token, result.err)
+ }
+ assertZammadManagedCredentialMetadata(t, f, connection, updatedName+" Zammad OAuth credentials", f.workspace2)
+}
+
+func TestZammadOAuthRefreshCommitRequiresCurrentClaimOwner(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ connection := createActiveZammadOAuthConnection(t, f, "claim-loss", time.Now().Add(-time.Minute))
+ entered, release := make(chan struct{}), make(chan struct{})
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ close(entered)
+ <-release
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "claim-lost-access", "refresh_token": "claim-lost-refresh", "expires_in": 3600}), nil
+ }))
+ done := make(chan error, 1)
+ go func() {
+ _, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1)
+ done <- err
+ }()
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("OAuth refresh did not reach token exchange")
+ }
+ var claimOwner string
+ var leaseUntil time.Time
+ if err := f.db.QueryRow("SELECT refresh_claim_owner, refresh_lock_until FROM zammad_oauth_tokens WHERE provider_id = ?", connection.ProviderID).Scan(&claimOwner, &leaseUntil); err != nil {
+ t.Fatal(err)
+ }
+ if claimOwner == "" || time.Until(leaseUntil) <= zammadHTTPTimeout {
+ t.Fatalf("refresh lease is not safely longer than HTTP timeout: owner=%q remaining=%s", claimOwner, time.Until(leaseUntil))
+ }
+ if _, err := f.db.ExecWrite("UPDATE zammad_oauth_tokens SET refresh_claim_owner = ? WHERE provider_id = ?", "replacement-owner", connection.ProviderID); err != nil {
+ t.Fatal(err)
+ }
+ close(release)
+ if err := <-done; !errors.Is(err, ErrZammadOAuthSuperseded) {
+ t.Fatalf("claim-lost refresh commit error = %v", err)
+ }
+ raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bundle, err := parseZammadOAuthCredential(raw)
+ if err != nil || bundle.AccessToken != "old-access" || bundle.RefreshToken != "old-refresh" {
+ t.Fatalf("claim-lost refresh overwrote credential: bundle=%#v err=%v", bundle, err)
+ }
+}
+
+func TestZammadOAuthRefreshRereadsRotatedCredentialAfterClaim(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ connection := createActiveZammadOAuthConnection(t, f, "refresh-reread", time.Now().Add(-time.Minute))
+ beforeClaim, continueClaim := make(chan struct{}), make(chan struct{})
+ f.service.SetOAuthBeforeRefreshClaimForTesting(func() {
+ close(beforeClaim)
+ <-continueClaim
+ })
+ var transportMu sync.Mutex
+ transportCalls := 0
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ transportMu.Lock()
+ transportCalls++
+ transportMu.Unlock()
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "unexpected-access", "refresh_token": "unexpected-refresh", "expires_in": 3600}), nil
+ }))
+ done := make(chan struct {
+ token string
+ err error
+ }, 1)
+ go func() {
+ token, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1)
+ done <- struct {
+ token string
+ err error
+ }{token: token, err: err}
+ }()
+ select {
+ case <-beforeClaim:
+ case <-time.After(5 * time.Second):
+ t.Fatal("second refresh request did not pause before its claim")
+ }
+ rotatedBundle, err := activeZammadOAuthCredential("already-refreshed-access", "already-rotated-refresh")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.credentials.RotateManaged(connection.CredentialID, models.RotateActionCredentialRequest{Secret: rotatedBundle}, string(models.IntegrationProviderZammad), connection.ProviderID); err != nil {
+ t.Fatal(err)
+ }
+ if err := repository.NewZammadRepository(f.db).UpsertOAuthToken(repository.ZammadOAuthToken{
+ ProviderID: connection.ProviderID, OAuthGeneration: connection.OAuthGeneration, ExpiresAt: time.Now().Add(time.Hour),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ close(continueClaim)
+ result := <-done
+ if result.err != nil || result.token != "already-refreshed-access" {
+ t.Fatalf("second request did not reuse freshly rotated credential: token=%q err=%v", result.token, result.err)
+ }
+ transportMu.Lock()
+ calls := transportCalls
+ transportMu.Unlock()
+ if calls != 0 {
+ t.Fatalf("second request reused a stale refresh token in %d upstream calls", calls)
+ }
+ var owner *string
+ if err := f.db.QueryRow("SELECT refresh_claim_owner FROM zammad_oauth_tokens WHERE provider_id = ?", connection.ProviderID).Scan(&owner); err != nil {
+ t.Fatal(err)
+ }
+ if owner != nil {
+ t.Fatalf("fresh-token fast path retained refresh claim %q", *owner)
+ }
+}
+
+func TestZammadOAuthInvalidGrantReturnsPersistenceFailure(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ connection := createActiveZammadOAuthConnection(t, f, "invalid-grant-persist", time.Now().Add(-time.Minute))
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ return jsonResponse(http.StatusBadRequest, map[string]string{"error": "invalid_grant"}), nil
+ }))
+ if _, err := f.db.ExecWrite(`CREATE TRIGGER fail_zammad_reauthorization
+ BEFORE UPDATE ON action_credentials
+ BEGIN SELECT RAISE(ABORT, 'synthetic credential persistence failure'); END`); err != nil {
+ t.Fatal(err)
+ }
+ _, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1)
+ if err == nil || errors.Is(err, ErrZammadReauthorizationRequired) {
+ t.Fatalf("invalid_grant persistence failure was hidden: %v", err)
+ }
+ token, tokenErr := repository.NewZammadRepository(f.db).GetOAuthToken(connection.ProviderID)
+ if tokenErr != nil || token.ReauthorizationRequired {
+ t.Fatalf("failed transaction partially marked reauthorization: token=%#v err=%v", token, tokenErr)
+ }
+ raw, _, resolveErr := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if resolveErr != nil {
+ t.Fatal(resolveErr)
+ }
+ bundle, parseErr := parseZammadOAuthCredential(raw)
+ if parseErr != nil || bundle.AccessToken != "old-access" {
+ t.Fatalf("failed transaction partially replaced credential: bundle=%#v err=%v", bundle, parseErr)
+ }
+}
+
+func TestZammadOAuthInvalidGrantPreservesConcurrentNameAndScopeUpdate(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ connection := createActiveZammadOAuthConnection(t, f, "invalid-grant-metadata-race", time.Now().Add(-time.Minute))
+ entered, release := make(chan struct{}), make(chan struct{})
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ close(entered)
+ <-release
+ return jsonResponse(http.StatusBadRequest, map[string]string{"error": "invalid_grant"}), nil
+ }))
+ done := make(chan error, 1)
+ go func() {
+ _, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1)
+ done <- err
+ }()
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("OAuth invalid_grant test did not reach token exchange")
+ }
+ updatedName := "Renamed during invalid grant"
+ updatedScope := []int{f.workspace2}
+ if _, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{Name: &updatedName, WorkspaceIDs: &updatedScope}); err != nil {
+ t.Fatal(err)
+ }
+ close(release)
+ if err := <-done; !errors.Is(err, ErrZammadReauthorizationRequired) {
+ t.Fatalf("invalid_grant result = %v", err)
+ }
+ assertZammadManagedCredentialMetadata(t, f, connection, updatedName+" Zammad OAuth credentials", f.workspace2)
+ raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace2, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil || !strings.Contains(raw, `"status":"reauthorization_required"`) {
+ t.Fatalf("invalid_grant did not persist reauthorization secret: raw=%q err=%v", raw, err)
+ }
+}
+
+func TestZammadOAuthTestSkipsAdminOnlyCorrelationFieldCheck(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests"))
+ connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "oauth-scope", Name: "Scoped OAuth", BaseURL: "https://scope.example.test", AuthMethod: models.ZammadAuthMethodOAuth,
+ OAuthClientID: "client", OAuthClientSecret: "secret", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bundle, err := activeZammadOAuthCredential("access", "refresh")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.credentials.RotateManaged(connection.CredentialID, models.RotateActionCredentialRequest{Secret: bundle}, string(models.IntegrationProviderZammad), connection.ProviderID); err != nil {
+ t.Fatal(err)
+ }
+ if err := repository.NewZammadRepository(f.db).UpsertOAuthToken(repository.ZammadOAuthToken{ProviderID: connection.ProviderID, ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
+ t.Fatal(err)
+ }
+ calledObjectManager := false
+ f.service.SetTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, targetURL string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ if strings.Contains(targetURL, "object_manager_attributes") {
+ calledObjectManager = true
+ return jsonResponse(http.StatusForbidden, map[string]string{}), nil
+ }
+ if strings.Contains(targetURL, "/groups") {
+ return jsonResponse(http.StatusOK, []map[string]any{{"id": 7, "name": "Support", "active": true}}), nil
+ }
+ return jsonResponse(http.StatusOK, []map[string]any{{"id": 2, "name": "open", "active": true}}), nil
+ }))
+ metadata, err := f.service.TestConnection(context.Background(), connection.ProviderID)
+ if err != nil || metadata.CorrelationFieldVerified || calledObjectManager {
+ t.Fatalf("OAuth test must not require admin.object: metadata=%#v err=%v called=%v", metadata, err, calledObjectManager)
+ }
+}
+
+func TestZammadSafeTransportHonorsAllowLocalConnections(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/groups" {
+ t.Fatalf("unexpected path %q", r.URL.Path)
+ }
+ _, _ = w.Write([]byte(`[]`))
+ }))
+ defer server.Close()
+ previous := utils.AllowLocalConnections()
+ defer utils.SetAllowLocalConnections(previous)
+ transport := newZammadSafeTransport(server.URL, "/api/v1/")
+ utils.SetAllowLocalConnections(true)
+ if _, err := transport.Do(context.Background(), http.MethodGet, server.URL+"/api/v1/groups", nil, nil); err != nil {
+ t.Fatalf("ALLOW_LOCAL_CONNECTIONS=true blocked local Zammad target: %v", err)
+ }
+ utils.SetAllowLocalConnections(false)
+ if _, err := transport.Do(context.Background(), http.MethodGet, server.URL+"/api/v1/groups", nil, nil); !errors.Is(err, utils.ErrBlockedSSRFAddr) {
+ t.Fatalf("ALLOW_LOCAL_CONNECTIONS=false did not block local Zammad target: %v", err)
+ }
+}
+
+func TestZammadClientsUseExactlyOneExpectedAuthorizationScheme(t *testing.T) {
+ for _, testCase := range []struct {
+ name, want string
+ client *zammad.Client
+ }{
+ {name: "legacy", want: "Token token=legacy-token", client: zammad.NewClient("https://zammad.example.test", "legacy-token", zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, headers map[string]string) (*zammad.Response, error) {
+ if headers["Authorization"] != "Token token=legacy-token" {
+ t.Fatalf("legacy authorization = %q", headers["Authorization"])
+ }
+ return jsonResponse(http.StatusOK, []map[string]any{}), nil
+ }))},
+ {name: "oauth", want: "Bearer oauth-token", client: zammad.NewOAuthClient("https://zammad.example.test", "oauth-token", zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, headers map[string]string) (*zammad.Response, error) {
+ if headers["Authorization"] != "Bearer oauth-token" {
+ t.Fatalf("OAuth authorization = %q", headers["Authorization"])
+ }
+ return jsonResponse(http.StatusOK, []map[string]any{}), nil
+ }))},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ if _, err := testCase.client.Groups(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
diff --git a/internal/services/zammad_transport.go b/internal/services/zammad_transport.go
new file mode 100644
index 000000000..6dbbe481a
--- /dev/null
+++ b/internal/services/zammad_transport.go
@@ -0,0 +1,69 @@
+package services
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "windshift/internal/integrations/zammad"
+ "windshift/internal/utils"
+)
+
+var errZammadRedirect = errors.New("zammad redirects are not allowed")
+
+const zammadHTTPTimeout = 30 * time.Second
+
+// newZammadSafeTransport is deliberately separate from the generic action
+// transport. It keeps the exact configured Zammad origin and endpoint prefix,
+// while its dialer honors the operator's ALLOW_LOCAL_CONNECTIONS policy.
+func newZammadSafeTransport(baseURL, allowedPathPrefix string) zammad.Transport {
+ base, err := url.Parse(baseURL)
+ return zammad.TransportFunc(func(ctx context.Context, method, targetURL string, body []byte, headers map[string]string) (*zammad.Response, error) {
+ if err != nil || base == nil || base.Scheme == "" || base.Host == "" {
+ return nil, errors.New("invalid Zammad base URL")
+ }
+ target, err := url.Parse(targetURL)
+ if err != nil || target == nil || target.Scheme == "" || target.Host == "" {
+ return nil, errors.New("invalid Zammad request URL")
+ }
+ allowedPath := strings.TrimRight(base.EscapedPath(), "/") + allowedPathPrefix
+ pathAllowed := target.EscapedPath() == allowedPath
+ if strings.HasSuffix(allowedPath, "/") {
+ pathAllowed = strings.HasPrefix(target.EscapedPath(), allowedPath)
+ }
+ if target.User != nil || target.Fragment != "" || target.Scheme != base.Scheme || !strings.EqualFold(target.Host, base.Host) || !pathAllowed {
+ return nil, errors.New("zammad request URL is outside the allowed origin or path")
+ }
+ request, err := http.NewRequestWithContext(ctx, method, target.String(), strings.NewReader(string(body)))
+ if err != nil {
+ return nil, errors.New("could not create Zammad request")
+ }
+ for key, value := range headers {
+ request.Header.Set(key, value)
+ }
+ transport := utils.ConfigureHTTPTransport(&http.Transport{DialContext: utils.SafeNetDialer(10 * time.Second).DialContext, DisableKeepAlives: true})
+ client := &http.Client{Timeout: zammadHTTPTimeout, Transport: transport, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return errZammadRedirect }}
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("zammad request failed: %w", err)
+ }
+ defer func() { _ = response.Body.Close() }()
+ if response.StatusCode >= 300 && response.StatusCode < 400 {
+ return nil, errZammadRedirect
+ }
+ const maxResponseBytes = 1 << 20
+ responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1))
+ if err != nil {
+ return nil, errors.New("could not read Zammad response")
+ }
+ if len(responseBody) > maxResponseBytes {
+ return nil, errors.New("zammad response exceeds 1 MiB")
+ }
+ return &zammad.Response{StatusCode: response.StatusCode, Body: responseBody}, nil
+ })
+}
From ad4f4241f57799726bf97677c75a1b41b98800b6 Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 04:14:41 +0200
Subject: [PATCH 02/11] fix: harden Zammad integration invariants
---
.gitignore | 2 +
.../lib/features/items/ZammadItemPanel.svelte | 187 ++-
.../lib/features/items/zammadPanelContext.js | 30 +
.../features/items/zammadPanelContext.spec.js | 43 +
frontend/src/lib/locales/ar/zammad.js | 2 +-
frontend/src/lib/locales/de/zammad.js | 2 +-
frontend/src/lib/locales/en/zammad.js | 2 +-
frontend/src/lib/locales/es/zammad.js | 2 +-
frontend/src/lib/locales/pt-BR/zammad.js | 2 +-
frontend/src/lib/locales/zh-CN/zammad.js | 2 +-
.../settings/ZammadConnectionManager.svelte | 4 +-
internal/database/migrations.go | 24 +
internal/database/schema/integrations.sql | 2 +
.../database/schema/integrations_postgres.sql | 2 +
internal/database/zammad_schema_test.go | 43 +-
internal/handlers/zammad.go | 30 +
internal/handlers/zammad_test.go | 97 +-
internal/integrations/zammad/client.go | 67 +-
internal/integrations/zammad/client_test.go | 72 +-
internal/models/zammad.go | 1 +
internal/repository/errors.go | 3 +
internal/repository/zammad_repository.go | 680 ++++++--
internal/repository/zammad_repository_test.go | 240 +++
.../services/item_workspace_move_service.go | 10 +
internal/services/zammad_service.go | 797 ++++++++--
internal/services/zammad_service_test.go | 1403 +++++++++++++++--
26 files changed, 3289 insertions(+), 460 deletions(-)
create mode 100644 frontend/src/lib/features/items/zammadPanelContext.js
create mode 100644 frontend/src/lib/features/items/zammadPanelContext.spec.js
create mode 100644 internal/repository/zammad_repository_test.go
diff --git a/.gitignore b/.gitignore
index 1a101667a..6da221905 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,8 @@ cx
# Public regression coverage for the locale runtime ships with the implementation.
!frontend/src/lib/stores/i18n-utils.spec.js
!frontend/src/lib/api/integrations.spec.js
+!frontend/src/lib/features/items/zammadPanelContext.spec.js
+!internal/repository/zammad_repository_test.go
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
diff --git a/frontend/src/lib/features/items/ZammadItemPanel.svelte b/frontend/src/lib/features/items/ZammadItemPanel.svelte
index 1a0a4f20d..06aa0a8b3 100644
--- a/frontend/src/lib/features/items/ZammadItemPanel.svelte
+++ b/frontend/src/lib/features/items/ZammadItemPanel.svelte
@@ -1,5 +1,4 @@
@@ -414,7 +543,7 @@
{#if canEdit && linkConnectionUsable}
- {#if link.ticket_id}
+ {#if link.ticket_id && link.sync_state !== 'creating'}
diff --git a/frontend/src/lib/features/items/zammadPanelContext.js b/frontend/src/lib/features/items/zammadPanelContext.js
new file mode 100644
index 000000000..90aacbc38
--- /dev/null
+++ b/frontend/src/lib/features/items/zammadPanelContext.js
@@ -0,0 +1,30 @@
+export function isCurrentZammadPanelContext(
+ requestVersion,
+ currentVersion,
+ requestItemId,
+ itemId,
+ requestWorkspaceId,
+ workspaceId
+) {
+ return (
+ requestVersion === currentVersion &&
+ requestItemId === itemId &&
+ requestWorkspaceId === workspaceId
+ );
+}
+
+export function isCurrentZammadMetadataRequest(
+ requestVersion,
+ currentVersion,
+ requestConnectionId,
+ selectedConnectionId,
+ showCreate,
+ dialogMode
+) {
+ return (
+ requestVersion === currentVersion &&
+ requestConnectionId === selectedConnectionId &&
+ showCreate &&
+ dialogMode === 'create'
+ );
+}
diff --git a/frontend/src/lib/features/items/zammadPanelContext.spec.js b/frontend/src/lib/features/items/zammadPanelContext.spec.js
new file mode 100644
index 000000000..e1f1675f9
--- /dev/null
+++ b/frontend/src/lib/features/items/zammadPanelContext.spec.js
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import {
+ isCurrentZammadMetadataRequest,
+ isCurrentZammadPanelContext,
+} from './zammadPanelContext.js';
+
+describe('Zammad item panel context guards', () => {
+ it('rejects a response from the previous item after a prop change', () => {
+ expect(
+ isCurrentZammadPanelContext(1, 2, 'old-item', 'new-item', 'workspace', 'workspace')
+ ).toBe(false);
+ });
+
+ it('rejects a response from the previous workspace even when the item id is reused', () => {
+ expect(
+ isCurrentZammadPanelContext(3, 3, 'item', 'item', 'old-workspace', 'new-workspace')
+ ).toBe(false);
+ });
+
+ it('accepts a response for the current item and workspace', () => {
+ expect(isCurrentZammadPanelContext(4, 4, 'item', 'item', 'workspace', 'workspace')).toBe(true);
+ });
+});
+
+describe('Zammad metadata request guards', () => {
+ it('rejects create metadata after switching to the link dialog', () => {
+ expect(isCurrentZammadMetadataRequest(1, 1, 'connection', 'connection', true, 'link')).toBe(
+ false
+ );
+ });
+
+ it('rejects create metadata after the dialog closes', () => {
+ expect(isCurrentZammadMetadataRequest(2, 2, 'connection', 'connection', false, 'create')).toBe(
+ false
+ );
+ });
+
+ it('accepts metadata only for the active create dialog and connection', () => {
+ expect(isCurrentZammadMetadataRequest(3, 3, 'connection', 'connection', true, 'create')).toBe(
+ true
+ );
+ });
+});
diff --git a/frontend/src/lib/locales/ar/zammad.js b/frontend/src/lib/locales/ar/zammad.js
index d0660b8f8..8c0965cc4 100644
--- a/frontend/src/lib/locales/ar/zammad.js
+++ b/frontend/src/lib/locales/ar/zammad.js
@@ -9,7 +9,7 @@ export default { zammad: {
authMethod: 'طريقة المصادقة', oauth: 'OAuth', oauthDescription: 'فوّض هذا الاتصال باستخدام OAuth من Zammad.', oauthClientId: 'معرّف عميل OAuth', oauthClientSecret: 'سر عميل OAuth',
secretStored: 'مخزّن بأمان - اتركه فارغًا للاحتفاظ به', oauthPending: 'تفويض OAuth قيد الانتظار', oauthConnectedStatus: 'OAuth متصل', oauthReauthorizationRequired: 'إعادة تفويض OAuth مطلوبة',
connectOAuth: 'الاتصال بـ Zammad', reauthorizeOAuth: 'التفويض مرة أخرى', oauthConnected: 'تم اتصال OAuth مع Zammad', oauthConnectionFailed: 'فشل تفويض OAuth لـ Zammad', oauthAuthorizationFailed: 'تعذر بدء تفويض OAuth لـ Zammad',
- correlationFieldNotVerified: 'تمنع أذونات OAuth المقيّدة التحقق التلقائي من حقل الربط. تأكد من وجوده في Zammad قبل إنشاء التذاكر.',
+ correlationFieldNotVerified: 'تمنع أذونات حساب الخدمة المقيّدة التحقق التلقائي من حقل الربط. تأكد من وجوده في Zammad قبل إنشاء التذاكر.',
defaultCustomer: 'عميل التذكرة الافتراضي', defaultGroup: 'المجموعة الافتراضية', allowedGroups: 'مجموعات التذاكر المسموح بها', allowedGroupIds: 'معرفات مجموعات Zammad المسموح بها', correlationField: 'حقل الربط',
correlationFieldHint: 'أنشئ هذا الحقل النصي في تذاكر Zammad حتى تعثر المحاولات المتكررة على التذكرة المنشأة.', completionStatus: 'حالة الإكمال في Windshift',
noAutomaticCompletion: 'عدم الإكمال تلقائيا', closedStates: 'حالات Zammad المغلقة', closedStateIds: 'معرّفات حالات Zammad المغلقة',
diff --git a/frontend/src/lib/locales/de/zammad.js b/frontend/src/lib/locales/de/zammad.js
index 098b623d1..6d45bd7fd 100644
--- a/frontend/src/lib/locales/de/zammad.js
+++ b/frontend/src/lib/locales/de/zammad.js
@@ -37,7 +37,7 @@ export default { zammad: {
oauthConnected: 'Zammad OAuth verbunden',
oauthConnectionFailed: 'Zammad-OAuth-Autorisierung fehlgeschlagen',
oauthAuthorizationFailed: 'Zammad-OAuth-Autorisierung konnte nicht gestartet werden',
- correlationFieldNotVerified: 'Eingeschränkte OAuth-Berechtigungen verhindern die automatische Prüfung des Korrelationsfelds. Vor der Ticketerstellung dessen Existenz in Zammad prüfen.',
+ correlationFieldNotVerified: 'Eingeschränkte Servicekonto-Berechtigungen verhindern die automatische Prüfung des Korrelationsfelds. Vor der Ticketerstellung dessen Existenz in Zammad prüfen.',
defaultCustomer: 'Standardkunde für Tickets',
defaultGroup: 'Standardgruppe',
allowedGroups: 'Erlaubte Ticketgruppen',
diff --git a/frontend/src/lib/locales/en/zammad.js b/frontend/src/lib/locales/en/zammad.js
index b713f90df..4a6ecf793 100644
--- a/frontend/src/lib/locales/en/zammad.js
+++ b/frontend/src/lib/locales/en/zammad.js
@@ -37,7 +37,7 @@ export default { zammad: {
oauthConnected: 'Zammad OAuth connected',
oauthConnectionFailed: 'Zammad OAuth authorization failed',
oauthAuthorizationFailed: 'Could not start Zammad OAuth authorization',
- correlationFieldNotVerified: 'Restricted OAuth permissions prevent automatic verification of the correlation field. Verify that it exists in Zammad before creating tickets.',
+ correlationFieldNotVerified: 'Restricted service-account permissions prevent automatic verification of the correlation field. Verify that it exists in Zammad before creating tickets.',
defaultCustomer: 'Default ticket customer',
defaultGroup: 'Default group',
allowedGroups: 'Allowed ticket groups',
diff --git a/frontend/src/lib/locales/es/zammad.js b/frontend/src/lib/locales/es/zammad.js
index c8387fdc3..0e39873c6 100644
--- a/frontend/src/lib/locales/es/zammad.js
+++ b/frontend/src/lib/locales/es/zammad.js
@@ -12,7 +12,7 @@ export default { zammad: {
authMethod: 'Método de autenticación', oauth: 'OAuth', oauthDescription: 'Autoriza esta conexión con OAuth de Zammad.', oauthClientId: 'ID de cliente OAuth', oauthClientSecret: 'Secreto de cliente OAuth',
secretStored: 'Guardado de forma segura - déjalo vacío para conservarlo', oauthPending: 'Autorización de OAuth pendiente', oauthConnectedStatus: 'OAuth conectado', oauthReauthorizationRequired: 'Se requiere volver a autorizar OAuth',
connectOAuth: 'Conectar con Zammad', reauthorizeOAuth: 'Autorizar de nuevo', oauthConnected: 'OAuth de Zammad conectado', oauthConnectionFailed: 'Falló la autorización OAuth de Zammad', oauthAuthorizationFailed: 'No se pudo iniciar la autorización OAuth de Zammad',
- correlationFieldNotVerified: 'Los permisos restringidos de OAuth impiden verificar automáticamente el campo de correlación. Comprueba que exista en Zammad antes de crear tickets.',
+ correlationFieldNotVerified: 'Los permisos restringidos de la cuenta de servicio impiden verificar automáticamente el campo de correlación. Comprueba que exista en Zammad antes de crear tickets.',
defaultCustomer: 'Cliente predeterminado del ticket', defaultGroup: 'Grupo predeterminado', allowedGroups: 'Grupos de tickets permitidos', allowedGroupIds: 'IDs de grupos de Zammad permitidos', correlationField: 'Campo de correlación',
correlationFieldHint: 'Crea este campo de texto en los tickets de Zammad para que los reintentos encuentren un ticket ya creado.',
completionStatus: 'Estado de finalización en Windshift', noAutomaticCompletion: 'No finalizar automáticamente', closedStates: 'Estados cerrados de Zammad',
diff --git a/frontend/src/lib/locales/pt-BR/zammad.js b/frontend/src/lib/locales/pt-BR/zammad.js
index 8828a0cf4..5ddd548c1 100644
--- a/frontend/src/lib/locales/pt-BR/zammad.js
+++ b/frontend/src/lib/locales/pt-BR/zammad.js
@@ -12,7 +12,7 @@ export default { zammad: {
authMethod: 'Método de autenticação', oauth: 'OAuth', oauthDescription: 'Autorize esta conexão com OAuth do Zammad.', oauthClientId: 'ID do cliente OAuth', oauthClientSecret: 'Segredo do cliente OAuth',
secretStored: 'Armazenado com segurança - deixe em branco para manter', oauthPending: 'Autorização OAuth pendente', oauthConnectedStatus: 'OAuth conectado', oauthReauthorizationRequired: 'É necessária nova autorização OAuth',
connectOAuth: 'Conectar ao Zammad', reauthorizeOAuth: 'Autorizar novamente', oauthConnected: 'OAuth do Zammad conectado', oauthConnectionFailed: 'Falha na autorização OAuth do Zammad', oauthAuthorizationFailed: 'Não foi possível iniciar a autorização OAuth do Zammad',
- correlationFieldNotVerified: 'Permissões OAuth restritas impedem a verificação automática do campo de correlação. Confirme que ele existe no Zammad antes de criar tickets.',
+ correlationFieldNotVerified: 'Permissões restritas da conta de serviço impedem a verificação automática do campo de correlação. Confirme que ele existe no Zammad antes de criar tickets.',
defaultCustomer: 'Cliente padrão do ticket', defaultGroup: 'Grupo padrão', allowedGroups: 'Grupos de tickets permitidos', allowedGroupIds: 'IDs de grupos Zammad permitidos', correlationField: 'Campo de correlação',
correlationFieldHint: 'Crie este campo de texto nos tickets do Zammad para que novas tentativas encontrem um ticket já criado.',
completionStatus: 'Status de conclusão no Windshift', noAutomaticCompletion: 'Não concluir automaticamente', closedStates: 'Estados fechados do Zammad',
diff --git a/frontend/src/lib/locales/zh-CN/zammad.js b/frontend/src/lib/locales/zh-CN/zammad.js
index 3132b7aaf..07c8298aa 100644
--- a/frontend/src/lib/locales/zh-CN/zammad.js
+++ b/frontend/src/lib/locales/zh-CN/zammad.js
@@ -9,7 +9,7 @@ export default { zammad: {
authMethod: '认证方式', oauth: 'OAuth', oauthDescription: '使用 Zammad OAuth 授权此连接。', oauthClientId: 'OAuth 客户端 ID', oauthClientSecret: 'OAuth 客户端密钥',
secretStored: '已安全保存 - 留空以保持不变', oauthPending: '等待 OAuth 授权', oauthConnectedStatus: 'OAuth 已连接', oauthReauthorizationRequired: '需要重新授权 OAuth',
connectOAuth: '连接 Zammad', reauthorizeOAuth: '再次授权', oauthConnected: 'Zammad OAuth 已连接', oauthConnectionFailed: 'Zammad OAuth 授权失败', oauthAuthorizationFailed: '无法开始 Zammad OAuth 授权',
- correlationFieldNotVerified: '受限的 OAuth 权限无法自动验证关联字段。请在创建工单前确认该字段已存在于 Zammad 中。',
+ correlationFieldNotVerified: '受限的服务账号权限无法自动验证关联字段。请在创建工单前确认该字段已存在于 Zammad 中。',
defaultCustomer: '默认工单客户', defaultGroup: '默认群组', allowedGroups: '允许的工单组', allowedGroupIds: '允许的 Zammad 组 ID', correlationField: '关联字段',
correlationFieldHint: '请在 Zammad 工单中创建此文本字段,以便重试时找到已创建的工单。', completionStatus: 'Windshift 完成状态',
noAutomaticCompletion: '不自动完成', closedStates: 'Zammad 已关闭状态', closedStateIds: 'Zammad 已关闭状态 ID',
diff --git a/frontend/src/lib/settings/ZammadConnectionManager.svelte b/frontend/src/lib/settings/ZammadConnectionManager.svelte
index 74fbf6666..3069b1732 100644
--- a/frontend/src/lib/settings/ZammadConnectionManager.svelte
+++ b/frontend/src/lib/settings/ZammadConnectionManager.svelte
@@ -383,7 +383,7 @@
{#if editing && metadataByConnection[editing.id]?.groups?.length}
-
+
{:else}
-
+
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index e964ed0d8..b5b737b58 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -723,6 +723,30 @@ var Catalog = []Migration{
ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS completion_applied BOOLEAN NOT NULL DEFAULT false;
`,
},
+ {
+ Version: "20260831_zammad_connection_config_revision",
+ Name: "Add optimistic revision for Zammad connection configuration",
+ CheckSQLite: sqliteColumnCheck("zammad_connections", "config_revision"),
+ CheckPostgres: pgColumnCheck("zammad_connections", "config_revision"),
+ SQLite: `
+ ALTER TABLE zammad_connections ADD COLUMN config_revision INTEGER NOT NULL DEFAULT 1;
+ `,
+ Postgres: `
+ ALTER TABLE zammad_connections ADD COLUMN IF NOT EXISTS config_revision BIGINT NOT NULL DEFAULT 1;
+ `,
+ },
+ {
+ Version: "20260831_zammad_ticket_sync_lock_owner",
+ Name: "Bind Zammad ticket sync leases to their owner",
+ CheckSQLite: sqliteColumnCheck("zammad_ticket_links", "sync_lock_owner"),
+ CheckPostgres: pgColumnCheck("zammad_ticket_links", "sync_lock_owner"),
+ SQLite: `
+ ALTER TABLE zammad_ticket_links ADD COLUMN sync_lock_owner TEXT;
+ `,
+ Postgres: `
+ ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS sync_lock_owner TEXT;
+ `,
+ },
}
func applySQLiteSSOAttributeMappingDefault(db Database) (retErr error) {
diff --git a/internal/database/schema/integrations.sql b/internal/database/schema/integrations.sql
index 5018e7144..13574cc79 100644
--- a/internal/database/schema/integrations.sql
+++ b/internal/database/schema/integrations.sql
@@ -143,6 +143,7 @@ CREATE TABLE IF NOT EXISTS zammad_connections (
credential_id INTEGER NOT NULL,
auth_method TEXT NOT NULL DEFAULT 'api_token',
oauth_generation INTEGER NOT NULL DEFAULT 1,
+ config_revision INTEGER NOT NULL DEFAULT 1,
oauth_attempt_id TEXT,
base_url TEXT NOT NULL,
default_group_id INTEGER,
@@ -226,6 +227,7 @@ CREATE TABLE IF NOT EXISTS zammad_ticket_links (
last_error TEXT DEFAULT '',
completion_applied BOOLEAN NOT NULL DEFAULT false,
sync_lock_until DATETIME,
+ sync_lock_owner TEXT,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
diff --git a/internal/database/schema/integrations_postgres.sql b/internal/database/schema/integrations_postgres.sql
index e9a9a7400..512248cc4 100644
--- a/internal/database/schema/integrations_postgres.sql
+++ b/internal/database/schema/integrations_postgres.sql
@@ -126,6 +126,7 @@ CREATE TABLE IF NOT EXISTS zammad_connections (
credential_id INTEGER NOT NULL REFERENCES action_credentials(id) ON DELETE RESTRICT,
auth_method TEXT NOT NULL DEFAULT 'api_token',
oauth_generation BIGINT NOT NULL DEFAULT 1,
+ config_revision BIGINT NOT NULL DEFAULT 1,
oauth_attempt_id TEXT,
base_url TEXT NOT NULL,
default_group_id INTEGER,
@@ -198,6 +199,7 @@ CREATE TABLE IF NOT EXISTS zammad_ticket_links (
last_error TEXT DEFAULT '',
completion_applied BOOLEAN NOT NULL DEFAULT false,
sync_lock_until TIMESTAMPTZ,
+ sync_lock_owner TEXT,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
diff --git a/internal/database/zammad_schema_test.go b/internal/database/zammad_schema_test.go
index 07866fd88..d60c510fa 100644
--- a/internal/database/zammad_schema_test.go
+++ b/internal/database/zammad_schema_test.go
@@ -26,7 +26,7 @@ func TestZammadSchemaInitializesOnSQLite(t *testing.T) {
}
}
- for _, version := range []string{"20260829_zammad_integration", "20260830_zammad_oauth_connections", "20260830_zammad_oauth_generation", "20260830_zammad_ticket_link_metadata", "20260830_zammad_ticket_link_completion_postgres"} {
+ for _, version := range []string{"20260829_zammad_integration", "20260830_zammad_oauth_connections", "20260830_zammad_oauth_generation", "20260830_zammad_ticket_link_metadata", "20260830_zammad_ticket_link_completion_postgres", "20260831_zammad_connection_config_revision", "20260831_zammad_ticket_sync_lock_owner"} {
var migrationCount int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version=?", version).Scan(&migrationCount); err != nil {
t.Fatal(err)
@@ -40,7 +40,7 @@ func TestZammadSchemaInitializesOnSQLite(t *testing.T) {
t.Fatal(err)
}
defer func() { _ = rows.Close() }()
- var requiredCredential, authMethod, oauthGeneration, oauthAttempt bool
+ var requiredCredential, authMethod, oauthGeneration, configRevision, oauthAttempt bool
for rows.Next() {
var cid int
var name, columnType string
@@ -58,16 +58,20 @@ func TestZammadSchemaInitializesOnSQLite(t *testing.T) {
if name == "oauth_generation" {
oauthGeneration = notNull == 1
}
+ if name == "config_revision" {
+ configRevision = notNull == 1
+ }
if name == "oauth_attempt_id" {
oauthAttempt = true
}
}
- if !requiredCredential || !authMethod || !oauthGeneration || !oauthAttempt {
+ if !requiredCredential || !authMethod || !oauthGeneration || !configRevision || !oauthAttempt {
t.Fatal("Zammad OAuth connection schema must retain the legacy managed-credential constraint")
}
for table, columns := range map[string][]string{
"zammad_oauth_tokens": {"oauth_generation", "refresh_claim_owner"},
"zammad_oauth_state": {"oauth_generation"},
+ "zammad_ticket_links": {"sync_lock_owner"},
} {
for _, column := range columns {
var count int
@@ -116,7 +120,8 @@ func TestZammadTicketLinkMetadataSQLiteUpgrade(t *testing.T) {
);
DROP TABLE zammad_ticket_links;
ALTER TABLE zammad_ticket_links_legacy RENAME TO zammad_ticket_links;
- DELETE FROM schema_migrations WHERE version = '20260830_zammad_ticket_link_metadata';
+ DELETE FROM schema_migrations
+ WHERE version IN ('20260830_zammad_ticket_link_metadata', '20260831_zammad_ticket_sync_lock_owner');
`)
if err != nil {
t.Fatal(err)
@@ -128,7 +133,7 @@ func TestZammadTicketLinkMetadataSQLiteUpgrade(t *testing.T) {
t.Fatalf("run additive ticket-link metadata migration: %v", err)
}
- for _, column := range []string{"owner_id", "owner_name", "last_attempt_at", "next_attempt_at", "completion_applied"} {
+ for _, column := range []string{"owner_id", "owner_name", "last_attempt_at", "next_attempt_at", "completion_applied", "sync_lock_owner"} {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('zammad_ticket_links') WHERE name = ?", column).Scan(&count); err != nil {
t.Fatal(err)
@@ -173,6 +178,28 @@ func TestZammadTicketLinkMetadataMigrationBackendParity(t *testing.T) {
}
}
+func TestZammadTicketSyncLockOwnerMigrationBackendParity(t *testing.T) {
+ var migration *Migration
+ for i := range Catalog {
+ if Catalog[i].Version == "20260831_zammad_ticket_sync_lock_owner" {
+ migration = &Catalog[i]
+ break
+ }
+ }
+ if migration == nil {
+ t.Fatal("Zammad sync lock owner migration is missing")
+ }
+ if !strings.Contains(migration.CheckSQLite, "sync_lock_owner") || !strings.Contains(migration.CheckPostgres, "sync_lock_owner") {
+ t.Fatal("sync lock owner migration checks must cover both backends")
+ }
+ if !strings.Contains(migration.SQLite, "ADD COLUMN sync_lock_owner TEXT") || !strings.Contains(migration.Postgres, "ADD COLUMN IF NOT EXISTS sync_lock_owner TEXT") {
+ t.Fatal("sync lock owner migration bodies must cover both backends")
+ }
+ if strings.Contains(zammadSchemaMigrationSQLite, "sync_lock_owner TEXT") || strings.Contains(zammadSchemaMigrationPostgres, "sync_lock_owner TEXT") {
+ t.Fatal("historical Zammad migration must remain checksum-stable; sync_lock_owner belongs only in the additive migration")
+ }
+}
+
// This starts from the deployed 20260829 table shape. The OAuth migration is
// additive: it preserves an API-token connection and permits a new OAuth row
// with its mandatory managed-credential container.
@@ -214,6 +241,7 @@ func TestZammadOAuthSQLiteUpgradePreservesLegacyConnection(t *testing.T) {
DROP TABLE zammad_oauth_tokens;
DELETE FROM schema_migrations WHERE version = '20260830_zammad_oauth_connections';
DELETE FROM schema_migrations WHERE version = '20260830_zammad_oauth_generation';
+ DELETE FROM schema_migrations WHERE version = '20260831_zammad_connection_config_revision';
`)
if _, onErr := db.Exec("PRAGMA foreign_keys=ON"); onErr != nil {
t.Fatal(onErr)
@@ -225,9 +253,10 @@ func TestZammadOAuthSQLiteUpgradePreservesLegacyConnection(t *testing.T) {
t.Fatalf("run additive OAuth migration: %v", err)
}
var credentialID int
+ var configRevision int64
var authMethod string
- if err := db.QueryRow(`SELECT credential_id, auth_method FROM zammad_connections WHERE provider_id='legacy-zammad'`).Scan(&credentialID, &authMethod); err != nil || credentialID != 9001 || authMethod != "api_token" {
- t.Fatalf("legacy API connection was not preserved: credential=%d method=%q err=%v", credentialID, authMethod, err)
+ if err := db.QueryRow(`SELECT credential_id, auth_method, config_revision FROM zammad_connections WHERE provider_id='legacy-zammad'`).Scan(&credentialID, &authMethod, &configRevision); err != nil || credentialID != 9001 || authMethod != "api_token" || configRevision != 1 {
+ t.Fatalf("legacy API connection was not preserved: credential=%d method=%q revision=%d err=%v", credentialID, authMethod, configRevision, err)
}
for _, statement := range []string{
`INSERT INTO action_credentials(id, name, credential_type, applies_to_all_workspaces, encrypted_secret, is_enabled) VALUES (9002, 'OAuth pending', 'custom_header', true, 'opaque-pending-ciphertext', true)`,
diff --git a/internal/handlers/zammad.go b/internal/handlers/zammad.go
index 846105e6b..0195e54ad 100644
--- a/internal/handlers/zammad.go
+++ b/internal/handlers/zammad.go
@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"net/url"
+ "slices"
"strconv"
"strings"
@@ -193,6 +194,7 @@ func (h *ZammadHandler) ListWorkspaceConnections(w http.ResponseWriter, r *http.
if connection.AuthMethod == models.ZammadAuthMethodOAuth {
ready = connection.OAuthConnected && !connection.ReauthorizationRequired
}
+ ready = ready && zammadConnectionHasUsableDefaultGroup(connection)
responses = append(responses, models.ZammadWorkspaceConnection{
ProviderID: connection.ProviderID, Name: connection.Name, AuthMethod: connection.AuthMethod,
Ready: ready, OAuthConnected: connection.OAuthConnected, ReauthorizationRequired: connection.ReauthorizationRequired,
@@ -203,6 +205,17 @@ func (h *ZammadHandler) ListWorkspaceConnections(w http.ResponseWriter, r *http.
respondJSONOK(w, responses)
}
+// zammadConnectionHasUsableDefaultGroup checks only locally persisted
+// configuration. A name-based default is usable without an allowlist, but
+// cannot be proven to belong to a non-empty allowlist until remote metadata
+// resolves it to an ID. Do not present that ambiguous configuration as ready.
+func zammadConnectionHasUsableDefaultGroup(connection *models.ZammadConnection) bool {
+ if connection.DefaultGroupID <= 0 {
+ return strings.TrimSpace(connection.DefaultGroupName) != "" && len(connection.AllowedGroupIDs) == 0
+ }
+ return len(connection.AllowedGroupIDs) == 0 || slices.Contains(connection.AllowedGroupIDs, connection.DefaultGroupID)
+}
+
func (h *ZammadHandler) GetWorkspaceMetadata(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := requireIDParam(w, r, "workspaceId")
if !ok {
@@ -391,6 +404,18 @@ func (h *ZammadHandler) respondServiceError(w http.ResponseWriter, r *http.Reque
respondNotFound(w, r, "zammad_connection")
case errors.Is(err, repository.ErrDuplicateEntry):
respondConflict(w, r, "A Zammad connection or ticket link with these identifiers already exists")
+ case errors.Is(err, repository.ErrConcurrentUpdate):
+ respondConflict(w, r, "The Zammad connection changed while it was being updated. Reload and retry the action.")
+ case errors.Is(err, services.ErrZammadLinkReservationConflict):
+ respondConflict(w, r, "The Zammad connection or item changed while the ticket link was being prepared. Retry the action.")
+ case errors.Is(err, services.ErrZammadOAuthSuperseded):
+ w.Header().Set("Retry-After", "1")
+ respondConflict(w, r, "The Zammad authorization changed while the request was running. Reload and retry the action.")
+ case errors.Is(err, services.ErrZammadTicketGroupPolicyChanged):
+ respondConflict(w, r, "The Zammad connection no longer allows the ticket's group. Review the connection group policy before retrying.")
+ case errors.Is(err, services.ErrZammadConnectionBusy):
+ w.Header().Set("Retry-After", "1")
+ respondConflict(w, r, "A Zammad ticket operation is in progress. Retry the connection change shortly.")
default:
var apiErr *zammad.APIError
var upstreamErr *zammad.UpstreamError
@@ -401,6 +426,11 @@ func (h *ZammadHandler) respondServiceError(w http.ResponseWriter, r *http.Reque
respondError(w, r, restapi.NewAPIError(http.StatusConflict, "ZAMMAD_REAUTHORIZATION_REQUIRED", "Zammad authorization must be renewed by a system administrator"))
return false
}
+ if errors.Is(err, services.ErrZammadOAuthRefreshInProgress) {
+ w.Header().Set("Retry-After", "1")
+ respondServiceUnavailable(w, r, "Zammad authorization is being refreshed. Retry shortly.")
+ return false
+ }
switch {
case errors.As(err, &validationErr):
respondValidationError(w, r, validationErr.Error())
diff --git a/internal/handlers/zammad_test.go b/internal/handlers/zammad_test.go
index da7f4fbf3..9b9b02f66 100644
--- a/internal/handlers/zammad_test.go
+++ b/internal/handlers/zammad_test.go
@@ -137,6 +137,101 @@ func TestZammadHandlerMapsRemoteAuthenticationFailureToBadGateway(t *testing.T)
}
}
+func TestZammadHandlerMapsOAuthRefreshContentionToRetryableResponse(t *testing.T) {
+ handler, _, _, _ := newZammadHandlerTest(t)
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPost, "/api/admin/zammad-connections/example/test", nil)
+ if handler.respondServiceError(recorder, request, services.ErrZammadOAuthRefreshInProgress) {
+ t.Fatal("expected handler to write an error response")
+ }
+ if recorder.Code != http.StatusServiceUnavailable || recorder.Header().Get("Retry-After") != "1" {
+ t.Fatalf("unexpected refresh contention response: status=%d retry-after=%q body=%s", recorder.Code, recorder.Header().Get("Retry-After"), recorder.Body.String())
+ }
+}
+
+func TestZammadHandlerMapsSupersededOAuthOperationToRetryableConflict(t *testing.T) {
+ handler, _, _, _ := newZammadHandlerTest(t)
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPost, "/api/admin/zammad-connections/example/test", nil)
+ if handler.respondServiceError(recorder, request, services.ErrZammadOAuthSuperseded) {
+ t.Fatal("expected handler to write an error response")
+ }
+ if recorder.Code != http.StatusConflict || recorder.Header().Get("Retry-After") != "1" {
+ t.Fatalf("unexpected superseded OAuth response: status=%d retry-after=%q body=%s", recorder.Code, recorder.Header().Get("Retry-After"), recorder.Body.String())
+ }
+}
+
+func TestZammadHandlerMapsReservationRaceToConflict(t *testing.T) {
+ handler, _, _, _ := newZammadHandlerTest(t)
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPost, "/api/items/1/zammad-tickets", nil)
+ if handler.respondServiceError(recorder, request, services.ErrZammadLinkReservationConflict) {
+ t.Fatal("expected handler to write an error response")
+ }
+ if recorder.Code != http.StatusConflict {
+ t.Fatalf("unexpected reservation conflict response: status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+}
+
+func TestZammadHandlerMapsConcurrentConnectionUpdateToConflict(t *testing.T) {
+ handler, _, _, _ := newZammadHandlerTest(t)
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPatch, "/api/admin/zammad-connections/example", nil)
+ if handler.respondServiceError(recorder, request, repository.ErrConcurrentUpdate) {
+ t.Fatal("expected handler to write an error response")
+ }
+ if recorder.Code != http.StatusConflict {
+ t.Fatalf("unexpected connection conflict response: status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+}
+
+func TestZammadHandlerMapsTicketGroupPolicyRaceToConflict(t *testing.T) {
+ handler, _, _, _ := newZammadHandlerTest(t)
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPatch, "/api/integrations/zammad/ticket-links/example", nil)
+ if handler.respondServiceError(recorder, request, services.ErrZammadTicketGroupPolicyChanged) {
+ t.Fatal("expected handler to write an error response")
+ }
+ if recorder.Code != http.StatusConflict {
+ t.Fatalf("unexpected group policy conflict response: status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+}
+
+func TestZammadHandlerMapsBusyConnectionToRetryableConflict(t *testing.T) {
+ handler, _, _, _ := newZammadHandlerTest(t)
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPatch, "/api/admin/zammad-connections/example", nil)
+ if handler.respondServiceError(recorder, request, services.ErrZammadConnectionBusy) {
+ t.Fatal("expected handler to write an error response")
+ }
+ if recorder.Code != http.StatusConflict || recorder.Header().Get("Retry-After") != "1" {
+ t.Fatalf("unexpected busy connection response: status=%d retry-after=%q body=%s", recorder.Code, recorder.Header().Get("Retry-After"), recorder.Body.String())
+ }
+}
+
+func TestZammadConnectionReadyRequiresLocallyUsableDefaultGroup(t *testing.T) {
+ tests := []struct {
+ name string
+ connection models.ZammadConnection
+ want bool
+ }{
+ {name: "numeric default without allowlist", connection: models.ZammadConnection{DefaultGroupID: 7}, want: true},
+ {name: "numeric default inside allowlist", connection: models.ZammadConnection{DefaultGroupID: 7, AllowedGroupIDs: []int{7, 8}}, want: true},
+ {name: "numeric default outside allowlist", connection: models.ZammadConnection{DefaultGroupID: 7, AllowedGroupIDs: []int{8}}, want: false},
+ {name: "name default without allowlist", connection: models.ZammadConnection{DefaultGroupName: "Support"}, want: true},
+ {name: "name default with allowlist is unresolved", connection: models.ZammadConnection{DefaultGroupName: "Support", AllowedGroupIDs: []int{8}}, want: false},
+ {name: "missing default", connection: models.ZammadConnection{}, want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := zammadConnectionHasUsableDefaultGroup(&tt.connection); got != tt.want {
+ t.Fatalf("zammadConnectionHasUsableDefaultGroup(%#v) = %v, want %v", tt.connection, got, tt.want)
+ }
+ })
+ }
+}
+
func TestZammadOAuthCallbackRedirectsToIntegrationsAdminTab(t *testing.T) {
handler, _, _, _ := newZammadHandlerTest(t)
recorder := httptest.NewRecorder()
@@ -160,7 +255,7 @@ func TestZammadOAuthCallbackAuditsInitiatorSuccessAndFailureWithoutSecrets(t *te
connection, err := handler.service.CreateConnection(models.CreateZammadConnectionRequest{
Slug: "audit-oauth", Name: "Audit OAuth", BaseURL: "https://audit-oauth.example.test",
AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "audit-client", OAuthClientSecret: "audit-client-secret",
- DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{workspaceID},
+ DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{workspaceID},
}, user.ID)
if err != nil {
t.Fatal(err)
diff --git a/internal/integrations/zammad/client.go b/internal/integrations/zammad/client.go
index 9d7e4621b..ffb5e8f3b 100644
--- a/internal/integrations/zammad/client.go
+++ b/internal/integrations/zammad/client.go
@@ -253,41 +253,48 @@ func (c *Client) UpdateTicket(ctx context.Context, ticketID int, stateID, groupI
// Owners searches on the effective target group. Zammad only keeps an
// assignment when the selected agent has full access to the ticket group.
func (c *Client) Owners(ctx context.Context, groupID int) ([]Owner, error) {
- query := url.Values{}
- query.Set("query", "*")
- query.Add("permissions[]", "ticket.agent")
- query.Set(fmt.Sprintf("group_ids[%d]", groupID), "full")
- query.Set("per_page", "100")
- var rawUsers []map[string]json.RawMessage
- if err := c.getJSON(ctx, "/api/v1/users/search?"+query.Encode(), &rawUsers); err != nil {
- return nil, err
- }
- owners := make([]Owner, 0, len(rawUsers))
- for _, raw := range rawUsers {
- var id int
- var active bool
- var first, last, login string
- if json.Unmarshal(raw["id"], &id) != nil || id <= 0 {
- continue
+ const pageSize = 100
+ owners := make([]Owner, 0)
+ for page := 1; ; page++ {
+ query := url.Values{}
+ query.Set("query", "*")
+ query.Add("permissions[]", "ticket.agent")
+ query.Set(fmt.Sprintf("group_ids[%d]", groupID), "full")
+ query.Set("page", strconv.Itoa(page))
+ query.Set("per_page", strconv.Itoa(pageSize))
+ var rawUsers []map[string]json.RawMessage
+ if err := c.getJSON(ctx, "/api/v1/users/search?"+query.Encode(), &rawUsers); err != nil {
+ return nil, err
}
- if value, ok := raw["active"]; ok {
- if err := json.Unmarshal(value, &active); err != nil || !active {
+ for _, raw := range rawUsers {
+ var id int
+ var active bool
+ var first, last, login string
+ if json.Unmarshal(raw["id"], &id) != nil || id <= 0 {
continue
}
+ if value, ok := raw["active"]; ok {
+ if err := json.Unmarshal(value, &active); err != nil || !active {
+ continue
+ }
+ }
+ var groups map[string][]string
+ groupValue, ok := raw["group_ids"]
+ if !ok || json.Unmarshal(groupValue, &groups) != nil || !containsAccess(groups[strconv.Itoa(groupID)], "full") {
+ continue
+ }
+ _ = json.Unmarshal(raw["firstname"], &first)
+ _ = json.Unmarshal(raw["lastname"], &last)
+ _ = json.Unmarshal(raw["login"], &login)
+ name := strings.TrimSpace(first + " " + last)
+ if name == "" {
+ name = login
+ }
+ owners = append(owners, Owner{ID: id, Name: name})
}
- var groups map[string][]string
- groupValue, ok := raw["group_ids"]
- if !ok || json.Unmarshal(groupValue, &groups) != nil || !containsAccess(groups[strconv.Itoa(groupID)], "full") {
- continue
- }
- _ = json.Unmarshal(raw["firstname"], &first)
- _ = json.Unmarshal(raw["lastname"], &last)
- _ = json.Unmarshal(raw["login"], &login)
- name := strings.TrimSpace(first + " " + last)
- if name == "" {
- name = login
+ if len(rawUsers) < pageSize {
+ break
}
- owners = append(owners, Owner{ID: id, Name: name})
}
return owners, nil
}
diff --git a/internal/integrations/zammad/client_test.go b/internal/integrations/zammad/client_test.go
index db7dfea88..9cab5ff2d 100644
--- a/internal/integrations/zammad/client_test.go
+++ b/internal/integrations/zammad/client_test.go
@@ -3,6 +3,8 @@ package zammad
import (
"bytes"
"context"
+ "errors"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
@@ -154,7 +156,7 @@ func TestClientOwnersUsesGroupPermissionQueryAndFiltersIneligibleUsers(t *testin
return
}
query := r.URL.Query()
- if query.Get("query") != "*" || query.Get("permissions[]") != "ticket.agent" || query.Get("group_ids[7]") != "full" || query.Get("per_page") != "100" {
+ if query.Get("query") != "*" || query.Get("permissions[]") != "ticket.agent" || query.Get("group_ids[7]") != "full" || query.Get("page") != "1" || query.Get("per_page") != "100" {
t.Fatalf("unexpected owner query: %s", r.URL.RawQuery)
}
_, _ = w.Write([]byte(`[
@@ -176,3 +178,71 @@ func TestClientOwnersUsesGroupPermissionQueryAndFiltersIneligibleUsers(t *testin
t.Fatalf("unexpected eligible owners: %#v", owners)
}
}
+
+func TestClientOwnersPagesUntilShortPage(t *testing.T) {
+ t.Parallel()
+ pageOne := make([]string, 100)
+ for i := range pageOne {
+ pageOne[i] = fmt.Sprintf(`{"id":%d,"active":true,"login":"user-%d","group_ids":{"7":["full"]}}`, i+1, i+1)
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/users/search" {
+ http.NotFound(w, r)
+ return
+ }
+ query := r.URL.Query()
+ if query.Get("query") != "*" || query.Get("permissions[]") != "ticket.agent" || query.Get("group_ids[7]") != "full" || query.Get("per_page") != "100" {
+ t.Fatalf("unexpected owner query: %s", r.URL.RawQuery)
+ }
+ switch query.Get("page") {
+ case "1":
+ _, _ = fmt.Fprintf(w, "[%s]", strings.Join(pageOne, ","))
+ case "2":
+ _, _ = w.Write([]byte(`[{"id":101,"active":true,"firstname":"Grace","lastname":"Hopper","group_ids":{"7":["full"]}}]`))
+ default:
+ t.Fatalf("unexpected page request: %s", query.Get("page"))
+ }
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "token", httpTestTransport{client: server.Client()})
+ owners, err := client.Owners(context.Background(), 7)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(owners) != 101 || owners[0] != (Owner{ID: 1, Name: "user-1"}) || owners[100] != (Owner{ID: 101, Name: "Grace Hopper"}) {
+ t.Fatalf("unexpected paged owners: len=%d first=%#v last=%#v", len(owners), owners[0], owners[len(owners)-1])
+ }
+}
+
+func TestClientOwnersPagingHonorsContext(t *testing.T) {
+ t.Parallel()
+ pageOne := make([]string, 100)
+ for i := range pageOne {
+ pageOne[i] = fmt.Sprintf(`{"id":%d,"active":true,"login":"user-%d","group_ids":{"7":["full"]}}`, i+1, i+1)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ requests := 0
+ transport := TransportFunc(func(requestCtx context.Context, _ string, _ string, _ []byte, _ map[string]string) (*Response, error) {
+ requests++
+ if requests == 1 {
+ cancel()
+ body := fmt.Sprintf("[%s]", strings.Join(pageOne, ","))
+ return &Response{StatusCode: http.StatusOK, Body: []byte(body)}, nil
+ }
+ if err := requestCtx.Err(); err != nil {
+ return nil, err
+ }
+ return &Response{StatusCode: http.StatusOK, Body: []byte(`[]`)}, nil
+ })
+
+ client := NewClient("https://zammad.example.test", "token", transport)
+ _, err := client.Owners(ctx, 7)
+ if err == nil || !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected context cancellation, got %v", err)
+ }
+ if requests != 2 {
+ t.Fatalf("expected second page request to receive canceled context, got %d requests", requests)
+ }
+}
diff --git a/internal/models/zammad.go b/internal/models/zammad.go
index ec21526b0..700aee094 100644
--- a/internal/models/zammad.go
+++ b/internal/models/zammad.go
@@ -33,6 +33,7 @@ type ZammadConnection struct {
CredentialID int `json:"-"`
AuthMethod ZammadAuthMethod `json:"auth_method"`
OAuthGeneration int64 `json:"-"`
+ ConfigRevision int64 `json:"-"`
OAuthAttemptID string `json:"-"`
OAuthClientID string `json:"oauth_client_id,omitempty"`
OAuthClientSecretEncrypted string `json:"-"`
diff --git a/internal/repository/errors.go b/internal/repository/errors.go
index c9aaac1bc..705acb478 100644
--- a/internal/repository/errors.go
+++ b/internal/repository/errors.go
@@ -17,6 +17,9 @@ var (
// ErrDuplicateEntry is returned when a unique constraint is violated
ErrDuplicateEntry = errors.New("duplicate entry")
+ // ErrConcurrentUpdate is returned when an optimistic write loses a race.
+ ErrConcurrentUpdate = errors.New("concurrent update")
+
// ErrDefaultChannel is returned when a delete is rejected because the
// channel became (or remained) the default for its route.
ErrDefaultChannel = errors.New("cannot delete default channel")
diff --git a/internal/repository/zammad_repository.go b/internal/repository/zammad_repository.go
index c9c717aae..397f5352b 100644
--- a/internal/repository/zammad_repository.go
+++ b/internal/repository/zammad_repository.go
@@ -16,13 +16,46 @@ type ZammadRepository struct {
db database.Database
}
+type ZammadTicketLinkReservationSnapshot struct {
+ BaseURL string
+ CorrelationField string
+ ConnectionEnabled bool
+ DefaultGroupID int
+ DefaultGroupName string
+ AllowedGroupIDs []int
+ DefaultCustomer string
+ WorkspaceID int
+ WorkspaceItemNumber int
+ WorkspaceKey string
+ WorkspaceAllowed bool
+}
+
+type ZammadTicketLinkScope struct {
+ ItemID int
+ WorkspaceID int
+ GroupID int
+ GroupName string
+ SetupComplete bool
+ SyncLocked bool
+}
+
+type ZammadConnectionMutationSnapshot struct {
+ BaseURL string
+ CorrelationField string
+ DefaultGroupID int
+ DefaultGroupName string
+ AllowedGroupIDs []int
+ ClosedStateIDs []int
+ CompletionStatusID *int
+}
+
func NewZammadRepository(db database.Database) *ZammadRepository {
return &ZammadRepository{db: db}
}
const zammadConnectionColumns = `
zc.provider_id, ip.slug, ip.name, ip.enabled, zc.base_url,
- zc.credential_id, zc.auth_method, zc.oauth_generation, COALESCE(zc.oauth_attempt_id, ''), ip.oauth_client_id, ip.oauth_client_secret_encrypted,
+ zc.credential_id, zc.auth_method, zc.oauth_generation, zc.config_revision, COALESCE(zc.oauth_attempt_id, ''), ip.oauth_client_id, ip.oauth_client_secret_encrypted,
EXISTS(SELECT 1 FROM zammad_oauth_tokens zot WHERE zot.provider_id = zc.provider_id AND zot.reauthorization_required = false),
COALESCE((SELECT reauthorization_required FROM zammad_oauth_tokens zot WHERE zot.provider_id = zc.provider_id), false),
zc.default_group_id, zc.default_group_name,
@@ -145,7 +178,27 @@ func (r *ZammadRepository) UpdateConnectionTx(tx database.Tx, connection *models
if err != nil {
return err
}
- result, err := tx.Exec(`UPDATE integration_providers
+ result, err := tx.Exec(`UPDATE zammad_connections SET
+ credential_id = ?, auth_method = ?, base_url = ?, default_group_id = ?, default_group_name = ?, allowed_group_ids = ?,
+ default_customer = ?, correlation_field = ?, closed_state_ids = ?,
+ completion_status_id = ?, applies_to_all_workspaces = ?, config_revision = config_revision + 1,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE provider_id = ? AND config_revision = ?`, nullablePositiveInt(connection.CredentialID), connection.AuthMethod, connection.BaseURL, nullablePositiveInt(connection.DefaultGroupID),
+ connection.DefaultGroupName, string(allowedGroupsJSON), connection.DefaultCustomer, connection.CorrelationField,
+ string(closedJSON), connection.CompletionStatusID,
+ connection.AppliesToAllWorkspaces, connection.ProviderID, connection.ConfigRevision)
+ if err != nil {
+ return err
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows == 0 {
+ return ErrConcurrentUpdate
+ }
+ connection.ConfigRevision++
+ result, err = tx.Exec(`UPDATE integration_providers
SET slug = ?, name = ?, enabled = ?, oauth_client_id = ?, oauth_client_secret_encrypted = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND provider_type = ?`, connection.Slug, connection.Name,
connection.Enabled, nullableString(connection.OAuthClientID), nullableString(connection.OAuthClientSecretEncrypted), connection.ProviderID, models.IntegrationProviderZammad)
@@ -158,45 +211,196 @@ func (r *ZammadRepository) UpdateConnectionTx(tx database.Tx, connection *models
if rows, _ := result.RowsAffected(); rows == 0 {
return ErrNotFound
}
- _, err = tx.Exec(`UPDATE zammad_connections SET
- credential_id = ?, auth_method = ?, base_url = ?, default_group_id = ?, default_group_name = ?, allowed_group_ids = ?,
- default_customer = ?, correlation_field = ?, closed_state_ids = ?,
- completion_status_id = ?, applies_to_all_workspaces = ?,
- updated_at = CURRENT_TIMESTAMP
- WHERE provider_id = ?`, nullablePositiveInt(connection.CredentialID), connection.AuthMethod, connection.BaseURL, nullablePositiveInt(connection.DefaultGroupID),
- connection.DefaultGroupName, string(allowedGroupsJSON), connection.DefaultCustomer, connection.CorrelationField,
- string(closedJSON), connection.CompletionStatusID,
- connection.AppliesToAllWorkspaces, connection.ProviderID)
- if err != nil {
+ return replaceZammadConnectionWorkspaces(tx, connection.ProviderID, connection.AppliesToAllWorkspaces, connection.WorkspaceIDs)
+}
+
+func (r *ZammadRepository) DeleteConnectionTx(tx database.Tx, id string) error {
+ var credentialID sql.NullInt64
+ if err := tx.QueryRow("SELECT credential_id FROM zammad_connections WHERE provider_id = ?", id).Scan(&credentialID); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
return err
}
- return replaceZammadConnectionWorkspaces(tx, connection.ProviderID, connection.AppliesToAllWorkspaces, connection.WorkspaceIDs)
+ if _, err := tx.Exec("DELETE FROM integration_providers WHERE id = ?", id); err != nil {
+ return err
+ }
+ if credentialID.Valid {
+ _, err := tx.Exec("DELETE FROM action_credentials WHERE id = ?", credentialID.Int64)
+ return err
+ }
+ return nil
}
-func (r *ZammadRepository) DeleteConnection(id string) error {
- return database.WithTx(r.db, func(tx database.Tx) error {
- var credentialID sql.NullInt64
- if err := tx.QueryRow("SELECT credential_id FROM zammad_connections WHERE provider_id = ?", id).Scan(&credentialID); err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return ErrNotFound
- }
- return err
+func (r *ZammadRepository) HasTicketLinksForConnectionTx(tx database.Tx, id string) (bool, error) {
+ var hasLinks bool
+ err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM zammad_ticket_links WHERE provider_id = ?)", id).Scan(&hasLinks)
+ return hasLinks, err
+}
+
+// LockConnectionTx is the first step of every connection mutation and ticket
+// reservation. PostgreSQL uses an explicit row lock; SQLite write transactions
+// are already started with _txlock=immediate.
+func (r *ZammadRepository) LockConnectionTx(tx database.Tx, id string) error {
+ query := "SELECT provider_id FROM zammad_connections WHERE provider_id = ?"
+ if r.db.GetDriverName() == "postgres" {
+ query += " FOR UPDATE"
+ }
+ var providerID string
+ if err := tx.QueryRow(query, id).Scan(&providerID); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
}
- if _, err := tx.Exec("DELETE FROM integration_providers WHERE id = ?", id); err != nil {
- return err
+ return err
+ }
+ return nil
+}
+
+// lockTicketLinkConnectionTx reads a ticket link's provider before acquiring
+// the provider's connection lock. The preliminary read intentionally carries
+// no row lock, so every actual lock acquisition remains connection first and
+// no ticket-link row can be locked ahead of its connection on PostgreSQL.
+func (r *ZammadRepository) lockTicketLinkConnectionTx(tx database.Tx, linkID string) error {
+ var providerID string
+ if err := tx.QueryRow("SELECT provider_id FROM zammad_ticket_links WHERE id = ?", linkID).Scan(&providerID); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
}
- if credentialID.Valid {
- _, err := tx.Exec("DELETE FROM action_credentials WHERE id = ?", credentialID.Int64)
- return err
+ return err
+ }
+ if err := r.LockConnectionTx(tx, providerID); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (r *ZammadRepository) ConnectionMutationSnapshotTx(tx database.Tx, id string) (*ZammadConnectionMutationSnapshot, error) {
+ snapshot := &ZammadConnectionMutationSnapshot{}
+ var allowedGroupIDsJSON, closedStateIDsJSON string
+ var completionStatusID sql.NullInt64
+ if err := tx.QueryRow(`SELECT base_url, correlation_field, COALESCE(default_group_id, 0),
+ default_group_name, allowed_group_ids, closed_state_ids, completion_status_id
+ FROM zammad_connections WHERE provider_id = ?`, id).Scan(
+ &snapshot.BaseURL, &snapshot.CorrelationField, &snapshot.DefaultGroupID,
+ &snapshot.DefaultGroupName, &allowedGroupIDsJSON, &closedStateIDsJSON, &completionStatusID,
+ ); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
}
- return nil
- })
+ return nil, err
+ }
+ if err := json.Unmarshal([]byte(allowedGroupIDsJSON), &snapshot.AllowedGroupIDs); err != nil {
+ return nil, fmt.Errorf("decode Zammad allowed group IDs: %w", err)
+ }
+ if err := json.Unmarshal([]byte(closedStateIDsJSON), &snapshot.ClosedStateIDs); err != nil {
+ return nil, fmt.Errorf("decode Zammad closed state IDs: %w", err)
+ }
+ if completionStatusID.Valid {
+ value := int(completionStatusID.Int64)
+ snapshot.CompletionStatusID = &value
+ }
+ return snapshot, nil
}
-func (r *ZammadRepository) HasTicketLinksForConnection(id string) (bool, error) {
- var hasLinks bool
- err := r.db.QueryRow("SELECT EXISTS(SELECT 1 FROM zammad_ticket_links WHERE provider_id = ?)", id).Scan(&hasLinks)
- return hasLinks, err
+func (r *ZammadRepository) ConnectionGroupPolicyTx(tx database.Tx, id string) (defaultGroupID int, defaultGroupName string, allowedGroupIDs []int, queryErr error) {
+ var allowedGroupIDsJSON string
+ if err := tx.QueryRow(`SELECT COALESCE(default_group_id, 0), default_group_name, allowed_group_ids
+ FROM zammad_connections WHERE provider_id = ?`, id).Scan(&defaultGroupID, &defaultGroupName, &allowedGroupIDsJSON); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, "", nil, ErrNotFound
+ }
+ return 0, "", nil, err
+ }
+ if err := json.Unmarshal([]byte(allowedGroupIDsJSON), &allowedGroupIDs); err != nil {
+ return 0, "", nil, fmt.Errorf("decode Zammad allowed group IDs: %w", err)
+ }
+ return defaultGroupID, defaultGroupName, allowedGroupIDs, nil
+}
+
+func (r *ZammadRepository) LockTicketLinkReservationTx(tx database.Tx, providerID string, itemID int) (*ZammadTicketLinkReservationSnapshot, error) {
+ connectionQuery := `SELECT zc.base_url, zc.correlation_field, ip.enabled,
+ COALESCE(zc.default_group_id, 0), zc.default_group_name, zc.allowed_group_ids, zc.default_customer
+ FROM zammad_connections zc
+ JOIN integration_providers ip ON ip.id = zc.provider_id
+ WHERE zc.provider_id = ?`
+ if r.db.GetDriverName() == "postgres" {
+ connectionQuery += " FOR UPDATE OF zc"
+ }
+ snapshot := &ZammadTicketLinkReservationSnapshot{}
+ var allowedGroupIDsJSON string
+ if err := tx.QueryRow(connectionQuery, providerID).Scan(
+ &snapshot.BaseURL, &snapshot.CorrelationField, &snapshot.ConnectionEnabled,
+ &snapshot.DefaultGroupID, &snapshot.DefaultGroupName, &allowedGroupIDsJSON, &snapshot.DefaultCustomer,
+ ); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+ if err := json.Unmarshal([]byte(allowedGroupIDsJSON), &snapshot.AllowedGroupIDs); err != nil {
+ return nil, fmt.Errorf("decode Zammad allowed group IDs: %w", err)
+ }
+ itemQuery := `SELECT i.workspace_id, i.workspace_item_number, w.key
+ FROM items i JOIN workspaces w ON w.id = i.workspace_id WHERE i.id = ?`
+ if r.db.GetDriverName() == "postgres" {
+ itemQuery += " FOR UPDATE OF i"
+ }
+ if err := tx.QueryRow(itemQuery, itemID).Scan(&snapshot.WorkspaceID, &snapshot.WorkspaceItemNumber, &snapshot.WorkspaceKey); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+ if err := tx.QueryRow(`SELECT EXISTS(
+ SELECT 1 FROM zammad_connections zc
+ WHERE zc.provider_id = ? AND (
+ zc.applies_to_all_workspaces = true OR EXISTS (
+ SELECT 1 FROM zammad_connection_workspaces zcw
+ WHERE zcw.provider_id = zc.provider_id AND zcw.workspace_id = ?
+ )
+ ))`, providerID, snapshot.WorkspaceID).Scan(&snapshot.WorkspaceAllowed); err != nil {
+ return nil, err
+ }
+ return snapshot, nil
+}
+
+func (r *ZammadRepository) ListTicketLinkScopesForUpdateTx(tx database.Tx, providerID string) ([]ZammadTicketLinkScope, error) {
+ query := `SELECT i.id, i.workspace_id, COALESCE(ztl.group_id, 0), COALESCE(ztl.group_name, ''),
+ ztl.item_integration_link_id IS NOT NULL,
+ ztl.sync_lock_until IS NOT NULL AND ztl.sync_lock_until > CURRENT_TIMESTAMP
+ FROM zammad_ticket_links ztl
+ JOIN items i ON i.id = ztl.item_id
+ WHERE ztl.provider_id = ? ORDER BY i.id`
+ if r.db.GetDriverName() == "postgres" {
+ query += " FOR UPDATE OF i"
+ }
+ rows, err := tx.Query(query, providerID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ scopes := []ZammadTicketLinkScope{}
+ for rows.Next() {
+ var scope ZammadTicketLinkScope
+ if err := rows.Scan(&scope.ItemID, &scope.WorkspaceID, &scope.GroupID, &scope.GroupName, &scope.SetupComplete, &scope.SyncLocked); err != nil {
+ return nil, err
+ }
+ scopes = append(scopes, scope)
+ }
+ return scopes, rows.Err()
+}
+
+func (r *ZammadRepository) HasTicketLinkUnavailableInWorkspaceTx(tx database.Tx, itemID, workspaceID int) (bool, error) {
+ var unavailable bool
+ err := tx.QueryRow(`SELECT EXISTS(
+ SELECT 1 FROM zammad_ticket_links ztl
+ JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id
+ WHERE ztl.item_id = ? AND zc.applies_to_all_workspaces = false
+ AND NOT EXISTS (
+ SELECT 1 FROM zammad_connection_workspaces zcw
+ WHERE zcw.provider_id = ztl.provider_id AND zcw.workspace_id = ?
+ ))`, itemID, workspaceID).Scan(&unavailable)
+ return unavailable, err
}
func (r *ZammadRepository) SetConnectionTestResult(id string, testedAt time.Time, testError string) error {
@@ -231,7 +435,7 @@ func scanZammadConnection(scanner interface{ Scan(...any) error }) (*models.Zamm
var oauthConnected, reauthorizationRequired bool
var allowedGroupsJSON, closedJSON string
err := scanner.Scan(&connection.ProviderID, &connection.Slug, &connection.Name,
- &connection.Enabled, &connection.BaseURL, &credentialID, &authMethod, &connection.OAuthGeneration, &connection.OAuthAttemptID, &oauthClientID, &oauthClientSecret,
+ &connection.Enabled, &connection.BaseURL, &credentialID, &authMethod, &connection.OAuthGeneration, &connection.ConfigRevision, &connection.OAuthAttemptID, &oauthClientID, &oauthClientSecret,
&oauthConnected, &reauthorizationRequired, &groupID,
&groupName, &allowedGroupsJSON, &connection.DefaultCustomer, &connection.CorrelationField,
&closedJSON, &completionStatusID, &connection.AppliesToAllWorkspaces,
@@ -323,7 +527,14 @@ func (r *ZammadRepository) GetTicketLinksForItem(itemID int) ([]*models.ZammadTi
rows, err := r.db.Query(`SELECT `+zammadTicketLinkColumns+`
FROM zammad_ticket_links ztl
JOIN integration_providers ip ON ip.id = ztl.provider_id
- WHERE ztl.item_id = ? ORDER BY ztl.created_at DESC`, itemID)
+ JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id
+ JOIN items i ON i.id = ztl.item_id
+ WHERE ztl.item_id = ? AND (
+ zc.applies_to_all_workspaces = true OR EXISTS (
+ SELECT 1 FROM zammad_connection_workspaces zcw
+ WHERE zcw.provider_id = ztl.provider_id AND zcw.workspace_id = i.workspace_id
+ )
+ ) ORDER BY ztl.created_at DESC`, itemID)
if err != nil {
return nil, err
}
@@ -369,14 +580,8 @@ func (r *ZammadRepository) GetTicketLinkForItem(itemID int, providerID string) (
return link, err
}
-func (r *ZammadRepository) HasTicketLinks(providerID string) (bool, error) {
- var exists bool
- err := r.db.QueryRow("SELECT EXISTS(SELECT 1 FROM zammad_ticket_links WHERE provider_id = ?)", providerID).Scan(&exists)
- return exists, err
-}
-
-func (r *ZammadRepository) CreatePendingTicketLink(link *models.ZammadTicketLink) error {
- _, err := r.db.ExecWrite(`INSERT INTO zammad_ticket_links
+func (r *ZammadRepository) CreatePendingTicketLinkTx(tx database.Tx, link *models.ZammadTicketLink) error {
+ _, err := tx.Exec(`INSERT INTO zammad_ticket_links
(id, item_id, provider_id, group_id, group_name, correlation_key,
sync_state, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, link.ID, link.ItemID, link.ProviderID,
@@ -388,11 +593,11 @@ func (r *ZammadRepository) CreatePendingTicketLink(link *models.ZammadTicketLink
return err
}
-// ReserveExistingTicketLink claims the provider/ticket pair before changing
+// ReserveExistingTicketLinkTx claims the provider/ticket pair before changing
// the remote correlation field. The unique constraint makes competing item
// links fail closed rather than attaching one ticket to two Windshift items.
-func (r *ZammadRepository) ReserveExistingTicketLink(link *models.ZammadTicketLink) error {
- _, err := r.db.ExecWrite(`INSERT INTO zammad_ticket_links
+func (r *ZammadRepository) ReserveExistingTicketLinkTx(tx database.Tx, link *models.ZammadTicketLink) error {
+ _, err := tx.Exec(`INSERT INTO zammad_ticket_links
(id, item_id, provider_id, ticket_id, ticket_number, ticket_url, group_id, group_name,
owner_id, owner_name, correlation_key, sync_state, last_status_id, last_status_name, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -405,92 +610,218 @@ func (r *ZammadRepository) ReserveExistingTicketLink(link *models.ZammadTicketLi
return err
}
-func (r *ZammadRepository) ClaimTicketCreation(itemID int, providerID string, now time.Time) (bool, error) {
- staleBefore := now.Add(-2 * time.Minute)
- result, err := r.db.ExecWrite(`UPDATE zammad_ticket_links
- SET sync_state = ?, creating_started_at = ?, last_error = '', updated_at = CURRENT_TIMESTAMP
- WHERE item_id = ? AND provider_id = ? AND (
- sync_state IN (?, ?, ?) OR (sync_state = ? AND creating_started_at < ?)
- )`, models.ZammadSyncCreating, now, itemID, providerID,
- models.ZammadSyncPending, models.ZammadSyncFailed, models.ZammadSyncUncertain,
- models.ZammadSyncCreating, staleBefore)
- if err != nil {
- return false, err
+// ClaimTicketCreation atomically claims a pending or retryable ticket
+// creation. Newly reserved existing tickets use the creating state with no
+// started timestamp and are eligible immediately; an older in-flight creation
+// is eligible only once its creation marker is stale. The sync lease is set in
+// the same update, so remote work has one owner from reservation to completion.
+func (r *ZammadRepository) ClaimTicketCreation(linkID, syncOwner string, now, until time.Time) (claimed, wasUncertain bool, err error) {
+ if syncOwner == "" {
+ return false, false, ErrInvalidInput
}
- rows, _ := result.RowsAffected()
- return rows == 1, nil
+ staleBefore := now.Add(-2 * time.Minute)
+ err = database.WithTx(r.db, func(tx database.Tx) error {
+ if err := r.lockTicketLinkConnectionTx(tx, linkID); err != nil {
+ return err
+ }
+ stateQuery := "SELECT sync_state FROM zammad_ticket_links WHERE id = ?"
+ if r.db.GetDriverName() == "postgres" {
+ stateQuery += " FOR UPDATE"
+ }
+ var previousState string
+ if err := tx.QueryRow(stateQuery, linkID).Scan(&previousState); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
+ return err
+ }
+ result, err := tx.Exec(`UPDATE zammad_ticket_links
+ SET sync_state = ?, creating_started_at = ?, sync_lock_until = ?, sync_lock_owner = ?,
+ last_error = '', updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ AND (sync_lock_until IS NULL OR sync_lock_until < CURRENT_TIMESTAMP)
+ AND (sync_state IN (?, ?, ?)
+ OR (sync_state = ? AND (creating_started_at IS NULL OR creating_started_at < ?)))`,
+ models.ZammadSyncCreating, now, until, syncOwner, linkID,
+ models.ZammadSyncPending, models.ZammadSyncFailed, models.ZammadSyncUncertain,
+ models.ZammadSyncCreating, staleBefore)
+ if err != nil {
+ return err
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ claimed = rows == 1
+ // A stale creating state may have crashed while the POST response was in
+ // flight. Treat it like an explicitly uncertain outcome so the next
+ // worker searches by correlation only instead of risking a duplicate.
+ wasUncertain = claimed && (previousState == string(models.ZammadSyncUncertain) || previousState == string(models.ZammadSyncCreating))
+ return nil
+ })
+ return claimed, wasUncertain, err
+}
+
+func (r *ZammadRepository) CompleteTicketCreation(linkID, syncOwner string, ticketID int, number, ticketURL string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName string, linkedBy int) error {
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ if err := r.lockTicketLinkConnectionTx(tx, linkID); err != nil {
+ return err
+ }
+ return r.CompleteTicketCreationTx(tx, linkID, syncOwner, ticketID, number, ticketURL, statusID, statusName, groupID, groupName, ownerID, ownerName, linkedBy)
+ })
}
-func (r *ZammadRepository) CompleteTicketCreation(linkID string, ticketID int, number, ticketURL string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName string, linkedBy int) error {
+// CompleteTicketCreationTx finalizes a created ticket inside the caller's
+// connection-policy transaction.
+func (r *ZammadRepository) CompleteTicketCreationTx(tx database.Tx, linkID, syncOwner string, ticketID int, number, ticketURL string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName string, linkedBy int) error {
+ if syncOwner == "" {
+ return ErrInvalidInput
+ }
genericLinkID := linkID + "-external"
metadata, _ := json.Marshal(map[string]any{
"status_id": statusID, "status_name": statusName,
"group_id": groupID, "group_name": groupName,
"owner_id": ownerID, "owner_name": ownerName,
})
- return database.WithTx(r.db, func(tx database.Tx) error {
- if _, err := tx.Exec(`INSERT INTO item_integration_links
+ result, err := tx.Exec(`INSERT INTO item_integration_links
(id, item_id, integration_provider_id, external_id, external_url,
title, icon, link_type, link_metadata, linked_by)
SELECT ?, item_id, provider_id, ?, ?, ?, ?, 'ticket', ?, ?
- FROM zammad_ticket_links WHERE id = ?
+ FROM zammad_ticket_links WHERE id = ? AND sync_lock_owner = ?
ON CONFLICT (item_id, integration_provider_id, external_id) DO UPDATE SET
external_url = excluded.external_url,
title = excluded.title,
link_metadata = excluded.link_metadata,
updated_at = CURRENT_TIMESTAMP`,
- genericLinkID, strconv.Itoa(ticketID), ticketURL, "Zammad #"+number,
- "ticket", string(metadata), strconv.Itoa(linkedBy), linkID); err != nil {
- return err
- }
- if err := tx.QueryRow(`SELECT iil.id FROM item_integration_links iil
+ genericLinkID, strconv.Itoa(ticketID), ticketURL, "Zammad #"+number,
+ "ticket", string(metadata), strconv.Itoa(linkedBy), linkID, syncOwner)
+ if err != nil {
+ return err
+ }
+ if rows, err := result.RowsAffected(); err != nil {
+ return err
+ } else if rows == 0 {
+ return ErrConcurrentUpdate
+ }
+ if err := tx.QueryRow(`SELECT iil.id FROM item_integration_links iil
JOIN zammad_ticket_links ztl ON CAST(ztl.item_id AS TEXT) = iil.item_id
AND ztl.provider_id = iil.integration_provider_id
- WHERE ztl.id = ? AND iil.external_id = ?`, linkID, strconv.Itoa(ticketID)).Scan(&genericLinkID); err != nil {
- return err
- }
- _, err := tx.Exec(`UPDATE zammad_ticket_links SET
+ WHERE ztl.id = ? AND iil.external_id = ?`, linkID, strconv.Itoa(ticketID)).Scan(&genericLinkID); err != nil {
+ return err
+ }
+ result, err = tx.Exec(`UPDATE zammad_ticket_links SET
item_integration_link_id = ?, ticket_id = ?, ticket_number = ?,
ticket_url = ?, group_id = ?, group_name = ?, owner_id = ?, owner_name = ?,
sync_state = ?, creating_started_at = NULL,
last_status_id = ?, last_status_name = ?, last_synced_at = CURRENT_TIMESTAMP,
last_attempt_at = CURRENT_TIMESTAMP, next_attempt_at = NULL,
- last_error = '', updated_at = CURRENT_TIMESTAMP
- WHERE id = ?`, genericLinkID, ticketID, number, ticketURL,
- nullablePositiveInt(groupID), groupName, nullablePositiveInt(ownerID), ownerName,
- models.ZammadSyncLinked, nullablePositiveInt(statusID), statusName, linkID)
+ last_error = '', sync_lock_until = NULL, sync_lock_owner = NULL,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND sync_lock_owner = ?`, genericLinkID, ticketID, number, ticketURL,
+ nullablePositiveInt(groupID), groupName, nullablePositiveInt(ownerID), ownerName,
+ models.ZammadSyncLinked, nullablePositiveInt(statusID), statusName, linkID, syncOwner)
+ if err != nil {
return err
+ }
+ if rows, err := result.RowsAffected(); err != nil {
+ return err
+ } else if rows != 1 {
+ return ErrConcurrentUpdate
+ }
+ return nil
+}
+
+func (r *ZammadRepository) CompleteExistingTicketLink(linkID, syncOwner, genericLinkID string, ticket *models.ZammadTicketLink, linkedBy int) error {
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ if err := r.lockTicketLinkConnectionTx(tx, linkID); err != nil {
+ return err
+ }
+ return r.CompleteExistingTicketLinkTx(tx, linkID, syncOwner, genericLinkID, ticket, linkedBy)
})
}
-func (r *ZammadRepository) CompleteExistingTicketLink(linkID, genericLinkID string, ticket *models.ZammadTicketLink, linkedBy int) error {
+// CompleteExistingTicketLinkTx finalizes an existing ticket link inside the
+// caller's connection-policy transaction.
+func (r *ZammadRepository) CompleteExistingTicketLinkTx(tx database.Tx, linkID, syncOwner, genericLinkID string, ticket *models.ZammadTicketLink, linkedBy int) error {
+ if syncOwner == "" {
+ return ErrInvalidInput
+ }
metadata, _ := json.Marshal(map[string]any{"status_id": ticket.LastStatusID, "status_name": ticket.LastStatusName, "group_id": ticket.GroupID, "group_name": ticket.GroupName, "owner_id": ticket.OwnerID, "owner_name": ticket.OwnerName})
- return database.WithTx(r.db, func(tx database.Tx) error {
- if _, err := tx.Exec(`INSERT INTO item_integration_links
+ result, err := tx.Exec(`INSERT INTO item_integration_links
(id, item_id, integration_provider_id, external_id, external_url, title, icon, link_type, link_metadata, linked_by)
- SELECT ?, item_id, provider_id, ?, ?, ?, ?, 'ticket', ?, ? FROM zammad_ticket_links WHERE id = ?
+ SELECT ?, item_id, provider_id, ?, ?, ?, ?, 'ticket', ?, ? FROM zammad_ticket_links WHERE id = ? AND sync_lock_owner = ?
ON CONFLICT (item_id, integration_provider_id, external_id) DO UPDATE SET external_url=excluded.external_url, title=excluded.title, link_metadata=excluded.link_metadata, updated_at=CURRENT_TIMESTAMP`,
- genericLinkID, strconv.Itoa(ticket.TicketID), ticket.TicketURL, "Zammad #"+ticket.TicketNumber, "ticket", string(metadata), strconv.Itoa(linkedBy), linkID); err != nil {
- return err
- }
- _, err := tx.Exec(`UPDATE zammad_ticket_links SET item_integration_link_id=?, ticket_id=?, ticket_number=?, ticket_url=?, group_id=?, group_name=?, owner_id=?, owner_name=?, sync_state=?, creating_started_at=NULL, last_status_id=?, last_status_name=?, last_synced_at=CURRENT_TIMESTAMP, last_attempt_at=CURRENT_TIMESTAMP, next_attempt_at=NULL, last_error='', updated_at=CURRENT_TIMESTAMP WHERE id=?`,
- genericLinkID, ticket.TicketID, ticket.TicketNumber, ticket.TicketURL, nullablePositiveInt(ticket.GroupID), ticket.GroupName, nullablePositiveInt(ticket.OwnerID), ticket.OwnerName, models.ZammadSyncLinked, nullablePositiveInt(ticket.LastStatusID), ticket.LastStatusName, linkID)
+ genericLinkID, strconv.Itoa(ticket.TicketID), ticket.TicketURL, "Zammad #"+ticket.TicketNumber, "ticket", string(metadata), strconv.Itoa(linkedBy), linkID, syncOwner)
+ if err != nil {
return err
- })
+ }
+ if rows, err := result.RowsAffected(); err != nil {
+ return err
+ } else if rows == 0 {
+ return ErrConcurrentUpdate
+ }
+ if err := tx.QueryRow(`SELECT iil.id FROM item_integration_links iil
+ JOIN zammad_ticket_links ztl ON CAST(ztl.item_id AS TEXT) = iil.item_id
+ AND ztl.provider_id = iil.integration_provider_id
+ WHERE ztl.id = ? AND ztl.sync_lock_owner = ? AND iil.external_id = ?`,
+ linkID, syncOwner, strconv.Itoa(ticket.TicketID)).Scan(&genericLinkID); err != nil {
+ return err
+ }
+ result, err = tx.Exec(`UPDATE zammad_ticket_links SET item_integration_link_id=?, ticket_id=?, ticket_number=?, ticket_url=?, group_id=?, group_name=?, owner_id=?, owner_name=?, sync_state=?, creating_started_at=NULL, last_status_id=?, last_status_name=?, last_synced_at=CURRENT_TIMESTAMP, last_attempt_at=CURRENT_TIMESTAMP, next_attempt_at=NULL, last_error='', sync_lock_until=NULL, sync_lock_owner=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=? AND sync_lock_owner=?`,
+ genericLinkID, ticket.TicketID, ticket.TicketNumber, ticket.TicketURL, nullablePositiveInt(ticket.GroupID), ticket.GroupName, nullablePositiveInt(ticket.OwnerID), ticket.OwnerName, models.ZammadSyncLinked, nullablePositiveInt(ticket.LastStatusID), ticket.LastStatusName, linkID, syncOwner)
+ if err != nil {
+ return err
+ }
+ if rows, err := result.RowsAffected(); err != nil {
+ return err
+ } else if rows != 1 {
+ return ErrConcurrentUpdate
+ }
+ return nil
}
-func (r *ZammadRepository) MarkTicketLinkFailed(id, safeError string) error {
- _, err := r.db.ExecWrite(`UPDATE zammad_ticket_links SET
- sync_state = ?, creating_started_at = NULL, last_error = ?,
- updated_at = CURRENT_TIMESTAMP WHERE id = ?`, models.ZammadSyncFailed, safeError, id)
- return err
+func (r *ZammadRepository) MarkTicketLinkFailed(id, syncOwner, safeError string) error {
+ return r.markTicketLinkWithClaim(id, syncOwner, `sync_state = ?, creating_started_at = NULL, last_error = ?,
+ sync_lock_until = NULL, sync_lock_owner = NULL`, models.ZammadSyncFailed, safeError)
}
-func (r *ZammadRepository) MarkTicketLinkUncertain(id, safeError string) error {
- _, err := r.db.ExecWrite(`UPDATE zammad_ticket_links SET
- sync_state = ?, creating_started_at = NULL, last_error = ?,
- updated_at = CURRENT_TIMESTAMP WHERE id = ?`, models.ZammadSyncUncertain, safeError, id)
- return err
+func (r *ZammadRepository) MarkTicketLinkUncertain(id, syncOwner, safeError string) error {
+ return r.markTicketLinkWithClaim(id, syncOwner, `sync_state = ?, creating_started_at = NULL, last_error = ?,
+ sync_lock_until = NULL, sync_lock_owner = NULL`, models.ZammadSyncUncertain, safeError)
+}
+
+// MarkTicketLinkSetupError retains an existing-ticket reservation after an
+// upstream correlation update failed. A retry can safely inspect the remote
+// field and finish the same reservation without allowing background sync to
+// treat it as a complete link.
+func (r *ZammadRepository) MarkTicketLinkSetupError(id, syncOwner, safeError string) error {
+ return r.markTicketLinkWithClaim(id, syncOwner, `last_error = ?,
+ sync_lock_until = NULL, sync_lock_owner = NULL`, safeError)
+}
+
+func (r *ZammadRepository) markTicketLinkWithClaim(id, syncOwner, setClause string, args ...any) error {
+ if syncOwner == "" {
+ return ErrInvalidInput
+ }
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ if err := r.lockTicketLinkConnectionTx(tx, id); err != nil {
+ return err
+ }
+ args = append(args, id, syncOwner)
+ result, err := tx.Exec(`UPDATE zammad_ticket_links SET `+setClause+`, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND sync_lock_owner = ?`, args...)
+ if err != nil {
+ return err
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return ErrConcurrentUpdate
+ }
+ return nil
+ })
}
func (r *ZammadRepository) ResetUncertainTicketCreation(id string) (bool, error) {
@@ -505,24 +836,47 @@ func (r *ZammadRepository) ResetUncertainTicketCreation(id string) (bool, error)
return rows == 1, nil
}
-func (r *ZammadRepository) UpdateTicketLinkSync(id string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName, safeError string, now time.Time, setCompletionApplied, completionApplied bool) error {
+func (r *ZammadRepository) UpdateTicketLinkSync(id, syncOwner string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName, safeError string, now time.Time, setCompletionApplied, completionApplied bool) error {
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ if err := r.lockTicketLinkConnectionTx(tx, id); err != nil {
+ return err
+ }
+ return r.UpdateTicketLinkSyncTx(tx, id, syncOwner, statusID, statusName, groupID, groupName, ownerID, ownerName, safeError, now, setCompletionApplied, completionApplied)
+ })
+}
+
+func (r *ZammadRepository) UpdateTicketLinkSyncTx(tx database.Tx, id, syncOwner string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName, safeError string, now time.Time, setCompletionApplied, completionApplied bool) error {
+ if syncOwner == "" {
+ return ErrInvalidInput
+ }
state := models.ZammadSyncLinked
var nextAttempt any
if safeError != "" {
state = models.ZammadSyncFailed
nextAttempt = now.Add(time.Minute)
}
- _, err := r.db.ExecWrite(`UPDATE zammad_ticket_links SET
+ result, err := tx.ExecWrite(`UPDATE zammad_ticket_links SET
last_status_id = ?, last_status_name = ?,
group_id = ?, group_name = ?, owner_id = ?, owner_name = ?,
last_synced_at = CASE WHEN ? = '' THEN ? ELSE last_synced_at END,
last_attempt_at = ?, next_attempt_at = ?,
- last_error = ?, sync_state = ?, sync_lock_until = NULL,
+ last_error = ?, sync_state = ?, sync_lock_until = NULL, sync_lock_owner = NULL,
completion_applied = CASE WHEN ? THEN ? ELSE completion_applied END,
- updated_at = CURRENT_TIMESTAMP WHERE id = ?`, nullablePositiveInt(statusID), statusName,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND item_integration_link_id IS NOT NULL AND sync_lock_owner = ?`, nullablePositiveInt(statusID), statusName,
nullablePositiveInt(groupID), groupName, nullablePositiveInt(ownerID), ownerName,
- safeError, now, now, nextAttempt, safeError, state, setCompletionApplied, completionApplied, id)
- return err
+ safeError, now, now, nextAttempt, safeError, state, setCompletionApplied, completionApplied, id, syncOwner)
+ if err != nil {
+ return err
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return ErrConcurrentUpdate
+ }
+ return nil
}
func (r *ZammadRepository) ListDueTicketLinks(before time.Time, limit int) ([]*models.ZammadTicketLink, error) {
@@ -531,6 +885,7 @@ func (r *ZammadRepository) ListDueTicketLinks(before time.Time, limit int) ([]*m
JOIN integration_providers ip ON ip.id = ztl.provider_id
JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id
WHERE ip.enabled = true AND ztl.ticket_id IS NOT NULL
+ AND ztl.item_integration_link_id IS NOT NULL
AND (zc.auth_method != 'oauth' OR EXISTS (
SELECT 1 FROM zammad_oauth_tokens zot
WHERE zot.provider_id = zc.provider_id AND zot.reauthorization_required = false
@@ -554,14 +909,83 @@ func (r *ZammadRepository) ListDueTicketLinks(before time.Time, limit int) ([]*m
return links, rows.Err()
}
-func (r *ZammadRepository) ClaimSync(id string, until time.Time) (bool, error) {
- result, err := r.db.ExecWrite(`UPDATE zammad_ticket_links SET sync_lock_until = ?
- WHERE id = ? AND (sync_lock_until IS NULL OR sync_lock_until < CURRENT_TIMESTAMP)`, until, id)
- if err != nil {
- return false, err
+func (r *ZammadRepository) ClaimSync(id, syncOwner string, until time.Time) (bool, error) {
+ if syncOwner == "" {
+ return false, ErrInvalidInput
}
- rows, _ := result.RowsAffected()
- return rows == 1, nil
+ claimed := false
+ err := database.WithTx(r.db, func(tx database.Tx) error {
+ if err := r.lockTicketLinkConnectionTx(tx, id); err != nil {
+ return err
+ }
+ result, err := tx.Exec(`UPDATE zammad_ticket_links SET sync_lock_until = ?, sync_lock_owner = ?
+ WHERE id = ? AND (sync_lock_until IS NULL OR sync_lock_until < CURRENT_TIMESTAMP)`, until, syncOwner, id)
+ if err != nil {
+ return err
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ claimed = rows == 1
+ return nil
+ })
+ return claimed, err
+}
+
+// RenewSyncClaim extends a lease only if it is still owned by syncOwner. An
+// expired lease may be renewed by its original owner until another worker
+// takes it over, which prevents an idle scheduler from leaving work stranded.
+func (r *ZammadRepository) RenewSyncClaim(id, syncOwner string, until time.Time) (bool, error) {
+ if syncOwner == "" {
+ return false, ErrInvalidInput
+ }
+ renewed := false
+ err := database.WithTx(r.db, func(tx database.Tx) error {
+ if err := r.lockTicketLinkConnectionTx(tx, id); err != nil {
+ return err
+ }
+ result, err := tx.Exec(`UPDATE zammad_ticket_links SET sync_lock_until = ?
+ WHERE id = ? AND sync_lock_owner = ?`, until, id, syncOwner)
+ if err != nil {
+ return err
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ renewed = rows == 1
+ return nil
+ })
+ return renewed, err
+}
+
+// ReleaseSyncClaim clears only the caller's own lease. A stale worker that
+// lost a lease to a newer owner receives ErrConcurrentUpdate and cannot clear
+// that newer owner's lock.
+func (r *ZammadRepository) ReleaseSyncClaim(id, syncOwner string) error {
+ if syncOwner == "" {
+ return ErrInvalidInput
+ }
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ if err := r.lockTicketLinkConnectionTx(tx, id); err != nil {
+ return err
+ }
+ result, err := tx.Exec(`UPDATE zammad_ticket_links
+ SET sync_lock_until = NULL, sync_lock_owner = NULL
+ WHERE id = ? AND sync_lock_owner = ?`, id, syncOwner)
+ if err != nil {
+ return err
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return ErrConcurrentUpdate
+ }
+ return nil
+ })
}
func scanZammadTicketLink(scanner interface{ Scan(...any) error }) (*models.ZammadTicketLink, error) {
@@ -642,6 +1066,46 @@ func (r *ZammadRepository) DeleteTicketLink(id string) error {
})
}
+// DeleteTicketLinkClaimed removes a ticket link and its generic item link only
+// while the caller still owns the sync lease. This keeps unlinking from
+// deleting a reservation or completed link after a different worker has taken
+// over an expired lease.
+func (r *ZammadRepository) DeleteTicketLinkClaimed(id, syncOwner string) error {
+ if syncOwner == "" {
+ return ErrInvalidInput
+ }
+ return database.WithTx(r.db, func(tx database.Tx) error {
+ if err := r.lockTicketLinkConnectionTx(tx, id); err != nil {
+ return err
+ }
+ var genericID sql.NullString
+ if err := tx.QueryRow(`SELECT item_integration_link_id
+ FROM zammad_ticket_links WHERE id = ? AND sync_lock_owner = ?`, id, syncOwner).Scan(&genericID); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrConcurrentUpdate
+ }
+ return err
+ }
+ result, err := tx.Exec(`DELETE FROM zammad_ticket_links
+ WHERE id = ? AND sync_lock_owner = ?`, id, syncOwner)
+ if err != nil {
+ return err
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return ErrConcurrentUpdate
+ }
+ if genericID.Valid {
+ _, err = tx.Exec(`DELETE FROM item_integration_links WHERE id = ?`, genericID.String)
+ return err
+ }
+ return nil
+ })
+}
+
func nullablePositiveInt(value int) any {
if value <= 0 {
return nil
diff --git a/internal/repository/zammad_repository_test.go b/internal/repository/zammad_repository_test.go
new file mode 100644
index 000000000..2f38c4664
--- /dev/null
+++ b/internal/repository/zammad_repository_test.go
@@ -0,0 +1,240 @@
+package repository
+
+import (
+ "errors"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "windshift/internal/database"
+ "windshift/internal/models"
+)
+
+type zammadLeaseFixture struct {
+ db database.Database
+ repo *ZammadRepository
+ linkID string
+}
+
+func newZammadLeaseFixture(t *testing.T) *zammadLeaseFixture {
+ t.Helper()
+ db, err := database.NewSQLiteDB(filepath.Join(t.TempDir(), "zammad-repository.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ if err := db.Initialize(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.ExecWrite(`INSERT INTO users (id, email, username, first_name, last_name)
+ VALUES (1, 'zammad-repository@example.test', 'zammad-repository', 'Zammad', 'Repository')`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.ExecWrite(`INSERT INTO workspaces (id, name, key) VALUES (1, 'Zammad tests', 'ZRT')`); err != nil {
+ t.Fatal(err)
+ }
+ var statusID int
+ if err := db.QueryRow(`SELECT id FROM statuses ORDER BY id LIMIT 1`).Scan(&statusID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.ExecWrite(`INSERT INTO items
+ (id, workspace_id, workspace_item_number, title, description, frac_index, status_id, creator_id, last_active_at)
+ VALUES (1, 1, 1, 'Zammad repository lease test', '', 'a0', ?, 1, CURRENT_TIMESTAMP)`, statusID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.ExecWrite(`INSERT INTO action_credentials
+ (id, name, credential_type, applies_to_all_workspaces, encrypted_secret, is_enabled)
+ VALUES (1, 'Zammad test credential', 'custom_header', true, 'test-ciphertext', true)`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.ExecWrite(`INSERT INTO integration_providers
+ (id, slug, name, provider_type, enabled) VALUES ('zammad-test', 'zammad-test', 'Zammad test', 'zammad', true)`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.ExecWrite(`INSERT INTO zammad_connections
+ (provider_id, credential_id, base_url, default_customer) VALUES ('zammad-test', 1, 'https://zammad.example.test', 'robot@example.test')`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.ExecWrite(`INSERT INTO zammad_ticket_links
+ (id, item_id, provider_id, correlation_key, sync_state, created_by)
+ VALUES ('zammad-link', 1, 'zammad-test', 'ZRT-1', ?, 1)`, models.ZammadSyncPending); err != nil {
+ t.Fatal(err)
+ }
+ return &zammadLeaseFixture{db: db, repo: NewZammadRepository(db), linkID: "zammad-link"}
+}
+
+func (f *zammadLeaseFixture) makeComplete(t *testing.T) {
+ t.Helper()
+ if _, err := f.db.ExecWrite(`INSERT INTO item_integration_links
+ (id, item_id, integration_provider_id, external_id, external_url, title, link_type, linked_by)
+ VALUES ('zammad-link-external', '1', 'zammad-test', '901', 'https://zammad.example.test/#ticket/zoom/901', 'Zammad #901', 'ticket', '1')`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite(`UPDATE zammad_ticket_links
+ SET item_integration_link_id = 'zammad-link-external', ticket_id = 901, ticket_number = '901',
+ sync_state = ? WHERE id = ?`, models.ZammadSyncLinked, f.linkID); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestZammadSyncLeaseOwnerTakeoverProtectsSnapshotReleaseAndDelete(t *testing.T) {
+ f := newZammadLeaseFixture(t)
+ f.makeComplete(t)
+ now := time.Now().UTC()
+
+ claimed, err := f.repo.ClaimSync(f.linkID, "owner-a", now.Add(-time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("claim owner A: claimed=%v err=%v", claimed, err)
+ }
+ claimed, err = f.repo.ClaimSync(f.linkID, "owner-b", now.Add(time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("take over expired lease as owner B: claimed=%v err=%v", claimed, err)
+ }
+
+ if err := f.repo.ReleaseSyncClaim(f.linkID, "owner-a"); !errors.Is(err, ErrConcurrentUpdate) {
+ t.Fatalf("owner A must not release owner B lease: %v", err)
+ }
+ if err := f.repo.UpdateTicketLinkSync(f.linkID, "owner-a", 4, "closed", 7, "Support", 0, "", "", now, true, true); !errors.Is(err, ErrConcurrentUpdate) {
+ t.Fatalf("owner A must not persist a snapshot after takeover: %v", err)
+ }
+ if err := f.repo.DeleteTicketLinkClaimed(f.linkID, "owner-a"); !errors.Is(err, ErrConcurrentUpdate) {
+ t.Fatalf("owner A must not delete owner B link: %v", err)
+ }
+ renewed, err := f.repo.RenewSyncClaim(f.linkID, "owner-a", now.Add(2*time.Minute))
+ if err != nil || renewed {
+ t.Fatalf("owner A must not renew owner B lease: renewed=%v err=%v", renewed, err)
+ }
+
+ var lockOwner string
+ var ticketID, statusID int
+ if err := f.db.QueryRow(`SELECT sync_lock_owner, ticket_id, COALESCE(last_status_id, 0)
+ FROM zammad_ticket_links WHERE id = ?`, f.linkID).Scan(&lockOwner, &ticketID, &statusID); err != nil {
+ t.Fatal(err)
+ }
+ if lockOwner != "owner-b" || ticketID != 901 || statusID != 0 {
+ t.Fatalf("stale owner changed stored link: owner=%q ticket=%d status=%d", lockOwner, ticketID, statusID)
+ }
+ var genericCount int
+ if err := f.db.QueryRow(`SELECT COUNT(*) FROM item_integration_links WHERE id = 'zammad-link-external'`).Scan(&genericCount); err != nil {
+ t.Fatal(err)
+ }
+ if genericCount != 1 {
+ t.Fatalf("stale owner deleted generic link: count=%d", genericCount)
+ }
+ if err := f.repo.ReleaseSyncClaim(f.linkID, "owner-b"); err != nil {
+ t.Fatalf("release current lease owner: %v", err)
+ }
+}
+
+func TestZammadTicketCompletionRequiresCurrentLeaseOwner(t *testing.T) {
+ f := newZammadLeaseFixture(t)
+ now := time.Now().UTC()
+
+ claimed, wasUncertain, err := f.repo.ClaimTicketCreation(f.linkID, "owner-a", now, now.Add(-time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("claim ticket creation as owner A: claimed=%v err=%v", claimed, err)
+ }
+ if wasUncertain {
+ t.Fatal("pending ticket creation must not report uncertain state")
+ }
+ claimed, err = f.repo.ClaimSync(f.linkID, "owner-b", now.Add(time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("take over ticket creation lease as owner B: claimed=%v err=%v", claimed, err)
+ }
+
+ if err := f.repo.CompleteTicketCreation(f.linkID, "owner-a", 902, "902", "https://zammad.example.test/#ticket/zoom/902", 2, "open", 7, "Support", 0, "", 1); !errors.Is(err, ErrConcurrentUpdate) {
+ t.Fatalf("stale owner must not complete ticket creation: %v", err)
+ }
+ var genericCount int
+ if err := f.db.QueryRow(`SELECT COUNT(*) FROM item_integration_links WHERE id = 'zammad-link-external'`).Scan(&genericCount); err != nil {
+ t.Fatal(err)
+ }
+ if genericCount != 0 {
+ t.Fatalf("stale owner created generic item link: count=%d", genericCount)
+ }
+
+ if err := f.repo.CompleteTicketCreation(f.linkID, "owner-b", 902, "902", "https://zammad.example.test/#ticket/zoom/902", 2, "open", 7, "Support", 0, "", 1); err != nil {
+ t.Fatalf("current owner completes ticket creation: %v", err)
+ }
+ var ticketID int
+ var lockOwner any
+ if err := f.db.QueryRow(`SELECT ticket_id, sync_lock_owner FROM zammad_ticket_links WHERE id = ?`, f.linkID).Scan(&ticketID, &lockOwner); err != nil {
+ t.Fatal(err)
+ }
+ if ticketID != 902 || lockOwner != nil {
+ t.Fatalf("completion did not atomically store ticket and clear lease: ticket=%d owner=%v", ticketID, lockOwner)
+ }
+}
+
+func TestZammadExistingTicketCompletionRequiresCurrentLeaseOwner(t *testing.T) {
+ f := newZammadLeaseFixture(t)
+ now := time.Now().UTC()
+ claimed, _, err := f.repo.ClaimTicketCreation(f.linkID, "owner-a", now, now.Add(-time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("claim existing-ticket reservation as owner A: claimed=%v err=%v", claimed, err)
+ }
+ claimed, err = f.repo.ClaimSync(f.linkID, "owner-b", now.Add(time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("take over existing-ticket reservation as owner B: claimed=%v err=%v", claimed, err)
+ }
+ ticket := &models.ZammadTicketLink{
+ TicketID: 903, TicketNumber: "903", TicketURL: "https://zammad.example.test/#ticket/zoom/903",
+ GroupID: 7, GroupName: "Support", LastStatusID: 2, LastStatusName: "open",
+ }
+ if err := f.repo.CompleteExistingTicketLink(f.linkID, "owner-a", f.linkID+"-external", ticket, 1); !errors.Is(err, ErrConcurrentUpdate) {
+ t.Fatalf("stale owner must not complete existing ticket link: %v", err)
+ }
+ if err := f.repo.CompleteExistingTicketLink(f.linkID, "owner-b", f.linkID+"-external", ticket, 1); err != nil {
+ t.Fatalf("current owner completes existing ticket link: %v", err)
+ }
+}
+
+func TestZammadExistingTicketCompletionReusesPreexistingGenericLinkID(t *testing.T) {
+ f := newZammadLeaseFixture(t)
+ if _, err := f.db.ExecWrite(`INSERT INTO item_integration_links
+ (id, item_id, integration_provider_id, external_id, external_url, title, link_type, linked_by)
+ VALUES ('preexisting-generic', '1', 'zammad-test', '903', 'https://old.example.test', 'Old title', 'ticket', '1')`); err != nil {
+ t.Fatal(err)
+ }
+ claimed, err := f.repo.ClaimSync(f.linkID, "owner", time.Now().Add(time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("claim existing-ticket reservation: claimed=%v err=%v", claimed, err)
+ }
+ ticket := &models.ZammadTicketLink{
+ TicketID: 903, TicketNumber: "903", TicketURL: "https://zammad.example.test/#ticket/zoom/903",
+ GroupID: 7, GroupName: "Support", LastStatusID: 2, LastStatusName: "open",
+ }
+ if err := f.repo.CompleteExistingTicketLink(f.linkID, "owner", f.linkID+"-external", ticket, 1); err != nil {
+ t.Fatalf("complete against preexisting generic link: %v", err)
+ }
+ var genericID string
+ if err := f.db.QueryRow(`SELECT item_integration_link_id FROM zammad_ticket_links WHERE id = ?`, f.linkID).Scan(&genericID); err != nil {
+ t.Fatal(err)
+ }
+ if genericID != "preexisting-generic" {
+ t.Fatalf("completion stored generated generic ID %q instead of existing row", genericID)
+ }
+}
+
+func TestZammadTicketCreationClaimReturnsAtomicUncertainState(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ state models.ZammadSyncState
+ startedAt any
+ }{
+ {name: "explicit uncertain", state: models.ZammadSyncUncertain},
+ {name: "stale in-flight create", state: models.ZammadSyncCreating, startedAt: time.Now().UTC().Add(-3 * time.Minute)},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ f := newZammadLeaseFixture(t)
+ if _, err := f.db.ExecWrite(`UPDATE zammad_ticket_links SET sync_state = ?, creating_started_at = ? WHERE id = ?`, tt.state, tt.startedAt, f.linkID); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().UTC()
+ claimed, wasUncertain, err := f.repo.ClaimTicketCreation(f.linkID, "owner-a", now, now.Add(time.Minute))
+ if err != nil || !claimed || !wasUncertain {
+ t.Fatalf("unsafe retry claim must report uncertain state: claimed=%v uncertain=%v err=%v", claimed, wasUncertain, err)
+ }
+ })
+ }
+}
diff --git a/internal/services/item_workspace_move_service.go b/internal/services/item_workspace_move_service.go
index 61a273934..83f8c983b 100644
--- a/internal/services/item_workspace_move_service.go
+++ b/internal/services/item_workspace_move_service.go
@@ -580,6 +580,16 @@ func (s *ItemWorkspaceMoveService) MoveContext(ctx context.Context, itemID, acto
if err != nil {
return nil, err
}
+ zammadLinkUnavailable, err := repository.NewZammadRepository(s.db).HasTicketLinkUnavailableInWorkspaceTx(tx, itemID, input.DestinationWorkspaceID)
+ if err != nil {
+ return nil, fmt.Errorf("validate Zammad ticket links for workspace move: %w", err)
+ }
+ if zammadLinkUnavailable {
+ return nil, &validation.ValidationError{
+ Field: "destination_workspace_id",
+ Message: "Destination workspace is not allowed by every linked Zammad connection",
+ }
+ }
newNumber, err := itemRepo.GetNextWorkspaceItemNumber(tx, input.DestinationWorkspaceID)
if err != nil {
diff --git a/internal/services/zammad_service.go b/internal/services/zammad_service.go
index 404ab9b1a..29f175d26 100644
--- a/internal/services/zammad_service.go
+++ b/internal/services/zammad_service.go
@@ -28,8 +28,15 @@ var zammadSlugPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,79}$`)
var ErrZammadReauthorizationRequired = errors.New("zammad OAuth reauthorization is required")
var ErrZammadOAuthSuperseded = errors.New("zammad OAuth operation was superseded by a configuration change")
+var ErrZammadOAuthRefreshInProgress = errors.New("zammad OAuth refresh is already in progress")
+var ErrZammadLinkReservationConflict = errors.New("zammad connection or item changed while the ticket link was being prepared")
+var ErrZammadTicketGroupPolicyChanged = errors.New("zammad connection no longer allows the ticket group")
+var ErrZammadConnectionBusy = errors.New("zammad connection has a ticket operation in progress")
const zammadOAuthRefreshLeaseDuration = 2 * zammadHTTPTimeout
+const zammadOAuthRefreshWaitDuration = 5 * time.Second
+const zammadOAuthRefreshPollInterval = 50 * time.Millisecond
+const zammadSyncLeaseDuration = 5 * time.Minute
type ZammadOAuthCallbackResult struct {
ProviderID string
@@ -83,18 +90,22 @@ type zammadPermissionChecker interface {
}
type ZammadService struct {
- db database.Database
- repo *repository.ZammadRepository
- credentials *ActionCredentialService
- permission zammadPermissionChecker
- workflow zammadWorkflowTransitioner
- condition *ConditionService
- approval *ApprovalService
- events *EventCoordinator
- transportOverride zammad.Transport
- oauthTransportOverride zammad.Transport
- encryption *sso.SecretEncryption
- oauthBeforeRefreshClaim func()
+ db database.Database
+ repo *repository.ZammadRepository
+ credentials *ActionCredentialService
+ permission zammadPermissionChecker
+ workflow zammadWorkflowTransitioner
+ condition *ConditionService
+ approval *ApprovalService
+ events *EventCoordinator
+ transportOverride zammad.Transport
+ oauthTransportOverride zammad.Transport
+ encryption *sso.SecretEncryption
+ oauthBeforeRefreshClaim func()
+ updateBeforeLock func()
+ persistBeforeLock func()
+ updateBeforeRemoteWrite func()
+ completionBeforeTransition func()
}
func NewZammadService(db database.Database, repo *repository.ZammadRepository, credentials *ActionCredentialService, permission zammadPermissionChecker, workflow zammadWorkflowTransitioner, condition *ConditionService, approval *ApprovalService) *ZammadService {
@@ -128,6 +139,22 @@ func (s *ZammadService) SetOAuthBeforeRefreshClaimForTesting(hook func()) {
s.oauthBeforeRefreshClaim = hook
}
+func (s *ZammadService) SetUpdateBeforeConnectionLockForTesting(hook func()) {
+ s.updateBeforeLock = hook
+}
+
+func (s *ZammadService) SetPersistBeforeConnectionLockForTesting(hook func()) {
+ s.persistBeforeLock = hook
+}
+
+func (s *ZammadService) SetUpdateBeforeRemoteWriteForTesting(hook func()) {
+ s.updateBeforeRemoteWrite = hook
+}
+
+func (s *ZammadService) SetCompletionBeforeTransitionForTesting(hook func()) {
+ s.completionBeforeTransition = hook
+}
+
func (s *ZammadService) ListConnections() ([]*models.ZammadConnection, error) {
return s.repo.ListConnections()
}
@@ -192,7 +219,6 @@ func (s *ZammadService) UpdateConnection(id string, req models.UpdateZammadConne
return nil, err
}
originalBaseURL := connection.BaseURL
- originalCorrelationField := connection.CorrelationField
originalOAuthClientID := connection.OAuthClientID
oauthSecretChanged := req.OAuthClientSecret != nil && strings.TrimSpace(*req.OAuthClientSecret) != ""
if req.Slug != nil {
@@ -267,15 +293,6 @@ func (s *ZammadService) UpdateConnection(id string, req models.UpdateZammadConne
if err := validateZammadConnection(connection); err != nil {
return nil, err
}
- if connection.BaseURL != originalBaseURL || connection.CorrelationField != originalCorrelationField {
- hasLinks, err := s.repo.HasTicketLinks(connection.ProviderID)
- if err != nil {
- return nil, err
- }
- if hasLinks {
- return nil, zammadValidationError("base_url and correlation_field cannot change after ticket creation has started")
- }
- }
if connection.AuthMethod == models.ZammadAuthMethodOAuth {
if err := validateZammadOAuthConfiguration(connection); err != nil {
return nil, err
@@ -292,7 +309,16 @@ func (s *ZammadService) UpdateConnection(id string, req models.UpdateZammadConne
if err != nil {
return nil, err
}
+ if s.updateBeforeLock != nil {
+ s.updateBeforeLock()
+ }
if err := database.WithTx(s.db, func(tx database.Tx) error {
+ if err := s.repo.LockConnectionTx(tx, connection.ProviderID); err != nil {
+ return err
+ }
+ if err := s.validateZammadConnectionLinksTx(tx, connection); err != nil {
+ return err
+ }
if err := s.repo.UpdateConnectionTx(tx, connection); err != nil {
return err
}
@@ -317,7 +343,16 @@ func (s *ZammadService) UpdateConnection(id string, req models.UpdateZammadConne
if err != nil {
return nil, err
}
+ if s.updateBeforeLock != nil {
+ s.updateBeforeLock()
+ }
if err := database.WithTx(s.db, func(tx database.Tx) error {
+ if err := s.repo.LockConnectionTx(tx, connection.ProviderID); err != nil {
+ return err
+ }
+ if err := s.validateZammadConnectionLinksTx(tx, connection); err != nil {
+ return err
+ }
if err := s.repo.UpdateConnectionTx(tx, connection); err != nil {
return err
}
@@ -329,14 +364,119 @@ func (s *ZammadService) UpdateConnection(id string, req models.UpdateZammadConne
}
func (s *ZammadService) DeleteConnection(id string) error {
- hasLinks, err := s.repo.HasTicketLinksForConnection(id)
+ return database.WithTx(s.db, func(tx database.Tx) error {
+ if err := s.repo.LockConnectionTx(tx, id); err != nil {
+ return err
+ }
+ hasLinks, err := s.repo.HasTicketLinksForConnectionTx(tx, id)
+ if err != nil {
+ return err
+ }
+ if hasLinks {
+ return zammadValidationError("unlink all Zammad tickets before deleting this connection")
+ }
+ return s.repo.DeleteConnectionTx(tx, id)
+ })
+}
+
+func (s *ZammadService) validateZammadConnectionLinksTx(tx database.Tx, connection *models.ZammadConnection) error {
+ current, err := s.repo.ConnectionMutationSnapshotTx(tx, connection.ProviderID)
+ if err != nil {
+ return err
+ }
+ links, err := s.repo.ListTicketLinkScopesForUpdateTx(tx, connection.ProviderID)
if err != nil {
return err
}
- if hasLinks {
- return zammadValidationError("unlink all Zammad tickets before deleting this connection")
+ if len(links) > 0 && (connection.BaseURL != current.BaseURL || connection.CorrelationField != current.CorrelationField) {
+ return zammadValidationError("base_url and correlation_field cannot change after ticket creation has started")
}
- return s.repo.DeleteConnection(id)
+ groupPolicyChanged := connection.DefaultGroupID != current.DefaultGroupID ||
+ connection.DefaultGroupName != current.DefaultGroupName ||
+ !slices.Equal(connection.AllowedGroupIDs, current.AllowedGroupIDs)
+ completionPolicyChanged := !slices.Equal(connection.ClosedStateIDs, current.ClosedStateIDs) ||
+ !optionalIntEqual(connection.CompletionStatusID, current.CompletionStatusID)
+ groupPolicyNarrows := groupPolicyChanged && zammadGroupPolicyNarrows(current, connection)
+ for _, link := range links {
+ if (groupPolicyChanged || completionPolicyChanged) && link.SyncLocked {
+ return ErrZammadConnectionBusy
+ }
+ if groupPolicyNarrows && !link.SetupComplete {
+ return ErrZammadConnectionBusy
+ }
+ if !zammadConnectionAllowsWorkspace(connection, link.WorkspaceID) {
+ return zammadValidationError("workspace scope cannot exclude items with linked Zammad tickets")
+ }
+ if !zammadConnectionAllowsGroupSnapshot(connection, link.GroupID, link.GroupName) {
+ return zammadValidationError("allowed groups cannot exclude linked Zammad tickets")
+ }
+ }
+ return nil
+}
+
+func optionalIntEqual(left, right *int) bool {
+ return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
+}
+
+func zammadConnectionAllowsWorkspace(connection *models.ZammadConnection, workspaceID int) bool {
+ return connection.AppliesToAllWorkspaces || slices.Contains(connection.WorkspaceIDs, workspaceID)
+}
+
+func zammadConnectionAllowsGroupSnapshot(connection *models.ZammadConnection, groupID int, groupName string) bool {
+ if len(connection.AllowedGroupIDs) > 0 {
+ return slices.Contains(connection.AllowedGroupIDs, groupID)
+ }
+ if connection.DefaultGroupID > 0 {
+ return connection.DefaultGroupID == groupID
+ }
+ return connection.DefaultGroupName != "" && connection.DefaultGroupName == groupName
+}
+
+// zammadGroupPolicyNarrows reports whether proposed can remove a group that an
+// incomplete ticket reservation may still need. Numeric group IDs can be
+// compared exactly. A name-only configuration has no stable ID to compare, so
+// only retaining the identical name-only policy is provably safe.
+func zammadGroupPolicyNarrows(current *repository.ZammadConnectionMutationSnapshot, proposed *models.ZammadConnection) bool {
+ if len(current.AllowedGroupIDs) > 0 {
+ for _, groupID := range current.AllowedGroupIDs {
+ if !zammadConnectionAllowsGroupSnapshot(proposed, groupID, "") {
+ return true
+ }
+ }
+ // The current default, including a name-only default, already resolves
+ // to one of these validated IDs. Preserving the entire allowlist is the
+ // complete safety condition and avoids treating a pure expansion as a
+ // narrowing when the stored default has no numeric ID.
+ return false
+ }
+ if current.DefaultGroupID > 0 {
+ return !zammadConnectionAllowsGroupSnapshot(proposed, current.DefaultGroupID, current.DefaultGroupName)
+ }
+ return len(proposed.AllowedGroupIDs) > 0 || proposed.DefaultGroupID > 0 || proposed.DefaultGroupName != current.DefaultGroupName
+}
+
+func (s *ZammadService) reserveZammadTicketLink(connection *models.ZammadConnection, item *models.Item, link *models.ZammadTicketLink, existing bool) error {
+ return database.WithTx(s.db, func(tx database.Tx) error {
+ snapshot, err := s.repo.LockTicketLinkReservationTx(tx, connection.ProviderID, item.ID)
+ if err != nil {
+ return err
+ }
+ if !snapshot.ConnectionEnabled || !snapshot.WorkspaceAllowed ||
+ snapshot.BaseURL != connection.BaseURL || snapshot.CorrelationField != connection.CorrelationField ||
+ snapshot.DefaultCustomer != connection.DefaultCustomer ||
+ snapshot.WorkspaceID != item.WorkspaceID || snapshot.WorkspaceItemNumber != item.WorkspaceItemNumber ||
+ snapshot.WorkspaceKey != item.WorkspaceKey ||
+ !zammadConnectionAllowsGroupSnapshot(&models.ZammadConnection{
+ DefaultGroupID: snapshot.DefaultGroupID, DefaultGroupName: snapshot.DefaultGroupName,
+ AllowedGroupIDs: snapshot.AllowedGroupIDs,
+ }, link.GroupID, link.GroupName) {
+ return ErrZammadLinkReservationConflict
+ }
+ if existing {
+ return s.repo.ReserveExistingTicketLinkTx(tx, link)
+ }
+ return s.repo.CreatePendingTicketLinkTx(tx, link)
+ })
}
// StartOAuth stores a short-lived state bound to this system connection and
@@ -477,7 +617,10 @@ func (s *ZammadService) oauthAccessToken(ctx context.Context, connection *models
}
token, err := s.repo.GetOAuthToken(connection.ProviderID)
if err != nil {
- return "", ErrZammadReauthorizationRequired
+ if errors.Is(err, repository.ErrNotFound) {
+ return "", ErrZammadReauthorizationRequired
+ }
+ return "", err
}
if token.ReauthorizationRequired {
return "", ErrZammadReauthorizationRequired
@@ -486,15 +629,7 @@ func (s *ZammadService) oauthAccessToken(ctx context.Context, connection *models
return "", ErrZammadOAuthSuperseded
}
if token.ExpiresAt.After(time.Now().Add(2 * time.Minute)) {
- raw, _, err := s.credentials.ResolveManaged(ctx, connection.CredentialID, workspaceID, string(models.IntegrationProviderZammad), connection.ProviderID)
- if err != nil {
- return "", err
- }
- bundle, err := parseZammadOAuthCredential(raw)
- if err != nil {
- return "", err
- }
- return bundle.AccessToken, nil
+ return s.resolveStoredZammadOAuthAccessToken(ctx, connection, workspaceID)
}
if s.oauthBeforeRefreshClaim != nil {
s.oauthBeforeRefreshClaim()
@@ -502,14 +637,52 @@ func (s *ZammadService) oauthAccessToken(ctx context.Context, connection *models
claimProviderID := connection.ProviderID
claimGeneration := token.OAuthGeneration
claimOwner := uuid.New().String()
- claimed, err := s.repo.ClaimOAuthRefresh(claimProviderID, claimGeneration, claimOwner, time.Now().Add(zammadOAuthRefreshLeaseDuration))
- if err != nil {
- return "", err
- }
- if !claimed {
- // Another request owns the short per-connection refresh lease. Never
- // issue a competing refresh request, which could invalidate rotation.
- return "", errors.New("zammad OAuth refresh is already in progress")
+ waitUntil := time.Now().Add(zammadOAuthRefreshWaitDuration)
+ for {
+ claimed, err := s.repo.ClaimOAuthRefresh(claimProviderID, claimGeneration, claimOwner, time.Now().Add(zammadOAuthRefreshLeaseDuration))
+ if err != nil {
+ return "", err
+ }
+ if claimed {
+ break
+ }
+
+ // Another request owns the per-connection refresh lease. Briefly wait
+ // for it to publish the rotated credential, then reuse that token rather
+ // than issuing a competing refresh request.
+ connection, err = s.repo.GetConnection(claimProviderID)
+ if err != nil {
+ return "", err
+ }
+ if connection.OAuthGeneration != claimGeneration {
+ return "", ErrZammadOAuthSuperseded
+ }
+ token, err = s.repo.GetOAuthToken(claimProviderID)
+ if err != nil {
+ if errors.Is(err, repository.ErrNotFound) {
+ return "", ErrZammadReauthorizationRequired
+ }
+ return "", err
+ }
+ if token.ReauthorizationRequired {
+ return "", ErrZammadReauthorizationRequired
+ }
+ if token.OAuthGeneration != claimGeneration {
+ return "", ErrZammadOAuthSuperseded
+ }
+ if token.ExpiresAt.After(time.Now().Add(2 * time.Minute)) {
+ return s.resolveStoredZammadOAuthAccessToken(ctx, connection, workspaceID)
+ }
+ if !time.Now().Before(waitUntil) {
+ return "", ErrZammadOAuthRefreshInProgress
+ }
+ timer := time.NewTimer(zammadOAuthRefreshPollInterval)
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return "", ctx.Err()
+ case <-timer.C:
+ }
}
claimActive := true
defer func() {
@@ -526,7 +699,10 @@ func (s *ZammadService) oauthAccessToken(ctx context.Context, connection *models
}
token, err = s.repo.GetOAuthTokenForRefreshClaim(claimProviderID, claimGeneration, claimOwner)
if err != nil {
- return "", ErrZammadOAuthSuperseded
+ if errors.Is(err, repository.ErrNotFound) {
+ return "", ErrZammadOAuthSuperseded
+ }
+ return "", err
}
if token.ReauthorizationRequired {
return "", ErrZammadReauthorizationRequired
@@ -596,6 +772,18 @@ func (s *ZammadService) oauthAccessToken(ctx context.Context, connection *models
return refreshed.AccessToken, nil
}
+func (s *ZammadService) resolveStoredZammadOAuthAccessToken(ctx context.Context, connection *models.ZammadConnection, workspaceID int) (string, error) {
+ raw, _, err := s.credentials.ResolveManaged(ctx, connection.CredentialID, workspaceID, string(models.IntegrationProviderZammad), connection.ProviderID)
+ if err != nil {
+ return "", err
+ }
+ bundle, err := parseZammadOAuthCredential(raw)
+ if err != nil {
+ return "", err
+ }
+ return bundle.AccessToken, nil
+}
+
func (s *ZammadService) markOAuthReauthorizationRequired(connection *models.ZammadConnection, generation int64, claimOwner string) error {
pending := pendingZammadOAuthCredential("reauthorization_required")
credentialUpdate, err := s.credentials.PrepareManagedSecretUpdate(connection.CredentialID, pending,
@@ -646,14 +834,10 @@ func (s *ZammadService) TestConnection(ctx context.Context, id string) (*models.
return nil, err
}
metadata, err := client.Metadata(ctx)
- if err == nil && connection.AuthMethod == models.ZammadAuthMethodAPIToken {
- err = client.ValidateCorrelationField(ctx, connection.CorrelationField)
- metadata.CorrelationFieldVerified = err == nil
- } else if err == nil {
- // The scoped OAuth service account intentionally need not have
- // admin.object. It is still tested against the groups and ticket-state
- // endpoints it actually needs; the custom correlation field is shown as
- // explicitly unverified rather than requiring broader privileges.
+ if err == nil {
+ // Least-privilege API-token and OAuth service accounts intentionally need
+ // not have admin.object. Test only the groups and ticket-state endpoints
+ // used at runtime and report the custom field as explicitly unverified.
metadata.CorrelationFieldVerified = false
}
if err == nil {
@@ -792,7 +976,7 @@ func (s *ZammadService) LinkExistingTicket(ctx context.Context, itemID, actorID
CorrelationKey: correlation, SyncState: models.ZammadSyncCreating,
LastStatusID: ticket.StateID, LastStatusName: statusName, CreatedBy: &actorID,
}
- if err := s.repo.ReserveExistingTicketLink(link); err != nil {
+ if err := s.reserveZammadTicketLink(connection, item, link, true); err != nil {
if !errors.Is(err, repository.ErrDuplicateEntry) {
return nil, err
}
@@ -801,13 +985,62 @@ func (s *ZammadService) LinkExistingTicket(ctx context.Context, itemID, actorID
return nil, err
}
link = existing
- correlation = link.CorrelationKey
}
}
+ syncOwner := uuid.New().String()
+ claimed, err := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration))
+ if err != nil {
+ return nil, err
+ }
+ if !claimed {
+ return nil, ErrZammadConnectionBusy
+ }
+ defer func() { _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) }()
+ link, err = s.repo.GetTicketLink(link.ID)
+ if err != nil {
+ return nil, err
+ }
+ if link.SyncState == models.ZammadSyncLinked && link.ItemIntegrationLinkID != "" {
+ return link, nil
+ }
+ if link.TicketID <= 0 || link.TicketID != ticket.ID {
+ return nil, zammadValidationError("this item already has another Zammad ticket for the selected connection")
+ }
+ // Reservation and claim may have waited behind another request. Reload the
+ // current connection and remote ticket before deciding whether to write the
+ // correlation field.
+ connection, client, err = s.client(ctx, link.ProviderID, item.WorkspaceID)
+ if err != nil {
+ return nil, err
+ }
+ ticket, err = client.GetTicket(ctx, link.TicketID)
+ if err != nil {
+ return nil, err
+ }
+ metadata, err = client.Metadata(ctx)
+ if err != nil {
+ return nil, err
+ }
+ group, err = allowedZammadGroup(connection, metadata.Groups, ticket.GroupID)
+ if err != nil {
+ return nil, err
+ }
+ remoteCorrelation, err = zammadTicketAttributeString(ticket, connection.CorrelationField)
+ if err != nil {
+ return nil, err
+ }
+ correlation = link.CorrelationKey
+ if remoteCorrelation != "" && remoteCorrelation != correlation {
+ return nil, zammadValidationError("Zammad ticket is already linked through another correlation key")
+ }
if remoteCorrelation == "" {
+ if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil {
+ return nil, err
+ }
ticket, err = client.UpdateTicket(ctx, ticket.ID, nil, nil, nil, connection.CorrelationField, correlation)
if err != nil {
+ _ = s.repo.MarkTicketLinkSetupError(link.ID, syncOwner, RedactString(err.Error()))
return nil, err
}
}
@@ -820,9 +1053,13 @@ func (s *ZammadService) LinkExistingTicket(ctx context.Context, itemID, actorID
link.OwnerName = resolveZammadOwnerName(ctx, client, ticket)
link.LastStatusID = ticket.StateID
link.LastStatusName = zammadStateName(metadata, ticket.StateID, ticket.StateName)
- if err := s.repo.CompleteExistingTicketLink(link.ID, link.ID+"-external", link, actorID); err != nil {
+ if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil {
return nil, err
}
+ if err := s.completeExistingTicketLinkWithCurrentGroupPolicy(link, syncOwner, actorID); err != nil {
+ _ = s.repo.MarkTicketLinkSetupError(link.ID, syncOwner, RedactString(err.Error()))
+ return nil, s.mapZammadSyncClaimError(err)
+ }
return s.repo.GetTicketLink(link.ID)
}
@@ -872,37 +1109,58 @@ func (s *ZammadService) CreateTicket(ctx context.Context, itemID, actorID int, r
GroupID: groupID, GroupName: groupName, CorrelationKey: correlation,
SyncState: models.ZammadSyncPending, CreatedBy: &actorID,
}
- if err := s.repo.CreatePendingTicketLink(link); err != nil && !errors.Is(err, repository.ErrDuplicateEntry) {
+ if err := s.reserveZammadTicketLink(connection, item, link, false); err != nil && !errors.Is(err, repository.ErrDuplicateEntry) {
return nil, err
}
link, err = s.repo.GetTicketLinkForItem(itemID, connection.ProviderID)
if err != nil {
return nil, err
}
- } else {
- // Once a durable creation attempt exists, retries keep its original
- // destination and correlation key. This also covers an item moved to a
- // different workspace between attempts.
- groupID = link.GroupID
- groupName = link.GroupName
- correlation = link.CorrelationKey
}
if link.TicketID != 0 {
return link, nil
}
- wasUncertain := link.SyncState == models.ZammadSyncUncertain
- claimed, err := s.repo.ClaimTicketCreation(itemID, connection.ProviderID, time.Now())
+ syncOwner := uuid.New().String()
+ now := time.Now()
+ claimed, wasUncertain, err := s.repo.ClaimTicketCreation(link.ID, syncOwner, now, now.Add(zammadSyncLeaseDuration))
if err != nil {
return nil, err
}
if !claimed {
return s.repo.GetTicketLinkForItem(itemID, connection.ProviderID)
}
+ defer func() { _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) }()
+ link, err = s.repo.GetTicketLink(link.ID)
+ if err != nil {
+ return nil, err
+ }
+ if link.TicketID != 0 {
+ return link, nil
+ }
+ // Once a durable creation attempt exists, retries keep its original
+ // destination and correlation key. This also covers an item moved to a
+ // different workspace between attempts.
+ groupID = link.GroupID
+ groupName = link.GroupName
+ correlation = link.CorrelationKey
+ connection, client, err = s.client(ctx, link.ProviderID, item.WorkspaceID)
+ if err != nil {
+ return nil, s.failZammadTicketCreationBeforePost(link.ID, syncOwner, wasUncertain, err)
+ }
+ groups, err = client.Groups(ctx)
+ if err != nil {
+ return nil, s.failZammadTicketCreationBeforePost(link.ID, syncOwner, wasUncertain, err)
+ }
+ if _, err := allowedZammadGroup(connection, groups, groupID); err != nil {
+ return nil, s.failZammadTicketCreationBeforePost(link.ID, syncOwner, wasUncertain, err)
+ }
ticket, requestErr := client.FindByCorrelation(ctx, connection.CorrelationField, correlation)
postAttempted := false
if requestErr == nil && ticket == nil && wasUncertain {
- _ = s.repo.MarkTicketLinkUncertain(link.ID, "Zammad ticket creation outcome is uncertain; retry only searches by correlation key")
+ if err := s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, "Zammad ticket creation outcome is uncertain; retry only searches by correlation key"); err != nil {
+ return nil, s.mapZammadSyncClaimError(err)
+ }
return s.repo.GetTicketLink(link.ID)
}
if requestErr == nil && ticket == nil {
@@ -912,33 +1170,40 @@ func (s *ZammadService) CreateTicket(ctx context.Context, itemID, actorID int, r
if body == "" {
body = title
}
+ if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil {
+ return nil, err
+ }
ticket, requestErr = client.CreateTicket(ctx, title, body, connection.DefaultCustomer,
groupName, connection.CorrelationField, correlation)
}
if requestErr != nil {
safeError := RedactString(requestErr.Error())
- if postAttempted && zammadCreationOutcomeUncertain(requestErr) {
- _ = s.repo.MarkTicketLinkUncertain(link.ID, safeError)
+ var markErr error
+ if wasUncertain || (postAttempted && zammadCreationOutcomeUncertain(requestErr)) {
+ markErr = s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, safeError)
} else {
- _ = s.repo.MarkTicketLinkFailed(link.ID, safeError)
+ markErr = s.repo.MarkTicketLinkFailed(link.ID, syncOwner, safeError)
+ }
+ if markErr != nil {
+ return nil, s.mapZammadSyncClaimError(markErr)
}
return nil, requestErr
}
- if ticket.GroupID == 0 && !postAttempted {
+ if ticket.GroupID == 0 {
ticket, requestErr = client.GetTicket(ctx, ticket.ID)
if requestErr != nil {
- _ = s.repo.MarkTicketLinkUncertain(link.ID, RedactString(requestErr.Error()))
+ if err := s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, RedactString(requestErr.Error())); err != nil {
+ return nil, s.mapZammadSyncClaimError(err)
+ }
return nil, requestErr
}
}
ticketGroupID := ticket.GroupID
- if ticketGroupID == 0 {
- ticketGroupID = groupID
- ticket.GroupID = groupID
- }
allowedGroup, groupErr := s.requireAllowedTicketGroup(ctx, connection, client, ticket, groups)
if groupErr != nil {
- _ = s.repo.MarkTicketLinkUncertain(link.ID, RedactString(groupErr.Error()))
+ if err := s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, RedactString(groupErr.Error())); err != nil {
+ return nil, s.mapZammadSyncClaimError(err)
+ }
return nil, groupErr
}
statusName := ticket.StateName
@@ -950,12 +1215,17 @@ func (s *ZammadService) CreateTicket(ctx context.Context, itemID, actorID int, r
ticketGroupName = allowedGroup.Name
}
ticketURL := connection.BaseURL + "/#ticket/zoom/" + fmt.Sprintf("%d", ticket.ID)
- if err := s.repo.CompleteTicketCreation(link.ID, ticket.ID, ticket.Number, ticketURL,
+ if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil {
+ return nil, err
+ }
+ if err := s.completeTicketCreationWithCurrentGroupPolicy(link.ProviderID, link.ID, syncOwner, ticket.ID, ticket.Number, ticketURL,
ticket.StateID, statusName, ticketGroupID, ticketGroupName, ticket.OwnerID, ticket.OwnerName, actorID); err != nil {
// The remote ticket is known to exist. Keep retries search-only until
// the durable local association has been completed.
- _ = s.repo.MarkTicketLinkUncertain(link.ID, RedactString(err.Error()))
- return nil, err
+ if markErr := s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, RedactString(err.Error())); markErr != nil {
+ return nil, s.mapZammadSyncClaimError(markErr)
+ }
+ return nil, s.mapZammadSyncClaimError(err)
}
return s.repo.GetTicketLink(link.ID)
}
@@ -965,22 +1235,25 @@ func validateZammadMetadata(connection *models.ZammadConnection, metadata *model
for _, group := range metadata.Groups {
activeGroups[group.ID] = group.Name
}
+ defaultGroupID := connection.DefaultGroupID
if connection.DefaultGroupID > 0 {
if _, ok := activeGroups[connection.DefaultGroupID]; !ok {
return zammadValidationError("default Zammad group is missing or inactive")
}
} else if connection.DefaultGroupName != "" {
- found := false
- for _, name := range activeGroups {
+ for groupID, name := range activeGroups {
if name == connection.DefaultGroupName {
- found = true
+ defaultGroupID = groupID
break
}
}
- if !found {
+ if defaultGroupID == 0 {
return zammadValidationError("default Zammad group is missing or inactive")
}
}
+ if len(connection.AllowedGroupIDs) > 0 && !slices.Contains(connection.AllowedGroupIDs, defaultGroupID) {
+ return zammadValidationError("default Zammad group must be included in allowed_group_ids")
+ }
for _, groupID := range connection.AllowedGroupIDs {
if _, ok := activeGroups[groupID]; !ok {
return zammadValidationError(fmt.Sprintf("allowed Zammad group %d is missing or inactive", groupID))
@@ -1009,8 +1282,26 @@ func (s *ZammadService) UpdateTicketLink(ctx context.Context, linkID string, req
if err != nil {
return nil, err
}
- if link.TicketID <= 0 {
- return nil, zammadValidationError("Zammad ticket has not been created")
+ if link.TicketID <= 0 || link.ItemIntegrationLinkID == "" {
+ return nil, zammadValidationError("Zammad ticket link setup is incomplete")
+ }
+ syncOwner := uuid.New().String()
+ claimed, err := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration))
+ if err != nil {
+ return nil, err
+ }
+ if !claimed {
+ return nil, ErrZammadConnectionBusy
+ }
+ defer func() { _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) }()
+ // The caller's snapshot predates the claim. Reload after winning it so a
+ // completed closed-state episode cannot be replayed from stale state.
+ link, err = s.repo.GetTicketLink(link.ID)
+ if err != nil {
+ return nil, err
+ }
+ if link.TicketID <= 0 || link.ItemIntegrationLinkID == "" {
+ return nil, zammadValidationError("Zammad ticket link setup is incomplete")
}
item, err := repository.NewItemRepository(s.db).FindByIDWithDetails(link.ItemID)
if err != nil {
@@ -1057,11 +1348,25 @@ func (s *ZammadService) UpdateTicketLink(ctx context.Context, linkID string, req
}
}
+ if s.updateBeforeRemoteWrite != nil {
+ s.updateBeforeRemoteWrite()
+ }
+ if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil {
+ return nil, err
+ }
+ currentConnection, err := s.repo.GetConnection(link.ProviderID)
+ if err != nil {
+ return nil, err
+ }
+ effectiveGroupName := zammadGroupName(metadata, effectiveGroupID, current.GroupName)
+ if !currentConnection.Enabled || !zammadConnectionAllowsGroupSnapshot(currentConnection, effectiveGroupID, effectiveGroupName) {
+ return nil, ErrZammadTicketGroupPolicyChanged
+ }
updated, err := client.UpdateTicket(ctx, link.TicketID, req.StateID, req.GroupID, ownerID, "", "")
if err != nil {
return nil, err
}
- if err := s.persistTicketSnapshot(ctx, link, connection, client, updated, metadata); err != nil {
+ if err := s.persistTicketSnapshot(ctx, link, client, updated, metadata, syncOwner); err != nil {
return nil, err
}
return s.repo.GetTicketLink(link.ID)
@@ -1076,7 +1381,22 @@ func (s *ZammadService) UnlinkTicket(ctx context.Context, linkID string) (*model
if err != nil {
return nil, err
}
- if link.TicketID > 0 {
+ syncOwner := uuid.New().String()
+ claimed, err := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration))
+ if err != nil {
+ return nil, err
+ }
+ if !claimed {
+ return nil, ErrZammadConnectionBusy
+ }
+ defer func() { _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) }()
+ link, err = s.repo.GetTicketLink(link.ID)
+ if err != nil {
+ return nil, err
+ }
+ needsCorrelationLookup := link.TicketID == 0 &&
+ (link.SyncState == models.ZammadSyncCreating || link.SyncState == models.ZammadSyncUncertain)
+ if link.TicketID > 0 || needsCorrelationLookup {
item, err := repository.NewItemRepository(s.db).FindByIDWithDetails(link.ItemID)
if err != nil {
return nil, err
@@ -1085,27 +1405,45 @@ func (s *ZammadService) UnlinkTicket(ctx context.Context, linkID string) (*model
if err != nil {
return nil, err
}
- ticket, getErr := client.GetTicket(ctx, link.TicketID)
- if getErr != nil {
+ var ticket *zammad.Ticket
+ var getErr error
+ if link.TicketID > 0 {
+ ticket, getErr = client.GetTicket(ctx, link.TicketID)
+ } else {
+ ticket, getErr = client.FindByCorrelation(ctx, connection.CorrelationField, link.CorrelationKey)
+ }
+ switch {
+ case getErr != nil:
var apiErr *zammad.APIError
- if !errors.As(getErr, &apiErr) || apiErr.StatusCode != 404 {
+ if link.TicketID == 0 || !errors.As(getErr, &apiErr) || apiErr.StatusCode != 404 {
return nil, getErr
}
- } else {
+ case ticket == nil:
+ // An empty search cannot prove that an ambiguously created ticket does
+ // not exist. Keep the pinned correlation so a later search or explicit
+ // administrator decision can recover it safely.
+ return nil, zammadValidationError("cannot safely unlink an uncertain Zammad ticket while correlation search is empty")
+ default:
remoteCorrelation, err := zammadTicketAttributeString(ticket, connection.CorrelationField)
if err != nil {
return nil, err
}
if remoteCorrelation == link.CorrelationKey {
+ if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil {
+ return nil, err
+ }
if _, err := client.UpdateTicket(ctx, ticket.ID, nil, nil, nil, connection.CorrelationField, ""); err != nil {
return nil, err
}
}
}
}
- if err := s.repo.DeleteTicketLink(link.ID); err != nil {
+ if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil {
return nil, err
}
+ if err := s.repo.DeleteTicketLinkClaimed(link.ID, syncOwner); err != nil {
+ return nil, s.mapZammadSyncClaimError(err)
+ }
return link, nil
}
@@ -1114,17 +1452,18 @@ func (s *ZammadService) SyncTicketLink(ctx context.Context, linkID string) (*mod
if err != nil {
return nil, err
}
- if link.TicketID == 0 {
- return nil, zammadValidationError("Zammad ticket has not been created")
+ if link.TicketID == 0 || link.ItemIntegrationLinkID == "" {
+ return nil, zammadValidationError("Zammad ticket link setup is incomplete")
}
- claimed, err := s.repo.ClaimSync(link.ID, time.Now().Add(2*time.Minute))
+ syncOwner := uuid.New().String()
+ claimed, err := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration))
if err != nil {
return nil, err
}
if !claimed {
- return link, nil
+ return nil, ErrZammadConnectionBusy
}
- return s.syncClaimedTicketLink(ctx, link)
+ return s.syncClaimedTicketLink(ctx, link, syncOwner)
}
// RetryUncertainTicketCreation is an explicit administrator override after
@@ -1147,30 +1486,45 @@ func (s *ZammadService) RetryUncertainTicketCreation(ctx context.Context, linkID
})
}
-func (s *ZammadService) syncClaimedTicketLink(ctx context.Context, link *models.ZammadTicketLink) (*models.ZammadTicketLink, error) {
+func (s *ZammadService) syncClaimedTicketLink(ctx context.Context, link *models.ZammadTicketLink, syncOwner string) (*models.ZammadTicketLink, error) {
+ defer func() { _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) }()
+ // The due-list/manual-refresh snapshot predates the sync claim. Reload after
+ // winning the claim so completion_applied and other idempotency state cannot
+ // be replayed from a stale worker snapshot.
+ current, err := s.repo.GetTicketLink(link.ID)
+ if err != nil {
+ return nil, err
+ }
+ link = current
item, itemErr := repository.NewItemRepository(s.db).FindByID(link.ItemID)
if itemErr != nil {
- s.recordZammadSyncError(link, itemErr)
+ s.recordZammadSyncError(link, syncOwner, itemErr)
return nil, itemErr
}
connection, client, err := s.client(ctx, link.ProviderID, item.WorkspaceID)
if err != nil {
- s.recordZammadSyncError(link, err)
+ if errors.Is(err, ErrZammadOAuthRefreshInProgress) {
+ _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner)
+ return nil, err
+ }
+ s.recordZammadSyncError(link, syncOwner, err)
return nil, err
}
if !connection.Enabled {
- _ = s.repo.UpdateTicketLinkSync(link.ID, link.LastStatusID, link.LastStatusName,
- link.GroupID, link.GroupName, link.OwnerID, link.OwnerName, "", time.Now(), false, false)
+ if err := s.repo.UpdateTicketLinkSync(link.ID, syncOwner, link.LastStatusID, link.LastStatusName,
+ link.GroupID, link.GroupName, link.OwnerID, link.OwnerName, "", time.Now(), false, false); err != nil {
+ return nil, s.mapZammadSyncClaimError(err)
+ }
return s.repo.GetTicketLink(link.ID)
}
ticket, err := client.GetTicket(ctx, link.TicketID)
if err != nil {
- s.recordZammadSyncError(link, err)
+ s.recordZammadSyncError(link, syncOwner, err)
return nil, err
}
allowedGroup, err := s.requireAllowedTicketGroup(ctx, connection, client, ticket, nil)
if err != nil {
- s.recordZammadSyncError(link, err)
+ s.recordZammadSyncError(link, syncOwner, err)
return nil, err
}
if ticket.GroupName == "" {
@@ -1182,23 +1536,38 @@ func (s *ZammadService) syncClaimedTicketLink(ctx context.Context, link *models.
if needsStateName || needsGroupName {
metadata, err = client.Metadata(ctx)
if err != nil {
- s.recordZammadSyncError(link, err)
+ s.recordZammadSyncError(link, syncOwner, err)
return nil, err
}
}
- if err := s.persistTicketSnapshot(ctx, link, connection, client, ticket, metadata); err != nil {
+ if err := s.persistTicketSnapshot(ctx, link, client, ticket, metadata, syncOwner); err != nil {
return nil, err
}
return s.repo.GetTicketLink(link.ID)
}
-func (s *ZammadService) recordZammadSyncError(link *models.ZammadTicketLink, err error) {
- _ = s.repo.UpdateTicketLinkSync(link.ID, link.LastStatusID, link.LastStatusName,
+func (s *ZammadService) recordZammadSyncError(link *models.ZammadTicketLink, syncOwner string, err error) {
+ _ = s.repo.UpdateTicketLinkSync(link.ID, syncOwner, link.LastStatusID, link.LastStatusName,
link.GroupID, link.GroupName, link.OwnerID, link.OwnerName,
RedactString(err.Error()), time.Now(), false, false)
}
-func (s *ZammadService) persistTicketSnapshot(ctx context.Context, link *models.ZammadTicketLink, connection *models.ZammadConnection, client *zammad.Client, ticket *zammad.Ticket, metadata *models.ZammadConnectionMetadata) error {
+func (s *ZammadService) persistTicketSnapshot(ctx context.Context, link *models.ZammadTicketLink, client *zammad.Client, ticket *zammad.Ticket, metadata *models.ZammadConnectionMetadata, syncOwner string) error {
+ if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil {
+ return err
+ }
+ // A lease can expire without being taken over. Configuration may have
+ // changed during that gap, so reload both the idempotency state and policy
+ // only after renewal has made the operation exclusive again.
+ currentLink, err := s.repo.GetTicketLink(link.ID)
+ if err != nil {
+ return err
+ }
+ link = currentLink
+ connection, err := s.repo.GetConnection(link.ProviderID)
+ if err != nil {
+ return err
+ }
statusName := zammadStateName(metadata, ticket.StateID, ticket.StateName)
if statusName == "" && ticket.StateID == link.LastStatusID {
statusName = link.LastStatusName
@@ -1207,23 +1576,147 @@ func (s *ZammadService) persistTicketSnapshot(ctx context.Context, link *models.
if groupName == "" && ticket.GroupID == link.GroupID {
groupName = link.GroupName
}
+ if !zammadConnectionAllowsGroupSnapshot(connection, ticket.GroupID, groupName) {
+ s.recordZammadSyncError(link, syncOwner, ErrZammadTicketGroupPolicyChanged)
+ return ErrZammadTicketGroupPolicyChanged
+ }
ownerName := resolveZammadOwnerName(ctx, client, ticket)
isClosed := slices.Contains(connection.ClosedStateIDs, ticket.StateID)
completionApplied := false
if connection.CompletionStatusID != nil && isClosed && !link.CompletionApplied {
- if err := s.completeWindshiftItem(ctx, link, connection); err != nil {
- safeError := RedactString(err.Error())
- _ = s.repo.UpdateTicketLinkSync(link.ID, ticket.StateID, statusName,
- ticket.GroupID, groupName, ticket.OwnerID, ownerName,
- safeError, time.Now(), false, false)
+ if s.completionBeforeTransition != nil {
+ s.completionBeforeTransition()
+ }
+ // Renew and reload unconditionally at the last boundary before the local
+ // side effect. A worker may have been paused long enough for takeover or
+ // for policy changes even without the test hook being installed.
+ if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil {
+ return err
+ }
+ link, err = s.repo.GetTicketLink(link.ID)
+ if err != nil {
+ return err
+ }
+ connection, err = s.repo.GetConnection(link.ProviderID)
+ if err != nil {
return err
}
- completionApplied = true
+ if !zammadConnectionAllowsGroupSnapshot(connection, ticket.GroupID, groupName) {
+ s.recordZammadSyncError(link, syncOwner, ErrZammadTicketGroupPolicyChanged)
+ return ErrZammadTicketGroupPolicyChanged
+ }
+ isClosed = slices.Contains(connection.ClosedStateIDs, ticket.StateID)
+ if connection.CompletionStatusID != nil && isClosed && !link.CompletionApplied {
+ completionCommitted, completionErr := s.completeWindshiftItem(ctx, link, connection)
+ if completionErr != nil {
+ safeError := RedactString(completionErr.Error())
+ _ = s.updateTicketLinkSyncWithCurrentGroupPolicy(link.ProviderID, link.ID, syncOwner, ticket.StateID, statusName,
+ ticket.GroupID, groupName, ticket.OwnerID, ownerName,
+ safeError, time.Now(), completionCommitted, completionCommitted)
+ return completionErr
+ }
+ completionApplied = true
+ }
}
setCompletionApplied := !isClosed || completionApplied
- return s.repo.UpdateTicketLinkSync(link.ID, ticket.StateID, statusName,
+ err = s.updateTicketLinkSyncWithCurrentGroupPolicy(link.ProviderID, link.ID, syncOwner, ticket.StateID, statusName,
ticket.GroupID, groupName, ticket.OwnerID, ownerName,
"", time.Now(), setCompletionApplied, completionApplied)
+ if errors.Is(err, ErrZammadTicketGroupPolicyChanged) {
+ s.recordZammadSyncError(link, syncOwner, err)
+ }
+ return s.mapZammadSyncClaimError(err)
+}
+
+func (s *ZammadService) updateTicketLinkSyncWithCurrentGroupPolicy(providerID, linkID, syncOwner string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName, safeError string, now time.Time, setCompletionApplied, completionApplied bool) error {
+ if s.persistBeforeLock != nil {
+ s.persistBeforeLock()
+ }
+ return database.WithTx(s.db, func(tx database.Tx) error {
+ if err := s.repo.LockConnectionTx(tx, providerID); err != nil {
+ return err
+ }
+ if err := s.requireCurrentZammadGroupTx(tx, providerID, groupID, groupName); err != nil {
+ return err
+ }
+ return s.repo.UpdateTicketLinkSyncTx(tx, linkID, syncOwner, statusID, statusName, groupID, groupName,
+ ownerID, ownerName, safeError, now, setCompletionApplied, completionApplied)
+ })
+}
+
+func (s *ZammadService) renewZammadSyncClaim(linkID, syncOwner string) error {
+ renewed, err := s.repo.RenewSyncClaim(linkID, syncOwner, time.Now().Add(zammadSyncLeaseDuration))
+ if err != nil {
+ return s.mapZammadSyncClaimError(err)
+ }
+ if !renewed {
+ return ErrZammadConnectionBusy
+ }
+ return nil
+}
+
+func (s *ZammadService) mapZammadSyncClaimError(err error) error {
+ if errors.Is(err, repository.ErrConcurrentUpdate) {
+ return ErrZammadConnectionBusy
+ }
+ return err
+}
+
+func (s *ZammadService) failZammadTicketCreationBeforePost(linkID, syncOwner string, wasUncertain bool, cause error) error {
+ var err error
+ if wasUncertain {
+ err = s.repo.MarkTicketLinkUncertain(linkID, syncOwner, RedactString(cause.Error()))
+ } else {
+ err = s.repo.MarkTicketLinkFailed(linkID, syncOwner, RedactString(cause.Error()))
+ }
+ if err != nil {
+ return s.mapZammadSyncClaimError(err)
+ }
+ return cause
+}
+
+func (s *ZammadService) completeTicketCreationWithCurrentGroupPolicy(providerID, linkID, syncOwner string, ticketID int, number, ticketURL string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName string, linkedBy int) error {
+ if s.persistBeforeLock != nil {
+ s.persistBeforeLock()
+ }
+ return database.WithTx(s.db, func(tx database.Tx) error {
+ if err := s.repo.LockConnectionTx(tx, providerID); err != nil {
+ return err
+ }
+ if err := s.requireCurrentZammadGroupTx(tx, providerID, groupID, groupName); err != nil {
+ return err
+ }
+ return s.repo.CompleteTicketCreationTx(tx, linkID, syncOwner, ticketID, number, ticketURL, statusID, statusName,
+ groupID, groupName, ownerID, ownerName, linkedBy)
+ })
+}
+
+func (s *ZammadService) completeExistingTicketLinkWithCurrentGroupPolicy(link *models.ZammadTicketLink, syncOwner string, linkedBy int) error {
+ if s.persistBeforeLock != nil {
+ s.persistBeforeLock()
+ }
+ return database.WithTx(s.db, func(tx database.Tx) error {
+ if err := s.repo.LockConnectionTx(tx, link.ProviderID); err != nil {
+ return err
+ }
+ if err := s.requireCurrentZammadGroupTx(tx, link.ProviderID, link.GroupID, link.GroupName); err != nil {
+ return err
+ }
+ return s.repo.CompleteExistingTicketLinkTx(tx, link.ID, syncOwner, link.ID+"-external", link, linkedBy)
+ })
+}
+
+func (s *ZammadService) requireCurrentZammadGroupTx(tx database.Tx, providerID string, groupID int, groupName string) error {
+ defaultGroupID, defaultGroupName, allowedGroupIDs, err := s.repo.ConnectionGroupPolicyTx(tx, providerID)
+ if err != nil {
+ return err
+ }
+ if !zammadConnectionAllowsGroupSnapshot(&models.ZammadConnection{
+ DefaultGroupID: defaultGroupID, DefaultGroupName: defaultGroupName, AllowedGroupIDs: allowedGroupIDs,
+ }, groupID, groupName) {
+ return ErrZammadTicketGroupPolicyChanged
+ }
+ return nil
}
func resolveZammadOwnerName(ctx context.Context, client *zammad.Client, ticket *zammad.Ticket) string {
@@ -1251,8 +1744,15 @@ func resolveZammadOwnerName(ctx context.Context, client *zammad.Client, ticket *
func allowedZammadGroups(connection *models.ZammadConnection, groups []models.ZammadGroup) []models.ZammadGroup {
allowed := make([]models.ZammadGroup, 0, len(groups))
for _, group := range groups {
- if slices.Contains(connection.AllowedGroupIDs, group.ID) ||
- (len(connection.AllowedGroupIDs) == 0 && (group.ID == connection.DefaultGroupID || group.Name == connection.DefaultGroupName)) {
+ isAllowed := slices.Contains(connection.AllowedGroupIDs, group.ID)
+ if len(connection.AllowedGroupIDs) == 0 {
+ if connection.DefaultGroupID > 0 {
+ isAllowed = group.ID == connection.DefaultGroupID
+ } else {
+ isAllowed = group.Name == connection.DefaultGroupName
+ }
+ }
+ if isAllowed {
allowed = append(allowed, group)
}
}
@@ -1272,32 +1772,14 @@ func (s *ZammadService) requireAllowedTicketGroup(ctx context.Context, connectio
if ticket == nil || ticket.GroupID <= 0 {
return models.ZammadGroup{}, zammadValidationError("Zammad ticket group is missing or invalid")
}
- if len(knownGroups) > 0 {
- return allowedZammadGroup(connection, knownGroups, ticket.GroupID)
- }
- if len(connection.AllowedGroupIDs) > 0 {
- if slices.Contains(connection.AllowedGroupIDs, ticket.GroupID) {
- return models.ZammadGroup{ID: ticket.GroupID, Name: ticket.GroupName, Active: true}, nil
- }
- return models.ZammadGroup{}, zammadValidationError("Zammad ticket group is not allowed for this connection")
- }
- if connection.DefaultGroupID > 0 {
- if connection.DefaultGroupID == ticket.GroupID {
- return models.ZammadGroup{ID: ticket.GroupID, Name: ticket.GroupName, Active: true}, nil
- }
- return models.ZammadGroup{}, zammadValidationError("Zammad ticket group is not allowed for this connection")
- }
- if ticket.GroupName != "" {
- if ticket.GroupName == connection.DefaultGroupName {
- return models.ZammadGroup{ID: ticket.GroupID, Name: ticket.GroupName, Active: true}, nil
+ if knownGroups == nil {
+ var err error
+ knownGroups, err = client.Groups(ctx)
+ if err != nil {
+ return models.ZammadGroup{}, err
}
- return models.ZammadGroup{}, zammadValidationError("Zammad ticket group is not allowed for this connection")
- }
- groups, err := client.Groups(ctx)
- if err != nil {
- return models.ZammadGroup{}, err
}
- return allowedZammadGroup(connection, groups, ticket.GroupID)
+ return allowedZammadGroup(connection, knownGroups, ticket.GroupID)
}
func zammadCreationOutcomeUncertain(err error) bool {
@@ -1371,38 +1853,39 @@ func (s *ZammadService) SyncDue(ctx context.Context, limit int) error {
}
var firstError error
for _, link := range links {
- claimed, claimErr := s.repo.ClaimSync(link.ID, time.Now().Add(2*time.Minute))
+ syncOwner := uuid.New().String()
+ claimed, claimErr := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration))
if claimErr != nil || !claimed {
if claimErr != nil && firstError == nil {
firstError = claimErr
}
continue
}
- if _, syncErr := s.syncClaimedTicketLink(ctx, link); syncErr != nil && firstError == nil {
+ if _, syncErr := s.syncClaimedTicketLink(ctx, link, syncOwner); syncErr != nil && firstError == nil {
firstError = syncErr
}
}
return firstError
}
-func (s *ZammadService) completeWindshiftItem(ctx context.Context, link *models.ZammadTicketLink, connection *models.ZammadConnection) error {
+func (s *ZammadService) completeWindshiftItem(ctx context.Context, link *models.ZammadTicketLink, connection *models.ZammadConnection) (bool, error) {
if connection.CompletionStatusID == nil {
- return nil
+ return false, nil
}
if connection.CreatedBy == nil {
- return errors.New("configured Zammad actor no longer exists")
+ return false, errors.New("configured Zammad actor no longer exists")
}
itemRepo := repository.NewItemRepository(s.db)
item, err := itemRepo.FindByIDWithDetails(link.ItemID)
if err != nil {
- return err
+ return false, err
}
allowed, err := s.permission.HasWorkspacePermission(*connection.CreatedBy, item.WorkspaceID, models.PermissionItemEdit)
if err != nil {
- return err
+ return false, err
}
if !allowed {
- return errors.New("configured Zammad actor no longer has item edit permission")
+ return false, errors.New("configured Zammad actor no longer has item edit permission")
}
result, err := s.workflow.PerformTransition(ctx, PerformTransitionRequest{
ItemID: link.ItemID, ToStatusID: *connection.CompletionStatusID,
@@ -1414,13 +1897,13 @@ func (s *ZammadService) completeWindshiftItem(ctx context.Context, link *models.
return metadata
}(),
}, itemRepo, s.condition, s.approval)
- if err != nil {
- return err
+ if err != nil && result == nil {
+ return false, err
}
if !result.NoOp && s.events != nil {
s.events.EmitStatusChanged(result.Item, result.OldStatusID, result.NewStatusID, *connection.CreatedBy, "Zammad")
}
- return nil
+ return true, err
}
func (s *ZammadService) client(ctx context.Context, id string, workspaceID int) (*models.ZammadConnection, *zammad.Client, error) {
@@ -1549,6 +2032,12 @@ func validateZammadConnection(connection *models.ZammadConnection) error {
if connection.DefaultGroupID < 0 {
return zammadValidationError("default_group_id must be positive")
}
+ if connection.DefaultGroupID == 0 && connection.DefaultGroupName == "" {
+ return zammadValidationError("default_group_id or default_group_name is required")
+ }
+ if connection.DefaultGroupID > 0 && len(connection.AllowedGroupIDs) > 0 && !slices.Contains(connection.AllowedGroupIDs, connection.DefaultGroupID) {
+ return zammadValidationError("default Zammad group must be included in allowed_group_ids")
+ }
if connection.CompletionStatusID != nil && *connection.CompletionStatusID <= 0 {
return zammadValidationError("completion_status_id must be positive")
}
diff --git a/internal/services/zammad_service_test.go b/internal/services/zammad_service_test.go
index 7385ddb45..89075afe8 100644
--- a/internal/services/zammad_service_test.go
+++ b/internal/services/zammad_service_test.go
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "slices"
"strconv"
"strings"
"sync"
@@ -29,7 +30,9 @@ type fakeZammadTransport struct {
ticket map[string]any
postErrorAfterCreate error
postStatusAfterCreate int
+ postResponse map[string]any
putError error
+ searchError error
getStatus int
getTicket map[string]any
hideSearch bool
@@ -37,6 +40,8 @@ type fakeZammadTransport struct {
states []map[string]any
users []map[string]any
putPayloads []map[string]any
+ createGroupID int
+ putRerouteGroupID int
}
func (f *fakeZammadTransport) Do(_ context.Context, method, targetURL string, body []byte, headers map[string]string) (*zammad.Response, error) {
@@ -52,6 +57,11 @@ func (f *fakeZammadTransport) Do(_ context.Context, method, targetURL string, bo
}
switch {
case parsed.Path == "/api/v1/tickets/search":
+ if f.searchError != nil {
+ err := f.searchError
+ f.searchError = nil
+ return nil, err
+ }
if f.ticket == nil || f.hideSearch {
return jsonResponse(http.StatusOK, []any{}), nil
}
@@ -62,8 +72,12 @@ func (f *fakeZammadTransport) Do(_ context.Context, method, targetURL string, bo
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
+ createGroupID := f.createGroupID
+ if createGroupID == 0 {
+ createGroupID = 7
+ }
f.ticket = map[string]any{
- "id": 901, "number": "420901", "group_id": 7,
+ "id": 901, "number": "420901", "group_id": createGroupID,
"state_id": 2, "state": "open",
"windshift_item_key": payload["windshift_item_key"],
}
@@ -77,6 +91,9 @@ func (f *fakeZammadTransport) Do(_ context.Context, method, targetURL string, bo
f.postStatusAfterCreate = 0
return jsonResponse(status, map[string]string{"error": "synthetic create failure"}), nil
}
+ if f.postResponse != nil {
+ return jsonResponse(http.StatusCreated, f.postResponse), nil
+ }
return jsonResponse(http.StatusCreated, f.ticket), nil
case strings.HasPrefix(parsed.Path, "/api/v1/tickets/") && method == http.MethodPut:
f.puts++
@@ -92,6 +109,9 @@ func (f *fakeZammadTransport) Do(_ context.Context, method, targetURL string, bo
for key, value := range payload {
f.ticket[key] = value
}
+ if f.putRerouteGroupID > 0 {
+ f.ticket["group_id"] = f.putRerouteGroupID
+ }
}
return jsonResponse(http.StatusOK, f.ticket), nil
case strings.HasPrefix(parsed.Path, "/api/v1/tickets/"):
@@ -160,8 +180,9 @@ func (allowZammadPermission) HasWorkspacePermission(_, _ int, _ string) (bool, e
}
type fakeZammadWorkflow struct {
- db database.Database
- writes int
+ db database.Database
+ writes int
+ postCommitErr error
}
func (f *fakeZammadWorkflow) PerformTransition(_ context.Context, req PerformTransitionRequest, itemRepo *repository.ItemRepository, _ *ConditionService, _ transitionApprovalService) (*PerformTransitionResult, error) {
@@ -182,7 +203,11 @@ func (f *fakeZammadWorkflow) PerformTransition(_ context.Context, req PerformTra
return nil, err
}
newStatusID := req.ToStatusID
- return &PerformTransitionResult{Item: item, OldStatusID: oldStatusID, NewStatusID: &newStatusID}, nil
+ result := &PerformTransitionResult{Item: item, OldStatusID: oldStatusID, NewStatusID: &newStatusID}
+ if f.postCommitErr != nil {
+ return result, f.postCommitErr
+ }
+ return result, nil
}
type zammadServiceFixture struct {
@@ -310,6 +335,65 @@ func TestZammadLinkExistingTicketPinsExactTicketAndIsIdempotent(t *testing.T) {
}
}
+func TestZammadLinkExistingTicketSetupFailureCannotBePromotedBySync(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.ticket = map[string]any{
+ "id": 715, "number": "420715", "group_id": 7, "group": "Support",
+ "state_id": 2, "state": "open", "owner_id": 1,
+ }
+ f.transport.putError = context.DeadlineExceeded
+
+ _, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{
+ ConnectionID: f.connection.ProviderID, TicketNumber: "420715",
+ })
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected correlation update failure, got %v", err)
+ }
+ reserved, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(reserved) != 1 {
+ t.Fatalf("failed setup did not retain exactly one reservation: links=%#v err=%v", reserved, err)
+ }
+ link := reserved[0]
+ if link.SyncState != models.ZammadSyncCreating || link.ItemIntegrationLinkID != "" || link.LastError == "" {
+ t.Fatalf("failed setup looked complete or lost its safe error: %#v", link)
+ }
+ var genericLinks int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE integration_provider_id = ?", f.connection.ProviderID).Scan(&genericLinks); err != nil || genericLinks != 0 {
+ t.Fatalf("failed setup created a generic integration link: count=%d err=%v", genericLinks, err)
+ }
+
+ f.transport.resetRequests()
+ if err := f.service.SyncDue(context.Background(), 10); err != nil {
+ t.Fatal(err)
+ }
+ requests, _ := f.transport.counts()
+ if requests != 0 {
+ t.Fatalf("scheduler attempted to sync incomplete reservation: requests=%d", requests)
+ }
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil {
+ t.Fatal("manual refresh promoted an incomplete reservation")
+ }
+ closedStateID := 4
+ if _, err := f.service.UpdateTicketLink(context.Background(), link.ID, models.UpdateZammadTicketLinkRequest{StateID: &closedStateID}); err == nil {
+ t.Fatal("ticket edit was allowed before link setup completed")
+ }
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil || stored.SyncState != models.ZammadSyncCreating || stored.ItemIntegrationLinkID != "" {
+ t.Fatalf("sync attempts changed incomplete reservation: link=%#v err=%v", stored, err)
+ }
+
+ f.transport.putError = nil
+ completed, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{
+ ConnectionID: f.connection.ProviderID, TicketNumber: "420715",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if completed.ID != link.ID || completed.SyncState != models.ZammadSyncLinked || completed.ItemIntegrationLinkID == "" || completed.LastError != "" {
+ t.Fatalf("retry did not finish the retained reservation: %#v", completed)
+ }
+}
+
func TestZammadLinkExistingTicketRejectsDisallowedGroupAndConflictingCorrelation(t *testing.T) {
t.Run("disallowed group", func(t *testing.T) {
f := newZammadServiceFixture(t, nil)
@@ -392,6 +476,263 @@ func TestZammadUpdateTicketLinkValidatesOwnerForEffectiveGroupAndPersistsSnapsho
}
}
+func TestZammadTicketSnapshotBlocksConcurrentGroupPolicyNarrowing(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}}
+ initialAllowedGroups := []int{7, 8}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &initialAllowedGroups}); err != nil {
+ t.Fatal(err)
+ }
+
+ arrived := make(chan struct{}, 1)
+ release := make(chan struct{})
+ f.service.SetPersistBeforeConnectionLockForTesting(func() {
+ arrived <- struct{}{}
+ <-release
+ })
+ defer f.service.SetPersistBeforeConnectionLockForTesting(nil)
+
+ newGroupID := 8
+ updateErr := make(chan error, 1)
+ go func() {
+ _, err := f.service.UpdateTicketLink(context.Background(), link.ID, models.UpdateZammadTicketLinkRequest{GroupID: &newGroupID})
+ updateErr <- err
+ }()
+ <-arrived
+ narrowedAllowedGroups := []int{7}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) {
+ t.Fatalf("group policy changed while remote update was pending: %v", err)
+ }
+ close(release)
+ if err := <-updateErr; err != nil {
+ t.Fatalf("remote group move failed after conflicting policy update was rejected: %v", err)
+ }
+
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.GroupID != 8 || stored.SyncState != models.ZammadSyncLinked || stored.LastError != "" {
+ t.Fatalf("rejected policy race prevented the valid snapshot: %#v", stored)
+ }
+ if got := int(f.transport.ticket["group_id"].(float64)); got != 8 {
+ t.Fatalf("test did not move the remote ticket before narrowing: group=%d", got)
+ }
+}
+
+func TestZammadExpiredUpdateLeaseReloadsGroupPolicyBeforeRemoteWrite(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}}
+ initialAllowed := []int{7, 8}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &initialAllowed}); err != nil {
+ t.Fatal(err)
+ }
+ arrived := make(chan struct{}, 1)
+ release := make(chan struct{})
+ f.service.SetUpdateBeforeRemoteWriteForTesting(func() {
+ arrived <- struct{}{}
+ <-release
+ })
+ defer f.service.SetUpdateBeforeRemoteWriteForTesting(nil)
+
+ groupID := 8
+ updateErr := make(chan error, 1)
+ go func() {
+ _, err := f.service.UpdateTicketLink(context.Background(), link.ID, models.UpdateZammadTicketLinkRequest{GroupID: &groupID})
+ updateErr <- err
+ }()
+ <-arrived
+ if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET sync_lock_until = ? WHERE id = ?", time.Now().Add(-time.Minute), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ narrowed := []int{7}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &narrowed}); err != nil {
+ t.Fatalf("group policy could not change after synthetic lease expiry: %v", err)
+ }
+ puts := f.transport.putCount()
+ close(release)
+ if err := <-updateErr; !errors.Is(err, ErrZammadTicketGroupPolicyChanged) {
+ t.Fatalf("expired update worker did not honor current group policy: %v", err)
+ }
+ if f.transport.putCount() != puts {
+ t.Fatalf("expired update worker wrote a group removed by current policy")
+ }
+}
+
+func TestZammadTicketCreationBlocksGroupNarrowingAcrossRemoteReroute(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}}
+ f.transport.createGroupID = 8
+ initialAllowedGroups := []int{7, 8}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &initialAllowedGroups}); err != nil {
+ t.Fatal(err)
+ }
+ arrived := make(chan struct{}, 1)
+ release := make(chan struct{})
+ f.service.SetPersistBeforeConnectionLockForTesting(func() {
+ arrived <- struct{}{}
+ <-release
+ })
+ defer f.service.SetPersistBeforeConnectionLockForTesting(nil)
+
+ type creationResult struct {
+ link *models.ZammadTicketLink
+ err error
+ }
+ result := make(chan creationResult, 1)
+ go func() {
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID, GroupID: 7})
+ result <- creationResult{link: link, err: err}
+ }()
+ <-arrived
+ narrowedAllowedGroups := []int{7}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) {
+ t.Fatalf("group policy changed while rerouted ticket creation was incomplete: %v", err)
+ }
+ close(release)
+ created := <-result
+ if created.err != nil || created.link == nil || created.link.GroupID != 8 {
+ t.Fatalf("ticket creation did not finish under the preserved policy: link=%#v err=%v", created.link, created.err)
+ }
+}
+
+func TestZammadIncompleteReservationAllowsPolicyExpansionAndCorrelationRecovery(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}}
+ f.transport.createGroupID = 8
+ initialAllowedGroups := []int{7}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &initialAllowedGroups}); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ var validationErr *ZammadValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("rerouted ticket unexpectedly completed under the old group policy: %v", err)
+ }
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 1 || links[0].SyncState != models.ZammadSyncUncertain {
+ t.Fatalf("rerouted ticket was not retained for correlation recovery: links=%#v err=%v", links, err)
+ }
+
+ expandedAllowedGroups := []int{7, 8}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &expandedAllowedGroups}); err != nil {
+ t.Fatalf("safe group policy expansion was blocked by incomplete reservation: %v", err)
+ }
+ narrowedAllowedGroups := []int{7}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) {
+ t.Fatalf("group policy narrowing was allowed while reservation was incomplete: %v", err)
+ }
+
+ recovered, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatalf("correlation retry did not recover after policy expansion: %v", err)
+ }
+ if recovered.TicketID != 901 || recovered.GroupID != 8 || recovered.SyncState != models.ZammadSyncLinked {
+ t.Fatalf("correlation retry did not complete the rerouted ticket: %#v", recovered)
+ }
+ _, posts := f.transport.counts()
+ if posts != 1 {
+ t.Fatalf("correlation retry created a duplicate ticket: posts=%d", posts)
+ }
+}
+
+func TestZammadGroupPolicyNarrows(t *testing.T) {
+ currentNumeric := &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7, AllowedGroupIDs: []int{7, 8}}
+ currentNameOnly := &repository.ZammadConnectionMutationSnapshot{DefaultGroupName: "Support"}
+ tests := []struct {
+ name string
+ current *repository.ZammadConnectionMutationSnapshot
+ proposed *models.ZammadConnection
+ narrows bool
+ }{
+ {
+ name: "numeric expansion", current: currentNumeric,
+ proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroupIDs: []int{7, 8, 9}},
+ },
+ {
+ name: "numeric expansion preserves name-only default through allowlist",
+ current: &repository.ZammadConnectionMutationSnapshot{
+ DefaultGroupName: "Support", AllowedGroupIDs: []int{7, 8},
+ },
+ proposed: &models.ZammadConnection{DefaultGroupName: "Support", AllowedGroupIDs: []int{7, 8, 9}},
+ },
+ {
+ name: "numeric narrowing", current: currentNumeric,
+ proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroupIDs: []int{7}}, narrows: true,
+ },
+ {
+ name: "old default remains allowed", current: &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7},
+ proposed: &models.ZammadConnection{DefaultGroupID: 8, AllowedGroupIDs: []int{7, 8}},
+ },
+ {
+ name: "old default excluded", current: &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7},
+ proposed: &models.ZammadConnection{DefaultGroupID: 8, AllowedGroupIDs: []int{8}}, narrows: true,
+ },
+ {
+ name: "same name-only policy", current: currentNameOnly,
+ proposed: &models.ZammadConnection{DefaultGroupName: "Support"},
+ },
+ {
+ name: "name-only changed to IDs", current: currentNameOnly,
+ proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroupIDs: []int{7}}, narrows: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := zammadGroupPolicyNarrows(tt.current, tt.proposed); got != tt.narrows {
+ t.Fatalf("zammadGroupPolicyNarrows() = %v, want %v", got, tt.narrows)
+ }
+ })
+ }
+}
+
+func TestZammadExistingTicketLinkBlocksGroupNarrowingAcrossRemoteReroute(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}}
+ f.transport.ticket = map[string]any{"id": 714, "number": "420714", "group_id": 7, "state_id": 2, "state": "open"}
+ f.transport.putRerouteGroupID = 8
+ initialAllowedGroups := []int{7, 8}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &initialAllowedGroups}); err != nil {
+ t.Fatal(err)
+ }
+ arrived := make(chan struct{}, 1)
+ release := make(chan struct{})
+ f.service.SetPersistBeforeConnectionLockForTesting(func() {
+ arrived <- struct{}{}
+ <-release
+ })
+ defer f.service.SetPersistBeforeConnectionLockForTesting(nil)
+
+ type linkResult struct {
+ link *models.ZammadTicketLink
+ err error
+ }
+ result := make(chan linkResult, 1)
+ go func() {
+ link, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ConnectionID: f.connection.ProviderID, TicketNumber: "420714"})
+ result <- linkResult{link: link, err: err}
+ }()
+ <-arrived
+ narrowedAllowedGroups := []int{7}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) {
+ t.Fatalf("group policy changed while rerouted existing-ticket link was incomplete: %v", err)
+ }
+ close(release)
+ linked := <-result
+ if linked.err != nil || linked.link == nil || linked.link.GroupID != 8 {
+ t.Fatalf("existing ticket link did not finish under the preserved policy: link=%#v err=%v", linked.link, linked.err)
+ }
+}
+
func TestZammadUnlinkClearsOnlyExactRemoteCorrelationWithoutDeletingTicket(t *testing.T) {
f := newZammadServiceFixture(t, nil)
link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
@@ -443,6 +784,53 @@ func TestZammadUnlinkDoesNotClearAnotherCorrelation(t *testing.T) {
}
}
+func TestZammadUnlinkUncertainCreationResolvesExactCorrelationBeforeDelete(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.postErrorAfterCreate = context.DeadlineExceeded
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected ambiguous create timeout, got %v", err)
+ }
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 1 || links[0].TicketID != 0 || links[0].SyncState != models.ZammadSyncUncertain {
+ t.Fatalf("ambiguous create reservation missing: links=%#v err=%v", links, err)
+ }
+ if _, err := f.service.UnlinkTicket(context.Background(), links[0].ID); err != nil {
+ t.Fatalf("uncertain unlink did not resolve the exact remote correlation: %v", err)
+ }
+ if f.transport.ticket["windshift_item_key"] != "" {
+ t.Fatalf("uncertain unlink left the resolved remote correlation: ticket=%#v", f.transport.ticket)
+ }
+ if _, err := f.service.GetTicketLink(links[0].ID); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("resolved uncertain reservation remained local: %v", err)
+ }
+}
+
+func TestZammadUnlinkUncertainCreationPreservesReservationWhenSearchIsEmpty(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.postErrorAfterCreate = context.DeadlineExceeded
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected ambiguous create timeout, got %v", err)
+ }
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 1 {
+ t.Fatalf("ambiguous create reservation missing: links=%#v err=%v", links, err)
+ }
+ correlation := links[0].CorrelationKey
+ f.transport.hideSearch = true
+ if _, err := f.service.UnlinkTicket(context.Background(), links[0].ID); err == nil {
+ t.Fatal("uncertain unlink discarded an unresolved correlation")
+ }
+ stored, err := f.service.GetTicketLink(links[0].ID)
+ if err != nil || stored.SyncState != models.ZammadSyncUncertain {
+ t.Fatalf("empty correlation search removed uncertain reservation: link=%#v err=%v", stored, err)
+ }
+ if f.transport.ticket["windshift_item_key"] != correlation {
+ t.Fatalf("empty search changed remote correlation: ticket=%#v", f.transport.ticket)
+ }
+}
+
func TestZammadCreateTicketMarksLocalCompletionFailureUncertainAndKeepsCorrelationPinned(t *testing.T) {
f := newZammadServiceFixture(t, nil)
if _, err := f.db.ExecWrite(`CREATE TRIGGER fail_zammad_ticket_completion
@@ -607,7 +995,12 @@ func TestZammadDueLinksRespectRetryDelayAndOAuthReauthorization(t *testing.T) {
t.Fatal(err)
}
attemptedAt := time.Now()
- if err := f.service.repo.UpdateTicketLinkSync(link.ID, link.LastStatusID, link.LastStatusName,
+ syncOwner := "retry-delay-test"
+ claimed, err := f.service.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("could not claim retry-delay test link: claimed=%v err=%v", claimed, err)
+ }
+ if err := f.service.repo.UpdateTicketLinkSync(link.ID, syncOwner, link.LastStatusID, link.LastStatusName,
link.GroupID, link.GroupName, link.OwnerID, link.OwnerName,
"synthetic safe failure", attemptedAt, false, false); err != nil {
t.Fatal(err)
@@ -686,6 +1079,101 @@ func TestZammadAmbiguousCreateNeverPostsAgainWhileSearchIsEmpty(t *testing.T) {
}
}
+func TestZammadUncertainCreateSurvivesTransientCorrelationSearchFailure(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.postErrorAfterCreate = context.DeadlineExceeded
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected ambiguous initial timeout, got %v", err)
+ }
+ f.transport.searchError = context.DeadlineExceeded
+ if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected transient correlation-search failure, got %v", err)
+ }
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 1 || links[0].SyncState != models.ZammadSyncUncertain {
+ t.Fatalf("search failure downgraded uncertain creation: links=%#v err=%v", links, err)
+ }
+ f.transport.hideSearch = true
+ retried, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, posts := f.transport.counts()
+ if posts != 1 || retried.SyncState != models.ZammadSyncUncertain {
+ t.Fatalf("search failure enabled a duplicate POST: posts=%d link=%#v", posts, retried)
+ }
+}
+
+func TestZammadStaleCreatingLeaseRetriesSearchOnly(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ item, err := repository.NewItemRepository(f.db).FindByIDWithDetails(f.item1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ link := &models.ZammadTicketLink{
+ ID: "stale-create-link", ItemID: item.ID, ProviderID: f.connection.ProviderID,
+ GroupID: 7, GroupName: "Support", CorrelationKey: "windshift:stale-create:PRI-49",
+ SyncState: models.ZammadSyncPending, CreatedBy: &f.actorID,
+ }
+ if err := f.service.reserveZammadTicketLink(f.connection, item, link, false); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite(`UPDATE zammad_ticket_links
+ SET sync_state = ?, creating_started_at = ?, sync_lock_until = ?, sync_lock_owner = ?
+ WHERE id = ?`, models.ZammadSyncCreating, time.Now().Add(-3*time.Minute), time.Now().Add(-time.Minute), "crashed-worker", link.ID); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.hideSearch = true
+ retried, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, posts := f.transport.counts()
+ if posts != 0 || retried.SyncState != models.ZammadSyncUncertain {
+ t.Fatalf("stale in-flight creation retried POST: posts=%d link=%#v", posts, retried)
+ }
+}
+
+func TestZammadCreateVerifiesGroupFromTicketDetailAfterPartialResponse(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ getTicket map[string]any
+ wantGroup int
+ }{
+ {
+ name: "remote reroute is rejected",
+ getTicket: map[string]any{"id": 901, "number": "420901", "group_id": 8, "state_id": 2, "state": "open"},
+ wantGroup: 8,
+ },
+ {
+ name: "missing detail group is rejected",
+ getTicket: map[string]any{"id": 901, "number": "420901", "state_id": 2, "state": "open"},
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}}
+ f.transport.createGroupID = tt.wantGroup
+ f.transport.postResponse = map[string]any{"id": 901, "number": "420901", "state_id": 2, "state": "open"}
+ f.transport.getTicket = tt.getTicket
+
+ _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ var validationErr *ZammadValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("partial create response completed without a verified allowed group: %v", err)
+ }
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 1 || links[0].SyncState != models.ZammadSyncUncertain || links[0].ItemIntegrationLinkID != "" {
+ t.Fatalf("unverified create was not retained as uncertain: links=%#v err=%v", links, err)
+ }
+ if got, ok := f.transport.ticket["group_id"].(int); tt.wantGroup > 0 && (!ok || got != tt.wantGroup) {
+ t.Fatalf("test did not model remote reroute: ticket=%#v", f.transport.ticket)
+ }
+ })
+ }
+}
+
func TestZammadRejectsUnconfiguredGroupBeforeCreate(t *testing.T) {
f := newZammadServiceFixture(t, nil)
_, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID, GroupID: 99})
@@ -727,150 +1215,717 @@ func TestZammadWorkspaceScopeBlocksHTTPAndSecretIsEncrypted(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if len(listed) != 0 {
- t.Fatalf("managed Zammad credential leaked into generic list: %#v", listed)
- }
- if _, err := f.credentials.Get(f.connection.CredentialID); !errors.Is(err, repository.ErrNotFound) {
- t.Fatalf("managed Zammad credential was addressable through generic CRUD: %v", err)
- }
- if _, _, err := f.credentials.Resolve(context.Background(), f.connection.CredentialID, f.workspace1); !errors.Is(err, ErrCredentialPurposeMismatch) {
- t.Fatalf("managed Zammad credential resolved through generic action path: %v", err)
+ if len(listed) != 0 {
+ t.Fatalf("managed Zammad credential leaked into generic list: %#v", listed)
+ }
+ if _, err := f.credentials.Get(f.connection.CredentialID); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("managed Zammad credential was addressable through generic CRUD: %v", err)
+ }
+ if _, _, err := f.credentials.Resolve(context.Background(), f.connection.CredentialID, f.workspace1); !errors.Is(err, ErrCredentialPurposeMismatch) {
+ t.Fatalf("managed Zammad credential resolved through generic action path: %v", err)
+ }
+}
+
+func TestZammadConnectionScopeCannotStrandExistingTicketLinks(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ excludedScope := []int{f.workspace2}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{WorkspaceIDs: &excludedScope}); err == nil {
+ t.Fatal("connection scope excluded a workspace containing a linked ticket")
+ }
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil || stored.ItemIntegrationLinkID == "" {
+ t.Fatalf("rejected scope update changed the link: link=%#v err=%v", stored, err)
+ }
+ if _, _, err := f.credentials.ResolveManaged(context.Background(), f.connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), f.connection.ProviderID); err != nil {
+ t.Fatalf("rejected scope update changed credential scope: %v", err)
+ }
+
+ expandedScope := []int{f.workspace1, f.workspace2}
+ updated, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{WorkspaceIDs: &expandedScope})
+ if err != nil || !slices.Contains(updated.WorkspaceIDs, f.workspace2) {
+ t.Fatalf("scope expansion with existing link failed: connection=%#v err=%v", updated, err)
+ }
+}
+
+func TestZammadConnectionRoutingAndGroupsCannotStrandExistingTicketLinks(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err != nil {
+ t.Fatal(err)
+ }
+ newBaseURL := "https://replacement.example.test"
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{BaseURL: &newBaseURL}); err == nil {
+ t.Fatal("base URL changed with an existing ticket link")
+ }
+ newCorrelationField := "replacement_item_key"
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{CorrelationField: &newCorrelationField}); err == nil {
+ t.Fatal("correlation field changed with an existing ticket link")
+ }
+ newDefaultGroupID := 8
+ newDefaultGroupName := "Escalations"
+ newAllowedGroups := []int{8}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
+ DefaultGroupID: &newDefaultGroupID, DefaultGroupName: &newDefaultGroupName, AllowedGroupIDs: &newAllowedGroups,
+ }); err == nil {
+ t.Fatal("group scope excluded an existing ticket link")
+ }
+ stored, err := f.service.GetConnection(f.connection.ProviderID)
+ if err != nil || stored.BaseURL != f.connection.BaseURL || stored.CorrelationField != f.connection.CorrelationField || stored.DefaultGroupID != 7 {
+ t.Fatalf("rejected routing/group updates changed connection: connection=%#v err=%v", stored, err)
+ }
+}
+
+func TestZammadReservationRejectsStaleConnectionAndItemSnapshots(t *testing.T) {
+ t.Run("connection routing", func(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ item, err := repository.NewItemRepository(f.db).FindByIDWithDetails(f.item1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ staleConnection := *f.connection
+ newBaseURL := "https://new-routing.example.test"
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{BaseURL: &newBaseURL}); err != nil {
+ t.Fatal(err)
+ }
+ link := &models.ZammadTicketLink{ID: "stale-routing", ItemID: item.ID, ProviderID: f.connection.ProviderID, GroupID: 7, GroupName: "Support", CorrelationKey: "stale", SyncState: models.ZammadSyncPending, CreatedBy: &f.actorID}
+ if err := f.service.reserveZammadTicketLink(&staleConnection, item, link, false); !errors.Is(err, ErrZammadLinkReservationConflict) {
+ t.Fatalf("stale connection snapshot reserved a link: %v", err)
+ }
+ })
+
+ t.Run("connection group policy", func(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ item, err := repository.NewItemRepository(f.db).FindByIDWithDetails(f.item1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ staleConnection := *f.connection
+ newDefaultGroupID := 8
+ newDefaultGroupName := "Escalations"
+ newAllowedGroups := []int{8}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
+ DefaultGroupID: &newDefaultGroupID, DefaultGroupName: &newDefaultGroupName, AllowedGroupIDs: &newAllowedGroups,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ link := &models.ZammadTicketLink{ID: "stale-group", ItemID: item.ID, ProviderID: f.connection.ProviderID, GroupID: 7, GroupName: "Support", CorrelationKey: "stale", SyncState: models.ZammadSyncPending, CreatedBy: &f.actorID}
+ if err := f.service.reserveZammadTicketLink(&staleConnection, item, link, false); !errors.Is(err, ErrZammadLinkReservationConflict) {
+ t.Fatalf("stale group policy reserved a link: %v", err)
+ }
+ })
+
+ t.Run("connection default customer", func(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ item, err := repository.NewItemRepository(f.db).FindByIDWithDetails(f.item1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ staleConnection := *f.connection
+ newDefaultCustomer := "new-customer@example.test"
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{DefaultCustomer: &newDefaultCustomer}); err != nil {
+ t.Fatal(err)
+ }
+ link := &models.ZammadTicketLink{ID: "stale-customer", ItemID: item.ID, ProviderID: f.connection.ProviderID, GroupID: 7, GroupName: "Support", CorrelationKey: "stale", SyncState: models.ZammadSyncPending, CreatedBy: &f.actorID}
+ if err := f.service.reserveZammadTicketLink(&staleConnection, item, link, false); !errors.Is(err, ErrZammadLinkReservationConflict) {
+ t.Fatalf("stale default customer reserved a link: %v", err)
+ }
+ })
+
+ t.Run("item workspace and key", func(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ item, err := repository.NewItemRepository(f.db).FindByIDWithDetails(f.item1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite("UPDATE items SET workspace_id = ?, workspace_item_number = 2 WHERE id = ?", f.workspace2, f.item1); err != nil {
+ t.Fatal(err)
+ }
+ link := &models.ZammadTicketLink{ID: "stale-item", ItemID: item.ID, ProviderID: f.connection.ProviderID, GroupID: 7, GroupName: "Support", CorrelationKey: "stale", SyncState: models.ZammadSyncPending, CreatedBy: &f.actorID}
+ if err := f.service.reserveZammadTicketLink(f.connection, item, link, false); !errors.Is(err, ErrZammadLinkReservationConflict) {
+ t.Fatalf("stale item snapshot reserved a link: %v", err)
+ }
+ })
+}
+
+func TestZammadOutOfScopeLegacyLinkIsNotExposed(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite("UPDATE items SET workspace_id = ?, workspace_item_number = 2 WHERE id = ?", f.workspace2, f.item1); err != nil {
+ t.Fatal(err)
+ }
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 0 {
+ t.Fatalf("out-of-scope legacy link was exposed: links=%#v err=%v", links, err)
+ }
+}
+
+func TestZammadLinkedItemMoveRequiresDestinationConnectionScope(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var itemTypeID int
+ if err := f.db.QueryRow("SELECT id FROM item_types WHERE hierarchy_level >= 0 ORDER BY id LIMIT 1").Scan(&itemTypeID); err != nil {
+ t.Fatal(err)
+ }
+ input := ItemWorkspaceMoveInput{DestinationWorkspaceID: f.workspace2, TargetItemTypeID: itemTypeID, TargetStatusID: f.openStatus}
+ moveService := NewItemWorkspaceMoveService(f.db)
+ if _, err := moveService.Move(f.item1, f.actorID, input); err == nil {
+ t.Fatal("item moved outside its linked Zammad connection scope")
+ }
+
+ expandedScope := []int{f.workspace1, f.workspace2}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{WorkspaceIDs: &expandedScope}); err != nil {
+ t.Fatal(err)
+ }
+ result, err := moveService.Move(f.item1, f.actorID, input)
+ if err != nil {
+ t.Fatalf("item move inside linked Zammad connection scope failed: %v", err)
+ }
+ if result.Item.WorkspaceID != f.workspace2 {
+ t.Fatalf("item remained in workspace %d", result.Item.WorkspaceID)
+ }
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil || stored.CorrelationKey != link.CorrelationKey {
+ t.Fatalf("allowed item move changed linked ticket identity: link=%#v err=%v", stored, err)
+ }
+}
+
+func TestZammadDeleteConnectionRejectsExistingTicketLinks(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.service.DeleteConnection(f.connection.ProviderID); err == nil {
+ t.Fatal("connection deletion succeeded despite existing ticket link")
+ }
+ var providerCount, credentialCount, linkCount int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM integration_providers WHERE id = ?", f.connection.ProviderID).Scan(&providerCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials WHERE id = ?", f.connection.CredentialID).Scan(&credentialCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_ticket_links WHERE id = ?", link.ID).Scan(&linkCount); err != nil {
+ t.Fatal(err)
+ }
+ if providerCount != 1 || credentialCount != 1 || linkCount != 1 {
+ t.Fatalf("rejected connection delete changed persisted data: providers=%d credentials=%d links=%d", providerCount, credentialCount, linkCount)
+ }
+}
+
+func TestZammadDeleteConnectionWithoutTicketLinksRemovesOwnedCredentialAtomically(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ credentialID := f.connection.CredentialID
+ if err := f.service.DeleteConnection(f.connection.ProviderID); err != nil {
+ t.Fatal(err)
+ }
+ var providerCount, credentialCount int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM integration_providers WHERE id = ?", f.connection.ProviderID).Scan(&providerCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials WHERE id = ?", credentialID).Scan(&credentialCount); err != nil {
+ t.Fatal(err)
+ }
+ if providerCount != 0 || credentialCount != 0 {
+ t.Fatalf("connection delete left data behind: providers=%d credentials=%d", providerCount, credentialCount)
+ }
+}
+
+func TestZammadConnectionUpdateRollsBackManagedCredentialOnProviderConflict(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ other, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "other-helpdesk", Name: "Other helpdesk", BaseURL: "https://other.example.test",
+ APIToken: "other-token", DefaultGroupID: 7, DefaultGroupName: "Support",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ newToken := "must-be-rolled-back"
+ newScope := []int{f.workspace2}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Slug: &other.Slug, APIToken: &newToken, WorkspaceIDs: &newScope}); !errors.Is(err, repository.ErrDuplicateEntry) {
+ t.Fatalf("expected duplicate provider slug, got %v", err)
+ }
+ secret, _, err := f.credentials.ResolveManaged(context.Background(), f.connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), f.connection.ProviderID)
+ if err != nil || secret != "synthetic-zammad-token" {
+ t.Fatalf("managed credential was not restored: secret_matches=%v err=%v", secret == "synthetic-zammad-token", err)
+ }
+ if _, _, err := f.credentials.ResolveManaged(context.Background(), f.connection.CredentialID, f.workspace2, string(models.IntegrationProviderZammad), f.connection.ProviderID); !errors.Is(err, ErrCredentialScopeMismatch) {
+ t.Fatalf("managed credential scope was not restored: %v", err)
+ }
+}
+
+func TestZammadConcurrentPartialConnectionUpdatesDoNotOverwriteEachOther(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ arrived := make(chan struct{}, 2)
+ release := make(chan struct{})
+ f.service.SetUpdateBeforeConnectionLockForTesting(func() {
+ arrived <- struct{}{}
+ <-release
+ })
+ defer f.service.SetUpdateBeforeConnectionLockForTesting(nil)
+
+ updatedName := "Concurrent name"
+ expandedScope := []int{f.workspace1, f.workspace2}
+ errorsByRequest := make(chan error, 2)
+ go func() {
+ _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Name: &updatedName})
+ errorsByRequest <- err
+ }()
+ go func() {
+ _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{WorkspaceIDs: &expandedScope})
+ errorsByRequest <- err
+ }()
+
+ <-arrived
+ <-arrived
+ close(release)
+ firstErr, secondErr := <-errorsByRequest, <-errorsByRequest
+ conflicts := 0
+ for _, err := range []error{firstErr, secondErr} {
+ if errors.Is(err, repository.ErrConcurrentUpdate) {
+ conflicts++
+ } else if err != nil {
+ t.Fatalf("unexpected concurrent update error: %v", err)
+ }
+ }
+ if conflicts != 1 {
+ t.Fatalf("expected exactly one optimistic conflict, got %d: first=%v second=%v", conflicts, firstErr, secondErr)
+ }
+
+ stored, err := f.service.GetConnection(f.connection.ProviderID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ nameWon := stored.Name == updatedName && slices.Equal(stored.WorkspaceIDs, []int{f.workspace1})
+ scopeWon := stored.Name == f.connection.Name && slices.Equal(stored.WorkspaceIDs, expandedScope)
+ if !nameWon && !scopeWon {
+ t.Fatalf("concurrent partial updates produced a mixed or stale result: name=%q workspaces=%v", stored.Name, stored.WorkspaceIDs)
+ }
+}
+
+func TestZammadSyncUpdatesStatusAndDisabledConnectionsAreSkipped(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 3, "state": "pending"}
+ link, err = f.service.SyncTicketLink(context.Background(), link.ID)
+ if err != nil || link.LastStatusID != 3 || link.LastStatusName != "pending" {
+ t.Fatalf("unexpected synced link: link=%#v err=%v", link, err)
+ }
+ disabled := false
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.resetRequests()
+ if err := f.service.SyncDue(context.Background(), 50); err != nil {
+ t.Fatal(err)
+ }
+ requests, _ := f.transport.counts()
+ if requests != 0 {
+ t.Fatalf("disabled connection was polled %d times", requests)
+ }
+}
+
+func TestZammadDisabledConnectionCanStillBeTestedByAdmin(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ disabled := false
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil {
+ t.Fatal(err)
+ }
+ metadata, err := f.service.TestConnection(context.Background(), f.connection.ProviderID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(metadata.Groups) != 1 || metadata.Groups[0].Name != "Support" {
+ t.Fatalf("unexpected connection test metadata: %#v", metadata)
+ }
+}
+
+func TestZammadConnectionValidationRequiresUsableDefaultGroup(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ _, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "missing-default", Name: "Missing default", BaseURL: "https://missing-default.example.test",
+ APIToken: "token", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ var validationErr *ZammadValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("connection without a default group was accepted: %v", err)
+ }
+
+ _, err = f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "default-outside-allowset", Name: "Default outside allowset", BaseURL: "https://default-outside.example.test",
+ APIToken: "token", DefaultGroupID: 7, AllowedGroupIDs: []int{8},
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("default group outside allowset was accepted: %v", err)
+ }
+}
+
+func TestZammadMetadataRequiresNamedDefaultGroupInsideAllowset(t *testing.T) {
+ connection := &models.ZammadConnection{DefaultGroupName: "Support", AllowedGroupIDs: []int{8}}
+ metadata := &models.ZammadConnectionMetadata{Groups: []models.ZammadGroup{
+ {ID: 7, Name: "Support", Active: true},
+ {ID: 8, Name: "Escalations", Active: true},
+ }}
+ var validationErr *ZammadValidationError
+ if err := validateZammadMetadata(connection, metadata); !errors.As(err, &validationErr) {
+ t.Fatalf("named default group outside allowset was accepted: %v", err)
+ }
+}
+
+func TestZammadUnknownTicketDoesNotChangeItem(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getStatus = http.StatusNotFound
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil {
+ t.Fatal("expected remote not-found error")
+ }
+ var statusID int
+ if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
+ t.Fatal(err)
+ }
+ if statusID != f.openStatus {
+ t.Fatalf("unknown remote ticket changed item status to %d", statusID)
+ }
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(stored.LastError, "synthetic remote detail") {
+ t.Fatalf("remote response body leaked into stored error: %q", stored.LastError)
+ }
+}
+
+func TestZammadClosedStateCompletesItemOnce(t *testing.T) {
+ workflow := &fakeZammadWorkflow{}
+ f := newZammadServiceFixture(t, workflow)
+ workflow.db = f.db
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ closed := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 4, "state": "closed"}
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ var statusID int
+ if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
+ t.Fatal(err)
+ }
+ if statusID != f.doneStatus || workflow.writes != 1 {
+ t.Fatalf("closed-state mapping was not idempotent: status=%d writes=%d", statusID, workflow.writes)
+ }
+ if _, err := f.db.ExecWrite("UPDATE items SET status_id = ? WHERE id = ?", f.openStatus, f.item1); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
+ t.Fatal(err)
+ }
+ if statusID != f.openStatus || workflow.writes != 1 {
+ t.Fatalf("same remote closed episode re-completed a reopened item: status=%d writes=%d", statusID, workflow.writes)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 2, "state": "open"}
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 4, "state": "closed"}
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if workflow.writes != 2 {
+ t.Fatalf("new remote closed episode did not complete the item: writes=%d", workflow.writes)
+ }
+}
+
+func TestZammadCompletionPolicyCannotChangeDuringClaimedTransition(t *testing.T) {
+ workflow := &fakeZammadWorkflow{}
+ f := newZammadServiceFixture(t, workflow)
+ workflow.db = f.db
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ closed := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 4, "state": "closed"}
+ arrived := make(chan struct{}, 1)
+ release := make(chan struct{})
+ f.service.SetCompletionBeforeTransitionForTesting(func() {
+ arrived <- struct{}{}
+ <-release
+ })
+ defer f.service.SetCompletionBeforeTransitionForTesting(nil)
+
+ syncErr := make(chan error, 1)
+ go func() {
+ _, err := f.service.SyncTicketLink(context.Background(), link.ID)
+ syncErr <- err
+ }()
+ <-arrived
+ noClosedStates := []int{}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
+ ClosedStateIDs: &noClosedStates, ClearCompletionStatus: true,
+ }); !errors.Is(err, ErrZammadConnectionBusy) {
+ t.Fatalf("completion policy changed during a claimed transition: %v", err)
+ }
+ close(release)
+ if err := <-syncErr; err != nil {
+ t.Fatalf("claimed transition failed after conflicting config update was rejected: %v", err)
+ }
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if workflow.writes != 1 || !stored.CompletionApplied {
+ t.Fatalf("completion did not commit exactly once: writes=%d link=%#v", workflow.writes, stored)
}
}
-func TestZammadDeleteConnectionRejectsExistingTicketLinks(t *testing.T) {
- f := newZammadServiceFixture(t, nil)
+func TestZammadPostCommitTransitionErrorStillFencesClosedEpisode(t *testing.T) {
+ workflow := &fakeZammadWorkflow{postCommitErr: errors.New("synthetic post-commit hook failure")}
+ f := newZammadServiceFixture(t, workflow)
+ workflow.db = f.db
link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
if err != nil {
t.Fatal(err)
}
- if err := f.service.DeleteConnection(f.connection.ProviderID); err == nil {
- t.Fatal("connection deletion succeeded despite existing ticket link")
- }
- var providerCount, credentialCount, linkCount int
- if err := f.db.QueryRow("SELECT COUNT(*) FROM integration_providers WHERE id = ?", f.connection.ProviderID).Scan(&providerCount); err != nil {
+ closed := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil {
t.Fatal(err)
}
- if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials WHERE id = ?", f.connection.CredentialID).Scan(&credentialCount); err != nil {
- t.Fatal(err)
+ f.transport.getTicket = map[string]any{"id": link.TicketID, "number": link.TicketNumber, "group_id": 7, "state_id": 4, "state": "closed"}
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil {
+ t.Fatal("post-commit hook error was not reported")
}
- if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_ticket_links WHERE id = ?", link.ID).Scan(&linkCount); err != nil {
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil {
t.Fatal(err)
}
- if providerCount != 1 || credentialCount != 1 || linkCount != 1 {
- t.Fatalf("rejected connection delete changed persisted data: providers=%d credentials=%d links=%d", providerCount, credentialCount, linkCount)
+ if workflow.writes != 1 || !stored.CompletionApplied {
+ t.Fatalf("committed transition was not fenced after hook error: writes=%d link=%#v", workflow.writes, stored)
}
-}
-
-func TestZammadDeleteConnectionWithoutTicketLinksRemovesOwnedCredentialAtomically(t *testing.T) {
- f := newZammadServiceFixture(t, nil)
- credentialID := f.connection.CredentialID
- if err := f.service.DeleteConnection(f.connection.ProviderID); err != nil {
+ if _, err := f.db.ExecWrite("UPDATE items SET status_id = ? WHERE id = ?", f.openStatus, f.item1); err != nil {
t.Fatal(err)
}
- var providerCount, credentialCount int
- if err := f.db.QueryRow("SELECT COUNT(*) FROM integration_providers WHERE id = ?", f.connection.ProviderID).Scan(&providerCount); err != nil {
+ workflow.postCommitErr = nil
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
t.Fatal(err)
}
- if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials WHERE id = ?", credentialID).Scan(&credentialCount); err != nil {
+ var statusID int
+ if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
t.Fatal(err)
}
- if providerCount != 0 || credentialCount != 0 {
- t.Fatalf("connection delete left data behind: providers=%d credentials=%d", providerCount, credentialCount)
+ if workflow.writes != 1 || statusID != f.openStatus {
+ t.Fatalf("same remote closed episode replayed after post-commit error: writes=%d status=%d", workflow.writes, statusID)
}
}
-func TestZammadConnectionUpdateRollsBackManagedCredentialOnProviderConflict(t *testing.T) {
- f := newZammadServiceFixture(t, nil)
- other, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
- Slug: "other-helpdesk", Name: "Other helpdesk", BaseURL: "https://other.example.test",
- APIToken: "other-token", DefaultGroupID: 7, DefaultGroupName: "Support",
- DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
- }, f.actorID)
+func TestZammadClaimedSyncBlocksConcurrentUnlinkBeforeCompletion(t *testing.T) {
+ workflow := &fakeZammadWorkflow{}
+ f := newZammadServiceFixture(t, workflow)
+ workflow.db = f.db
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
if err != nil {
t.Fatal(err)
}
- newToken := "must-be-rolled-back"
- newScope := []int{f.workspace2}
- if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Slug: &other.Slug, APIToken: &newToken, WorkspaceIDs: &newScope}); !errors.Is(err, repository.ErrDuplicateEntry) {
- t.Fatalf("expected duplicate provider slug, got %v", err)
+ closed := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil {
+ t.Fatal(err)
}
- secret, _, err := f.credentials.ResolveManaged(context.Background(), f.connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), f.connection.ProviderID)
- if err != nil || secret != "synthetic-zammad-token" {
- t.Fatalf("managed credential was not restored: secret_matches=%v err=%v", secret == "synthetic-zammad-token", err)
+ f.transport.getTicket = map[string]any{"id": link.TicketID, "number": link.TicketNumber, "group_id": 7, "state_id": 4, "state": "closed"}
+ arrived := make(chan struct{}, 1)
+ release := make(chan struct{})
+ f.service.SetCompletionBeforeTransitionForTesting(func() {
+ arrived <- struct{}{}
+ <-release
+ })
+ defer f.service.SetCompletionBeforeTransitionForTesting(nil)
+
+ syncErr := make(chan error, 1)
+ go func() {
+ _, err := f.service.SyncTicketLink(context.Background(), link.ID)
+ syncErr <- err
+ }()
+ <-arrived
+ if _, err := f.service.UnlinkTicket(context.Background(), link.ID); !errors.Is(err, ErrZammadConnectionBusy) {
+ t.Fatalf("unlink entered a claimed sync before completion: %v", err)
}
- if _, _, err := f.credentials.ResolveManaged(context.Background(), f.connection.CredentialID, f.workspace2, string(models.IntegrationProviderZammad), f.connection.ProviderID); !errors.Is(err, ErrCredentialScopeMismatch) {
- t.Fatalf("managed credential scope was not restored: %v", err)
+ close(release)
+ if err := <-syncErr; err != nil {
+ t.Fatalf("claimed sync failed after concurrent unlink was rejected: %v", err)
+ }
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil {
+ t.Fatalf("rejected unlink removed the local link: %v", err)
+ }
+ if workflow.writes != 1 || !stored.CompletionApplied {
+ t.Fatalf("claimed sync did not complete exactly once: writes=%d link=%#v", workflow.writes, stored)
}
}
-func TestZammadSyncUpdatesStatusAndDisabledConnectionsAreSkipped(t *testing.T) {
- f := newZammadServiceFixture(t, nil)
+func TestZammadExpiredSyncLeaseReloadsChangedCompletionPolicyBeforeTransition(t *testing.T) {
+ workflow := &fakeZammadWorkflow{}
+ f := newZammadServiceFixture(t, workflow)
+ workflow.db = f.db
link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
if err != nil {
t.Fatal(err)
}
- f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 3, "state": "pending"}
- link, err = f.service.SyncTicketLink(context.Background(), link.ID)
- if err != nil || link.LastStatusID != 3 || link.LastStatusName != "pending" {
- t.Fatalf("unexpected synced link: link=%#v err=%v", link, err)
+ closed := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil {
+ t.Fatal(err)
}
- disabled := false
- if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil {
+ f.transport.getTicket = map[string]any{"id": link.TicketID, "number": link.TicketNumber, "group_id": 7, "state_id": 4, "state": "closed"}
+ arrived := make(chan struct{}, 1)
+ release := make(chan struct{})
+ f.service.SetCompletionBeforeTransitionForTesting(func() {
+ arrived <- struct{}{}
+ <-release
+ })
+ defer f.service.SetCompletionBeforeTransitionForTesting(nil)
+
+ syncErr := make(chan error, 1)
+ go func() {
+ _, err := f.service.SyncTicketLink(context.Background(), link.ID)
+ syncErr <- err
+ }()
+ <-arrived
+ if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET sync_lock_until = ? WHERE id = ?", time.Now().Add(-time.Minute), link.ID); err != nil {
t.Fatal(err)
}
- f.transport.resetRequests()
- if err := f.service.SyncDue(context.Background(), 50); err != nil {
+ noClosedStates := []int{}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
+ ClosedStateIDs: &noClosedStates, ClearCompletionStatus: true,
+ }); err != nil {
+ t.Fatalf("completion policy could not change after the synthetic lease expiry: %v", err)
+ }
+ close(release)
+ if err := <-syncErr; err != nil {
+ t.Fatalf("sync did not recover its untaken lease under the current policy: %v", err)
+ }
+ var statusID int
+ if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
t.Fatal(err)
}
- requests, _ := f.transport.counts()
- if requests != 0 {
- t.Fatalf("disabled connection was polled %d times", requests)
+ if workflow.writes != 0 || statusID != f.openStatus {
+ t.Fatalf("expired worker applied the superseded completion policy: writes=%d status=%d", workflow.writes, statusID)
}
}
-func TestZammadDisabledConnectionCanStillBeTestedByAdmin(t *testing.T) {
+func TestZammadTicketCreationClaimBlocksConcurrentUnlinkBeforeCompletion(t *testing.T) {
f := newZammadServiceFixture(t, nil)
- disabled := false
- if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil {
- t.Fatal(err)
+ arrived := make(chan struct{}, 1)
+ release := make(chan struct{})
+ f.service.SetPersistBeforeConnectionLockForTesting(func() {
+ arrived <- struct{}{}
+ <-release
+ })
+ defer f.service.SetPersistBeforeConnectionLockForTesting(nil)
+
+ type createResult struct {
+ link *models.ZammadTicketLink
+ err error
}
- metadata, err := f.service.TestConnection(context.Background(), f.connection.ProviderID)
- if err != nil {
+ result := make(chan createResult, 1)
+ go func() {
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ result <- createResult{link: link, err: err}
+ }()
+ <-arrived
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 1 {
+ t.Fatalf("creation reservation was not visible: links=%#v err=%v", links, err)
+ }
+ if _, err := f.service.UnlinkTicket(context.Background(), links[0].ID); !errors.Is(err, ErrZammadConnectionBusy) {
+ t.Fatalf("unlink deleted a claimed creation before local completion: %v", err)
+ }
+ close(release)
+ created := <-result
+ if created.err != nil || created.link == nil || created.link.ItemIntegrationLinkID == "" {
+ t.Fatalf("ticket creation did not complete after unlink was rejected: link=%#v err=%v", created.link, created.err)
+ }
+ var genericLinks int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE id = ?", created.link.ItemIntegrationLinkID).Scan(&genericLinks); err != nil {
t.Fatal(err)
}
- if len(metadata.Groups) != 1 || metadata.Groups[0].Name != "Support" {
- t.Fatalf("unexpected connection test metadata: %#v", metadata)
+ if genericLinks != 1 {
+ t.Fatalf("ticket creation committed %d generic links, want 1", genericLinks)
}
}
-func TestZammadUnknownTicketDoesNotChangeItem(t *testing.T) {
+func TestZammadExistingTicketClaimBlocksConcurrentUnlinkBeforeCompletion(t *testing.T) {
f := newZammadServiceFixture(t, nil)
- link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
- if err != nil {
- t.Fatal(err)
+ f.transport.ticket = map[string]any{"id": 714, "number": "420714", "group_id": 7, "state_id": 2, "state": "open"}
+ arrived := make(chan struct{}, 1)
+ release := make(chan struct{})
+ f.service.SetPersistBeforeConnectionLockForTesting(func() {
+ arrived <- struct{}{}
+ <-release
+ })
+ defer f.service.SetPersistBeforeConnectionLockForTesting(nil)
+
+ type linkResult struct {
+ link *models.ZammadTicketLink
+ err error
}
- f.transport.getStatus = http.StatusNotFound
- if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil {
- t.Fatal("expected remote not-found error")
+ result := make(chan linkResult, 1)
+ go func() {
+ link, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{
+ ConnectionID: f.connection.ProviderID, TicketNumber: "420714",
+ })
+ result <- linkResult{link: link, err: err}
+ }()
+ <-arrived
+ links, err := f.service.TicketLinksForItem(f.item1)
+ if err != nil || len(links) != 1 {
+ t.Fatalf("existing-ticket reservation was not visible: links=%#v err=%v", links, err)
}
- var statusID int
- if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
- t.Fatal(err)
+ if _, err := f.service.UnlinkTicket(context.Background(), links[0].ID); !errors.Is(err, ErrZammadConnectionBusy) {
+ t.Fatalf("unlink deleted a claimed existing-ticket link before completion: %v", err)
}
- if statusID != f.openStatus {
- t.Fatalf("unknown remote ticket changed item status to %d", statusID)
+ close(release)
+ linked := <-result
+ if linked.err != nil || linked.link == nil || linked.link.ItemIntegrationLinkID == "" {
+ t.Fatalf("existing-ticket link did not complete after unlink was rejected: link=%#v err=%v", linked.link, linked.err)
}
- stored, err := f.service.GetTicketLink(link.ID)
- if err != nil {
+ var genericLinks int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE id = ?", linked.link.ItemIntegrationLinkID).Scan(&genericLinks); err != nil {
t.Fatal(err)
}
- if strings.Contains(stored.LastError, "synthetic remote detail") {
- t.Fatalf("remote response body leaked into stored error: %q", stored.LastError)
+ if genericLinks != 1 {
+ t.Fatalf("existing-ticket completion committed %d generic links, want 1", genericLinks)
}
}
-func TestZammadClosedStateCompletesItemOnce(t *testing.T) {
+func TestZammadSyncReloadsCompletionStateAfterClaim(t *testing.T) {
workflow := &fakeZammadWorkflow{}
f := newZammadServiceFixture(t, workflow)
workflow.db = f.db
@@ -878,46 +1933,76 @@ func TestZammadClosedStateCompletesItemOnce(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- closed := []int{4}
- if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil {
+ closedStates := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closedStates, CompletionStatusID: &f.doneStatus}); err != nil {
t.Fatal(err)
}
- f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 4, "state": "closed"}
- if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ stale, err := f.service.GetTicketLink(link.ID)
+ if err != nil {
t.Fatal(err)
}
- if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET completion_applied = true WHERE id = ?", link.ID); err != nil {
t.Fatal(err)
}
- var statusID int
- if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
+ f.transport.getTicket = map[string]any{"id": link.TicketID, "number": link.TicketNumber, "group_id": 7, "state_id": 4, "state": "closed"}
+ syncOwner := "stale-completion-test"
+ claimed, err := f.service.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("could not claim stale-link regression sync: claimed=%v err=%v", claimed, err)
+ }
+ updated, err := f.service.syncClaimedTicketLink(context.Background(), stale, syncOwner)
+ if err != nil {
t.Fatal(err)
}
- if statusID != f.doneStatus || workflow.writes != 1 {
- t.Fatalf("closed-state mapping was not idempotent: status=%d writes=%d", statusID, workflow.writes)
+ if workflow.writes != 0 || !updated.CompletionApplied {
+ t.Fatalf("stale completion state was replayed: writes=%d link=%#v", workflow.writes, updated)
}
- if _, err := f.db.ExecWrite("UPDATE items SET status_id = ? WHERE id = ?", f.openStatus, f.item1); err != nil {
- t.Fatal(err)
+ var statusID int
+ if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil || statusID != f.openStatus {
+ t.Fatalf("stale sync changed item status: status=%d err=%v", statusID, err)
}
- if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+}
+
+func TestZammadManualRefreshReportsBusyWhenAnotherWorkerOwnsLease(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
t.Fatal(err)
}
- if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil {
- t.Fatal(err)
+ claimed, err := f.service.repo.ClaimSync(link.ID, "scheduler-owner", time.Now().Add(time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("scheduler could not claim ticket: claimed=%v err=%v", claimed, err)
}
- if statusID != f.openStatus || workflow.writes != 1 {
- t.Fatalf("same remote closed episode re-completed a reopened item: status=%d writes=%d", statusID, workflow.writes)
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); !errors.Is(err, ErrZammadConnectionBusy) {
+ t.Fatalf("manual refresh reported success while scheduler owned the lease: %v", err)
}
- f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 2, "state": "open"}
- if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ if err := f.service.repo.ReleaseSyncClaim(link.ID, "scheduler-owner"); err != nil {
t.Fatal(err)
}
- f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 4, "state": "closed"}
- if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+}
+
+func TestZammadSyncRejectsConfiguredGroupAfterItBecomesInactive(t *testing.T) {
+ workflow := &fakeZammadWorkflow{}
+ f := newZammadServiceFixture(t, workflow)
+ workflow.db = f.db
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
t.Fatal(err)
}
- if workflow.writes != 2 {
- t.Fatalf("new remote closed episode did not complete the item: writes=%d", workflow.writes)
+ closedStates := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closedStates, CompletionStatusID: &f.doneStatus}); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": false}}
+ f.transport.getTicket = map[string]any{"id": link.TicketID, "number": link.TicketNumber, "group_id": 7, "state_id": 4, "state": "closed"}
+ _, err = f.service.SyncTicketLink(context.Background(), link.ID)
+ var validationErr *ZammadValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("inactive configured group was accepted: %v", err)
+ }
+ stored, getErr := f.service.GetTicketLink(link.ID)
+ if getErr != nil || workflow.writes != 0 || stored.CompletionApplied {
+ t.Fatalf("inactive group changed completion state: link=%#v writes=%d err=%v", stored, workflow.writes, getErr)
}
}
@@ -956,8 +2041,8 @@ func TestZammadDueSyncUsesAgeThreshold(t *testing.T) {
t.Fatal(err)
}
requests, _ := f.transport.counts()
- if requests != 1 {
- t.Fatalf("expected one ticket refresh, got %d transport requests", requests)
+ if requests != 2 {
+ t.Fatalf("expected ticket and active-group refresh, got %d transport requests", requests)
}
}
@@ -973,7 +2058,7 @@ func TestZammadOAuthConnectionStoresConnectionTokensAndConsumesStateOnce(t *test
connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
Slug: "oauth-helpdesk", Name: "OAuth helpdesk", BaseURL: "https://oauth.example.test",
AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "synthetic-client", OAuthClientSecret: "synthetic-client-secret",
- DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
}, f.actorID)
if err != nil {
t.Fatal(err)
@@ -991,7 +2076,7 @@ func TestZammadOAuthConnectionStoresConnectionTokensAndConsumesStateOnce(t *test
}
if _, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
Slug: "oauth-helpdesk", Name: "Duplicate OAuth helpdesk", BaseURL: "https://duplicate.example.test",
- AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret", DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
}, f.actorID); !errors.Is(err, repository.ErrDuplicateEntry) {
t.Fatalf("expected duplicate OAuth connection error, got %v", err)
}
@@ -1113,7 +2198,7 @@ func createActiveZammadOAuthConnection(t *testing.T, f *zammadServiceFixture, sl
connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
Slug: slug, Name: "OAuth " + slug, BaseURL: "https://" + slug + ".example.test",
AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
- DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
}, f.actorID)
if err != nil {
t.Fatal(err)
@@ -1154,7 +2239,7 @@ func TestZammadOAuthCallbackCannotCommitAfterConfigurationReset(t *testing.T) {
connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
Slug: "callback-race", Name: "OAuth callback race", BaseURL: "https://callback-race.example.test",
AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
- DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
}, f.actorID)
if err != nil {
t.Fatal(err)
@@ -1211,7 +2296,7 @@ func TestZammadOAuthCallbackPreservesConcurrentNameAndScopeUpdate(t *testing.T)
connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
Slug: "callback-metadata-race", Name: "Original callback name", BaseURL: "https://callback-metadata-race.example.test",
AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
- DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
}, f.actorID)
if err != nil {
t.Fatal(err)
@@ -1266,7 +2351,7 @@ func TestZammadOAuthParallelStartsLeaveOnlyNewestUsableState(t *testing.T) {
connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
Slug: "parallel-state", Name: "Parallel OAuth state", BaseURL: "https://parallel-state.example.test",
AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
- DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
}, f.actorID)
if err != nil {
t.Fatal(err)
@@ -1324,7 +2409,7 @@ func TestZammadOAuthNewStartSupersedesAlreadyRunningCallback(t *testing.T) {
connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
Slug: "running-callback", Name: "Running OAuth callback", BaseURL: "https://running-callback.example.test",
AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
- DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
}, f.actorID)
if err != nil {
t.Fatal(err)
@@ -1462,6 +2547,61 @@ func TestZammadOAuthRefreshPreservesConcurrentNameAndScopeUpdate(t *testing.T) {
assertZammadManagedCredentialMetadata(t, f, connection, updatedName+" Zammad OAuth credentials", f.workspace2)
}
+func TestZammadOAuthConcurrentRefreshWaitsForRotatedCredential(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ connection := createActiveZammadOAuthConnection(t, f, "refresh-contention", time.Now().Add(-time.Minute))
+ entered, release := make(chan struct{}), make(chan struct{})
+ var transportMu sync.Mutex
+ transportCalls := 0
+ f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ transportMu.Lock()
+ transportCalls++
+ call := transportCalls
+ transportMu.Unlock()
+ if call == 1 {
+ close(entered)
+ <-release
+ }
+ return jsonResponse(http.StatusOK, map[string]any{"access_token": "shared-refreshed-access", "refresh_token": "shared-refreshed-refresh", "expires_in": 3600}), nil
+ }))
+ type tokenResult struct {
+ token string
+ err error
+ }
+ results := make(chan tokenResult, 2)
+ refresh := func() {
+ token, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1)
+ results <- tokenResult{token: token, err: err}
+ }
+ go refresh()
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("first OAuth refresh did not reach token exchange")
+ }
+ go refresh()
+ time.Sleep(2 * zammadOAuthRefreshPollInterval)
+ transportMu.Lock()
+ callsBeforeRelease := transportCalls
+ transportMu.Unlock()
+ if callsBeforeRelease != 1 {
+ t.Fatalf("contending request issued a second refresh: calls=%d", callsBeforeRelease)
+ }
+ close(release)
+ first, second := <-results, <-results
+ for i, result := range []tokenResult{first, second} {
+ if result.err != nil || result.token != "shared-refreshed-access" {
+ t.Fatalf("refresh result %d did not reuse rotated access token: token=%q err=%v", i+1, result.token, result.err)
+ }
+ }
+ transportMu.Lock()
+ finalCalls := transportCalls
+ transportMu.Unlock()
+ if finalCalls != 1 {
+ t.Fatalf("parallel refresh used the refresh token %d times", finalCalls)
+ }
+}
+
func TestZammadOAuthRefreshCommitRequiresCurrentClaimOwner(t *testing.T) {
f := newZammadServiceFixture(t, nil)
connection := createActiveZammadOAuthConnection(t, f, "claim-loss", time.Now().Add(-time.Minute))
@@ -1639,7 +2779,7 @@ func TestZammadOAuthTestSkipsAdminOnlyCorrelationFieldCheck(t *testing.T) {
f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests"))
connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
Slug: "oauth-scope", Name: "Scoped OAuth", BaseURL: "https://scope.example.test", AuthMethod: models.ZammadAuthMethodOAuth,
- OAuthClientID: "client", OAuthClientSecret: "secret", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ OAuthClientID: "client", OAuthClientSecret: "secret", DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
}, f.actorID)
if err != nil {
t.Fatal(err)
@@ -1671,6 +2811,25 @@ func TestZammadOAuthTestSkipsAdminOnlyCorrelationFieldCheck(t *testing.T) {
}
}
+func TestZammadAPITokenTestSkipsAdminOnlyCorrelationFieldCheck(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ calledObjectManager := false
+ f.service.SetTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, targetURL string, _ []byte, _ map[string]string) (*zammad.Response, error) {
+ if strings.Contains(targetURL, "object_manager_attributes") {
+ calledObjectManager = true
+ return jsonResponse(http.StatusForbidden, map[string]string{}), nil
+ }
+ if strings.Contains(targetURL, "/groups") {
+ return jsonResponse(http.StatusOK, []map[string]any{{"id": 7, "name": "Support", "active": true}}), nil
+ }
+ return jsonResponse(http.StatusOK, []map[string]any{{"id": 2, "name": "open", "active": true}}), nil
+ }))
+ metadata, err := f.service.TestConnection(context.Background(), f.connection.ProviderID)
+ if err != nil || metadata.CorrelationFieldVerified || calledObjectManager {
+ t.Fatalf("API-token test must not require admin.object: metadata=%#v err=%v called=%v", metadata, err, calledObjectManager)
+ }
+}
+
func TestZammadSafeTransportHonorsAllowLocalConnections(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/groups" {
From 57b585cb642cb48e831aa8f844dd2aff1940bcb3 Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 04:45:57 +0200
Subject: [PATCH 03/11] fix: preserve Zammad link invariants
---
.../lib/features/items/ZammadItemPanel.svelte | 4 +-
internal/aitools/items.go | 3 +
internal/database/migrations.go | 85 ++++++++
internal/database/schema/integrations.sql | 2 +-
.../database/schema/integrations_postgres.sql | 2 +-
internal/database/zammad_schema_test.go | 37 +++-
internal/handlers/items.go | 8 +
internal/handlers/users.go | 17 +-
internal/handlers/workspaces_handler.go | 11 +-
internal/handlers/zammad.go | 2 +-
internal/handlers/zammad_test.go | 28 +++
internal/repository/item_repository.go | 26 +++
internal/repository/workspace_repository.go | 34 ++++
internal/repository/zammad_repository.go | 43 ++++
internal/restapi/v1/handlers/items.go | 9 +
internal/restapi/v1/handlers/workspaces.go | 9 +
internal/services/item_crud_service.go | 21 +-
internal/services/user_offboard.go | 42 +++-
internal/services/workspace_service.go | 62 ++++--
internal/services/zammad_service.go | 23 ++-
internal/services/zammad_service_test.go | 191 ++++++++++++++++++
21 files changed, 611 insertions(+), 48 deletions(-)
diff --git a/frontend/src/lib/features/items/ZammadItemPanel.svelte b/frontend/src/lib/features/items/ZammadItemPanel.svelte
index 06aa0a8b3..347e3db11 100644
--- a/frontend/src/lib/features/items/ZammadItemPanel.svelte
+++ b/frontend/src/lib/features/items/ZammadItemPanel.svelte
@@ -541,9 +541,9 @@
{link.last_synced_at ? t('zammad.lastSynced', { time: new Date(link.last_synced_at).toLocaleString() }) : t('zammad.notSynced')}
- {#if canEdit && linkConnectionUsable}
+ {#if canEdit}
- {#if link.ticket_id && link.sync_state !== 'creating'}
+ {#if linkConnectionUsable && link.ticket_id && link.sync_state !== 'creating'}
diff --git a/internal/aitools/items.go b/internal/aitools/items.go
index 1e9371c69..238101522 100644
--- a/internal/aitools/items.go
+++ b/internal/aitools/items.go
@@ -461,6 +461,9 @@ func init() {
if errors.Is(err, services.ErrItemDeletionForbidden) {
return map[string]string{"error": "permission denied"}, nil
}
+ if errors.Is(err, services.ErrItemHasZammadTicketLinks) {
+ return map[string]string{"error": "unlink all Zammad tickets from the item or its descendants before deleting them"}, nil
+ }
return nil, err
}
env.AuditWrite(logger.ResourceItem, itemID, "delete_item", result.Item.Title)
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index b5b737b58..eb98f3211 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -747,6 +747,91 @@ var Catalog = []Migration{
ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS sync_lock_owner TEXT;
`,
},
+ {
+ Version: "20260831_zammad_ticket_link_item_restrict",
+ Name: "Require Zammad ticket unlink before item deletion",
+ CheckSQLite: `
+ SELECT COUNT(*)
+ FROM pragma_foreign_key_list('zammad_ticket_links')
+ WHERE "table" = 'items' AND "from" = 'item_id' AND on_delete = 'RESTRICT'
+ `,
+ CheckPostgres: `
+ SELECT COUNT(*)
+ FROM information_schema.referential_constraints rc
+ JOIN information_schema.key_column_usage kcu
+ ON kcu.constraint_schema = rc.constraint_schema
+ AND kcu.constraint_name = rc.constraint_name
+ WHERE rc.constraint_schema = current_schema()
+ AND kcu.table_schema = current_schema()
+ AND kcu.table_name = 'zammad_ticket_links'
+ AND kcu.column_name = 'item_id'
+ AND rc.delete_rule = 'RESTRICT'
+ `,
+ SQLite: `
+ CREATE TABLE zammad_ticket_links_item_restrict (
+ id TEXT PRIMARY KEY,
+ item_id INTEGER NOT NULL,
+ provider_id TEXT NOT NULL,
+ item_integration_link_id TEXT,
+ ticket_id INTEGER,
+ ticket_number TEXT DEFAULT '',
+ ticket_url TEXT DEFAULT '',
+ group_id INTEGER,
+ group_name TEXT DEFAULT '',
+ owner_id INTEGER,
+ owner_name TEXT DEFAULT '',
+ correlation_key TEXT NOT NULL,
+ sync_state TEXT NOT NULL DEFAULT 'pending',
+ creating_started_at DATETIME,
+ last_status_id INTEGER,
+ last_status_name TEXT DEFAULT '',
+ last_synced_at DATETIME,
+ last_attempt_at DATETIME,
+ next_attempt_at DATETIME,
+ last_error TEXT DEFAULT '',
+ completion_applied BOOLEAN NOT NULL DEFAULT false,
+ sync_lock_until DATETIME,
+ sync_lock_owner TEXT,
+ created_by INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE RESTRICT,
+ FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
+ FOREIGN KEY (item_integration_link_id) REFERENCES item_integration_links(id) ON DELETE SET NULL,
+ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
+ UNIQUE(item_id, provider_id),
+ UNIQUE(provider_id, ticket_id),
+ UNIQUE(provider_id, correlation_key)
+ );
+ INSERT INTO zammad_ticket_links_item_restrict (
+ id, item_id, provider_id, item_integration_link_id, ticket_id,
+ ticket_number, ticket_url, group_id, group_name, owner_id,
+ owner_name, correlation_key, sync_state, creating_started_at,
+ last_status_id, last_status_name, last_synced_at, last_attempt_at,
+ next_attempt_at, last_error, completion_applied, sync_lock_until,
+ sync_lock_owner, created_by, created_at, updated_at
+ )
+ SELECT
+ id, item_id, provider_id, item_integration_link_id, ticket_id,
+ ticket_number, ticket_url, group_id, group_name, owner_id,
+ owner_name, correlation_key, sync_state, creating_started_at,
+ last_status_id, last_status_name, last_synced_at, last_attempt_at,
+ next_attempt_at, last_error, completion_applied, sync_lock_until,
+ sync_lock_owner, created_by, created_at, updated_at
+ FROM zammad_ticket_links;
+ DROP TABLE zammad_ticket_links;
+ ALTER TABLE zammad_ticket_links_item_restrict RENAME TO zammad_ticket_links;
+ CREATE INDEX idx_zammad_ticket_links_item ON zammad_ticket_links(item_id);
+ CREATE INDEX idx_zammad_ticket_links_sync ON zammad_ticket_links(sync_state, last_synced_at);
+ `,
+ Postgres: `
+ ALTER TABLE zammad_ticket_links
+ DROP CONSTRAINT zammad_ticket_links_item_id_fkey;
+ ALTER TABLE zammad_ticket_links
+ ADD CONSTRAINT zammad_ticket_links_item_id_fkey
+ FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE RESTRICT;
+ `,
+ },
}
func applySQLiteSSOAttributeMappingDefault(db Database) (retErr error) {
diff --git a/internal/database/schema/integrations.sql b/internal/database/schema/integrations.sql
index 13574cc79..0d8edc218 100644
--- a/internal/database/schema/integrations.sql
+++ b/internal/database/schema/integrations.sql
@@ -231,7 +231,7 @@ CREATE TABLE IF NOT EXISTS zammad_ticket_links (
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
+ FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE RESTRICT,
FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
FOREIGN KEY (item_integration_link_id) REFERENCES item_integration_links(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
diff --git a/internal/database/schema/integrations_postgres.sql b/internal/database/schema/integrations_postgres.sql
index 512248cc4..97311cd2c 100644
--- a/internal/database/schema/integrations_postgres.sql
+++ b/internal/database/schema/integrations_postgres.sql
@@ -178,7 +178,7 @@ CREATE INDEX IF NOT EXISTS idx_zammad_connection_workspaces_workspace ON zammad_
CREATE TABLE IF NOT EXISTS zammad_ticket_links (
id TEXT PRIMARY KEY,
- item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+ item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE RESTRICT,
provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE,
item_integration_link_id TEXT REFERENCES item_integration_links(id) ON DELETE SET NULL,
ticket_id INTEGER,
diff --git a/internal/database/zammad_schema_test.go b/internal/database/zammad_schema_test.go
index d60c510fa..7e3386775 100644
--- a/internal/database/zammad_schema_test.go
+++ b/internal/database/zammad_schema_test.go
@@ -26,7 +26,7 @@ func TestZammadSchemaInitializesOnSQLite(t *testing.T) {
}
}
- for _, version := range []string{"20260829_zammad_integration", "20260830_zammad_oauth_connections", "20260830_zammad_oauth_generation", "20260830_zammad_ticket_link_metadata", "20260830_zammad_ticket_link_completion_postgres", "20260831_zammad_connection_config_revision", "20260831_zammad_ticket_sync_lock_owner"} {
+ for _, version := range []string{"20260829_zammad_integration", "20260830_zammad_oauth_connections", "20260830_zammad_oauth_generation", "20260830_zammad_ticket_link_metadata", "20260830_zammad_ticket_link_completion_postgres", "20260831_zammad_connection_config_revision", "20260831_zammad_ticket_sync_lock_owner", "20260831_zammad_ticket_link_item_restrict"} {
var migrationCount int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version=?", version).Scan(&migrationCount); err != nil {
t.Fatal(err)
@@ -121,7 +121,11 @@ func TestZammadTicketLinkMetadataSQLiteUpgrade(t *testing.T) {
DROP TABLE zammad_ticket_links;
ALTER TABLE zammad_ticket_links_legacy RENAME TO zammad_ticket_links;
DELETE FROM schema_migrations
- WHERE version IN ('20260830_zammad_ticket_link_metadata', '20260831_zammad_ticket_sync_lock_owner');
+ WHERE version IN (
+ '20260830_zammad_ticket_link_metadata',
+ '20260831_zammad_ticket_sync_lock_owner',
+ '20260831_zammad_ticket_link_item_restrict'
+ );
`)
if err != nil {
t.Fatal(err)
@@ -142,6 +146,13 @@ func TestZammadTicketLinkMetadataSQLiteUpgrade(t *testing.T) {
t.Fatalf("expected migrated zammad_ticket_links.%s", column)
}
}
+ var deleteRule string
+ if err := db.QueryRow(`SELECT on_delete FROM pragma_foreign_key_list('zammad_ticket_links') WHERE "table" = 'items' AND "from" = 'item_id'`).Scan(&deleteRule); err != nil {
+ t.Fatal(err)
+ }
+ if deleteRule != "RESTRICT" {
+ t.Fatalf("expected migrated item foreign key to restrict deletion, got %q", deleteRule)
+ }
}
func TestZammadTicketLinkMetadataMigrationBackendParity(t *testing.T) {
@@ -200,6 +211,28 @@ func TestZammadTicketSyncLockOwnerMigrationBackendParity(t *testing.T) {
}
}
+func TestZammadTicketLinkItemRestrictMigrationBackendParity(t *testing.T) {
+ var migration *Migration
+ for i := range Catalog {
+ if Catalog[i].Version == "20260831_zammad_ticket_link_item_restrict" {
+ migration = &Catalog[i]
+ break
+ }
+ }
+ if migration == nil {
+ t.Fatal("Zammad item deletion restriction migration is missing")
+ }
+ if !strings.Contains(migration.CheckSQLite, "on_delete = 'RESTRICT'") || !strings.Contains(migration.CheckPostgres, "delete_rule = 'RESTRICT'") {
+ t.Fatal("item deletion restriction migration checks must cover both backends")
+ }
+ if !strings.Contains(migration.SQLite, "REFERENCES items(id) ON DELETE RESTRICT") || !strings.Contains(migration.Postgres, "REFERENCES items(id) ON DELETE RESTRICT") {
+ t.Fatal("item deletion restriction migration bodies must cover both backends")
+ }
+ if !strings.Contains(zammadSchemaMigrationSQLite, "REFERENCES items(id) ON DELETE CASCADE") || !strings.Contains(zammadSchemaMigrationPostgres, "REFERENCES items(id) ON DELETE CASCADE") {
+ t.Fatal("historical Zammad migrations must remain checksum-stable")
+ }
+}
+
// This starts from the deployed 20260829 table shape. The OAuth migration is
// additive: it preserves an API-token connection and permits a new OAuth row
// with its mandatory managed-credential container.
diff --git a/internal/handlers/items.go b/internal/handlers/items.go
index 39e135c4f..e5ff8e3c7 100644
--- a/internal/handlers/items.go
+++ b/internal/handlers/items.go
@@ -1140,6 +1140,14 @@ func (h *ItemHandler) respondItemDeletionError(w http.ResponseWriter, r *http.Re
respondNotFound(w, r, "item")
return true
}
+ if errors.Is(err, services.ErrItemHasZammadTicketLinks) {
+ respondError(w, r, restapi.NewAPIError(
+ http.StatusConflict,
+ restapi.ErrCodeConflict,
+ "Unlink all Zammad tickets from the affected items before deleting them.",
+ ))
+ return true
+ }
respondInternalError(w, r, err)
return true
}
diff --git a/internal/handlers/users.go b/internal/handlers/users.go
index 7ec5b78c5..63a331995 100644
--- a/internal/handlers/users.go
+++ b/internal/handlers/users.go
@@ -666,7 +666,17 @@ func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
- // Audit before anonymization so we capture the original details.
+ if err := h.offboardUser(id); err != nil {
+ if errors.Is(err, services.ErrUserOffboardingHasZammadTicketLinks) {
+ respondConflict(w, r, "Cannot offboard user while their personal workspace contains linked Zammad tickets")
+ return
+ }
+ respondInternalError(w, r, err)
+ return
+ }
+
+ // Audit after successful offboarding so a failed or blocked operation is
+ // never recorded as a successful deletion.
if currentUser != nil {
h.auditor.LogWithDetails(r, currentUser,
logger.ActionUserDelete, logger.ResourceUser,
@@ -678,11 +688,6 @@ func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) {
},
)
}
-
- if err := h.offboardUser(id); err != nil {
- respondInternalError(w, r, err)
- return
- }
h.invalidateUserSessions(id)
w.WriteHeader(http.StatusNoContent)
diff --git a/internal/handlers/workspaces_handler.go b/internal/handlers/workspaces_handler.go
index cfcd462b5..1ca3b2d69 100644
--- a/internal/handlers/workspaces_handler.go
+++ b/internal/handlers/workspaces_handler.go
@@ -516,13 +516,12 @@ func (h *WorkspaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
- // Drop item number sequence for this workspace (PostgreSQL only, no-op for SQLite)
- if err = h.repo.DropItemSequence(int64(id)); err != nil {
- slog.Warn("failed to drop item sequence for workspace", slog.String("component", "workspaces"), slog.Int("workspace_id", id), slog.Any("error", err))
- }
-
- err = h.repo.Delete(id)
+ err = h.workspaceService.Delete(id)
if err != nil {
+ if errors.Is(err, services.ErrWorkspaceHasZammadTicketLinks) {
+ respondConflict(w, r, "Unlink all Zammad tickets from this workspace before deleting it.")
+ return
+ }
respondInternalError(w, r, err)
return
}
diff --git a/internal/handlers/zammad.go b/internal/handlers/zammad.go
index 0195e54ad..ac7b02bba 100644
--- a/internal/handlers/zammad.go
+++ b/internal/handlers/zammad.go
@@ -238,7 +238,7 @@ func (h *ZammadHandler) GetWorkspaceOwners(w http.ResponseWriter, r *http.Reques
return
}
user, ok := RequireAuth(w, r)
- if !ok || !RequireWorkspacePermission(w, r, user.ID, workspaceID, models.PermissionItemView, h.permissionService) {
+ if !ok || !RequireWorkspacePermission(w, r, user.ID, workspaceID, models.PermissionItemEdit, h.permissionService) {
return
}
groupID, err := strconv.Atoi(r.URL.Query().Get("group_id"))
diff --git a/internal/handlers/zammad_test.go b/internal/handlers/zammad_test.go
index 9b9b02f66..8e92a9d93 100644
--- a/internal/handlers/zammad_test.go
+++ b/internal/handlers/zammad_test.go
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "strconv"
"strings"
"testing"
@@ -125,6 +126,33 @@ func TestZammadHandlerReturnsStructuredValidationAndNotFoundErrors(t *testing.T)
}
}
+func TestZammadWorkspaceOwnersRequireItemEditPermission(t *testing.T) {
+ handler, db, user, workspaceID := newZammadHandlerTest(t)
+ if _, err := db.ExecWrite(`INSERT INTO user_workspace_roles (user_id, workspace_id, role_id, granted_by)
+ SELECT ?, ?, id, ? FROM workspace_roles WHERE name = 'Viewer'`, user.ID, workspaceID, user.ID); err != nil {
+ t.Fatal(err)
+ }
+ config := services.DefaultPermissionCacheConfig()
+ config.WarmupOnStartup = false
+ config.PreWarmActive = false
+ permissionService, err := services.NewPermissionService(db, config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = permissionService.Close() })
+ handler.permissionService = permissionService
+
+ workspacePathID := strconv.Itoa(workspaceID)
+ request := authenticatedZammadRequest(http.MethodGet, "/api/workspaces/"+workspacePathID+"/zammad-connections/helpdesk/owners?group_id=7", nil, user)
+ request.SetPathValue("workspaceId", workspacePathID)
+ request.SetPathValue("id", "helpdesk")
+ recorder := httptest.NewRecorder()
+ handler.GetWorkspaceOwners(recorder, request)
+ if recorder.Code != http.StatusForbidden {
+ t.Fatalf("viewer could enumerate Zammad owners: status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+}
+
func TestZammadHandlerMapsRemoteAuthenticationFailureToBadGateway(t *testing.T) {
handler, _, _, _ := newZammadHandlerTest(t)
recorder := httptest.NewRecorder()
diff --git a/internal/repository/item_repository.go b/internal/repository/item_repository.go
index e95182db5..80ddaa79d 100644
--- a/internal/repository/item_repository.go
+++ b/internal/repository/item_repository.go
@@ -222,6 +222,32 @@ func (r *ItemRepository) FindByIDsForUpdateContext(ctx context.Context, tx datab
return items, nil
}
+// LockWorkspaceItemsTx prevents new foreign-key references from being added to
+// the workspace's current items while a destructive workspace operation runs.
+// The caller must lock the workspace row first so concurrent item creation is
+// fenced as well.
+func (r *ItemRepository) LockWorkspaceItemsTx(tx database.Tx, workspaceID int) error {
+ query := "SELECT id FROM items WHERE workspace_id = ? ORDER BY id"
+ if r.db.GetDriverName() == "postgres" {
+ query += " FOR UPDATE"
+ }
+ rows, err := tx.Query(query, workspaceID)
+ if err != nil {
+ return fmt.Errorf("lock workspace items: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ for rows.Next() {
+ var itemID int
+ if err := rows.Scan(&itemID); err != nil {
+ return fmt.Errorf("scan locked workspace item: %w", err)
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return fmt.Errorf("iterate locked workspace items: %w", err)
+ }
+ return nil
+}
+
type ItemWithWorkspaceStatus struct {
*models.Item
WorkspaceActive bool
diff --git a/internal/repository/workspace_repository.go b/internal/repository/workspace_repository.go
index 64e00c8cb..e69e7cca1 100644
--- a/internal/repository/workspace_repository.go
+++ b/internal/repository/workspace_repository.go
@@ -347,6 +347,15 @@ func (r *WorkspaceRepository) Delete(id int) error {
return nil
}
+// DeleteTx removes a workspace inside the caller's transaction. Cache
+// invalidation remains the caller's post-commit responsibility.
+func (r *WorkspaceRepository) DeleteTx(tx database.Tx, id int) error {
+ if _, err := tx.ExecWrite("DELETE FROM workspaces WHERE id = ?", id); err != nil {
+ return err
+ }
+ return nil
+}
+
// FindMissingOrPersonal accepts a set of workspace IDs and returns those that
// don't exist or are flagged as personal — i.e. invalid as portal/form
// submission targets. Used by UpdateChannelConfig to reject bogus IDs before
@@ -401,6 +410,20 @@ func (r *WorkspaceRepository) Exists(id int) (bool, error) {
return exists, err
}
+// LockForDeleteTx locks a workspace before its items are fenced and deleted.
+func (r *WorkspaceRepository) LockForDeleteTx(tx database.Tx, id int) (bool, error) {
+ query := "SELECT id FROM workspaces WHERE id = ?"
+ if r.db.GetDriverName() == "postgres" {
+ query += " FOR UPDATE"
+ }
+ var lockedID int
+ err := tx.QueryRow(query, id).Scan(&lockedID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ return err == nil, err
+}
+
// KeyExists checks if a workspace key exists
func (r *WorkspaceRepository) KeyExists(key string) (bool, error) {
var exists bool
@@ -707,3 +730,14 @@ func (r *WorkspaceRepository) DropItemSequence(workspaceID int64) error {
_, err := r.db.ExecWrite(fmt.Sprintf(`DROP SEQUENCE IF EXISTS %q`, seqName))
return err
}
+
+// DropItemSequenceTx removes the per-workspace sequence inside the caller's
+// transaction. No-op on SQLite.
+func (r *WorkspaceRepository) DropItemSequenceTx(tx database.Tx, workspaceID int64) error {
+ if r.db.GetDriverName() != "postgres" {
+ return nil
+ }
+ seqName := fmt.Sprintf("workspace_%d_item_seq", workspaceID)
+ _, err := tx.ExecWrite(fmt.Sprintf(`DROP SEQUENCE IF EXISTS %q`, seqName))
+ return err
+}
diff --git a/internal/repository/zammad_repository.go b/internal/repository/zammad_repository.go
index 397f5352b..da18d2255 100644
--- a/internal/repository/zammad_repository.go
+++ b/internal/repository/zammad_repository.go
@@ -127,6 +127,22 @@ func (r *ZammadRepository) IsConnectionAvailableToWorkspace(id string, workspace
return available, err
}
+// IsConnectionScopedToWorkspace checks only the configured workspace scope.
+// Cleanup paths use it so an administrator can disable a connection without
+// making its existing links impossible to remove.
+func (r *ZammadRepository) IsConnectionScopedToWorkspace(id string, workspaceID int) (bool, error) {
+ var scoped bool
+ err := r.db.QueryRow(`SELECT EXISTS(
+ SELECT 1 FROM zammad_connections zc
+ WHERE zc.provider_id = ? AND (
+ zc.applies_to_all_workspaces = true OR EXISTS (
+ SELECT 1 FROM zammad_connection_workspaces zcw
+ WHERE zcw.provider_id = zc.provider_id AND zcw.workspace_id = ?
+ )
+ ))`, id, workspaceID).Scan(&scoped)
+ return scoped, err
+}
+
func (r *ZammadRepository) CreateConnection(connection *models.ZammadConnection) error {
closedJSON, err := json.Marshal(connection.ClosedStateIDs)
if err != nil {
@@ -238,6 +254,33 @@ func (r *ZammadRepository) HasTicketLinksForConnectionTx(tx database.Tx, id stri
return hasLinks, err
}
+func (r *ZammadRepository) HasTicketLinksForItemsTx(tx database.Tx, itemIDs []int) (bool, error) {
+ if len(itemIDs) == 0 {
+ return false, nil
+ }
+ placeholders, args := inPlaceholders(itemIDs)
+ var hasLinks bool
+ err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM zammad_ticket_links WHERE item_id IN ("+placeholders+"))", args...).Scan(&hasLinks)
+ if err != nil {
+ return false, fmt.Errorf("check Zammad ticket links for items: %w", err)
+ }
+ return hasLinks, nil
+}
+
+func (r *ZammadRepository) HasTicketLinksForWorkspaceTx(tx database.Tx, workspaceID int) (bool, error) {
+ var hasLinks bool
+ err := tx.QueryRow(`SELECT EXISTS(
+ SELECT 1
+ FROM zammad_ticket_links ztl
+ JOIN items i ON i.id = ztl.item_id
+ WHERE i.workspace_id = ?
+ )`, workspaceID).Scan(&hasLinks)
+ if err != nil {
+ return false, fmt.Errorf("check Zammad ticket links for workspace: %w", err)
+ }
+ return hasLinks, nil
+}
+
// LockConnectionTx is the first step of every connection mutation and ticket
// reservation. PostgreSQL uses an explicit row lock; SQLite write transactions
// are already started with _txlock=immediate.
diff --git a/internal/restapi/v1/handlers/items.go b/internal/restapi/v1/handlers/items.go
index e3f6c8f79..544b91b96 100644
--- a/internal/restapi/v1/handlers/items.go
+++ b/internal/restapi/v1/handlers/items.go
@@ -885,6 +885,7 @@ func (h *ItemHandler) Transition(w http.ResponseWriter, r *http.Request) {
// @Failure 401 {object} handlers.ErrorResponse
// @Failure 403 {object} handlers.ErrorResponse "Token lacks the items:delete scope"
// @Failure 404 {object} handlers.ErrorResponse "Item not found or not visible to caller"
+// @Failure 409 {object} handlers.ErrorResponse "Item or descendant still has a linked Zammad ticket"
// @Failure 500 {object} handlers.ErrorResponse
// @Router /items/{id} [delete]
func (h *ItemHandler) Delete(w http.ResponseWriter, r *http.Request) {
@@ -908,6 +909,14 @@ func (h *ItemHandler) Delete(w http.ResponseWriter, r *http.Request) {
h.RespondError(w, r, restapi.ErrItemNotFound)
return
}
+ if errors.Is(err, services.ErrItemHasZammadTicketLinks) {
+ h.RespondError(w, r, restapi.NewAPIError(
+ http.StatusConflict,
+ restapi.ErrCodeConflict,
+ "Unlink all Zammad tickets from the affected items before deleting them.",
+ ))
+ return
+ }
h.RespondInternalError(w, r)
return
}
diff --git a/internal/restapi/v1/handlers/workspaces.go b/internal/restapi/v1/handlers/workspaces.go
index e0c17f3be..7b27b9f86 100644
--- a/internal/restapi/v1/handlers/workspaces.go
+++ b/internal/restapi/v1/handlers/workspaces.go
@@ -370,6 +370,7 @@ func (h *WorkspaceHandler) Update(w http.ResponseWriter, r *http.Request) {
// @Failure 401 {object} handlers.ErrorResponse
// @Failure 403 {object} handlers.ErrorResponse "Token lacks the workspaces:delete scope"
// @Failure 404 {object} handlers.ErrorResponse "Workspace not found or caller cannot delete it"
+// @Failure 409 {object} handlers.ErrorResponse "Workspace still has items linked to Zammad tickets"
// @Failure 500 {object} handlers.ErrorResponse
// @Router /workspaces/{id} [delete]
func (h *WorkspaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
@@ -397,6 +398,14 @@ func (h *WorkspaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
h.RespondError(w, r, restapi.ErrWorkspaceNotFound)
return
}
+ if errors.Is(err, services.ErrWorkspaceHasZammadTicketLinks) {
+ h.RespondError(w, r, restapi.NewAPIError(
+ http.StatusConflict,
+ restapi.ErrCodeConflict,
+ "Unlink all Zammad tickets from this workspace before deleting it.",
+ ))
+ return
+ }
h.RespondInternalError(w, r)
return
}
diff --git a/internal/services/item_crud_service.go b/internal/services/item_crud_service.go
index fbd2c0d2b..fe20a2593 100644
--- a/internal/services/item_crud_service.go
+++ b/internal/services/item_crud_service.go
@@ -18,8 +18,9 @@ import (
)
var (
- ErrCollectionNotFound = errors.New("collection not found")
- ErrQLQuery = errors.New("QL query error")
+ ErrCollectionNotFound = errors.New("collection not found")
+ ErrQLQuery = errors.New("QL query error")
+ ErrItemHasZammadTicketLinks = errors.New("item has linked Zammad tickets")
)
// ItemCRUDService handles item CRUD operations
@@ -27,6 +28,7 @@ type ItemCRUDService struct {
db database.Database
repo *repository.ItemRepository
workspaceRepo *repository.WorkspaceRepository
+ zammadRepo *repository.ZammadRepository
}
// NewItemCRUDService creates a new item CRUD service
@@ -35,6 +37,7 @@ func NewItemCRUDService(db database.Database) *ItemCRUDService {
db: db,
repo: repository.NewItemRepository(db),
workspaceRepo: repository.NewWorkspaceRepository(db),
+ zammadRepo: repository.NewZammadRepository(db),
}
}
@@ -95,6 +98,13 @@ func (s *ItemCRUDService) DeleteSingleWithMetadata(itemID int, metadata itemeven
if err != nil {
return err
}
+ hasZammadLinks, err := s.zammadRepo.HasTicketLinksForItemsTx(tx, []int{itemID})
+ if err != nil {
+ return err
+ }
+ if hasZammadLinks {
+ return ErrItemHasZammadTicketLinks
+ }
if metadata.OccurredAt.IsZero() {
metadata.OccurredAt = time.Now()
}
@@ -157,6 +167,13 @@ func (s *ItemCRUDService) DeleteWithMetadata(itemID int, metadata itemevents.Met
if len(items) != len(allIDs) {
return repository.ErrNotFound
}
+ hasZammadLinks, err := s.zammadRepo.HasTicketLinksForItemsTx(tx, allIDs)
+ if err != nil {
+ return err
+ }
+ if hasZammadLinks {
+ return ErrItemHasZammadTicketLinks
+ }
if metadata.OccurredAt.IsZero() {
metadata.OccurredAt = time.Now()
}
diff --git a/internal/services/user_offboard.go b/internal/services/user_offboard.go
index bf8a48dcf..37b947d7a 100644
--- a/internal/services/user_offboard.go
+++ b/internal/services/user_offboard.go
@@ -1,6 +1,8 @@
package services
import (
+ "database/sql"
+ "errors"
"fmt"
"log/slog"
@@ -14,6 +16,10 @@ type UserNotificationDeleter interface {
DeleteUserNotifications(userID int) error
}
+// ErrUserOffboardingHasZammadTicketLinks prevents deleting a personal
+// workspace while its Zammad tickets would lose their Windshift item links.
+var ErrUserOffboardingHasZammadTicketLinks = errors.New("user has linked Zammad tickets in personal workspace")
+
// OffboardUser deactivates a user and anonymizes their PII while preserving
// audit trails. The user row is kept (anonymized) so that FK references from
// item_history, comments, time_worklogs, etc. remain valid.
@@ -28,6 +34,35 @@ func OffboardUser(db database.Database, userID int, notificationDeleter UserNoti
}
defer func() { _ = tx.Rollback() }()
+ // Resolve the personal workspace and protect its Zammad links before any
+ // mutation in this transaction. Offboarding must not orphan linked tickets.
+ var personalWsID *int
+ var wsID int
+ personalWorkspaceQuery := `SELECT id FROM workspaces WHERE is_personal = true AND owner_id = ?`
+ if db.GetDriverName() == "postgres" {
+ personalWorkspaceQuery += " FOR UPDATE"
+ }
+ row := tx.QueryRow(personalWorkspaceQuery, userID)
+ if err := row.Scan(&wsID); err == nil {
+ personalWsID = &wsID
+ } else if !errors.Is(err, sql.ErrNoRows) {
+ return nil, fmt.Errorf("failed to find personal workspace: %w", err)
+ }
+ itemRepo := repository.NewItemRepository(db)
+ if personalWsID != nil {
+ if err := itemRepo.LockWorkspaceItemsTx(tx, *personalWsID); err != nil {
+ return nil, err
+ }
+ zammadRepo := repository.NewZammadRepository(db)
+ hasLinks, err := zammadRepo.HasTicketLinksForWorkspaceTx(tx, *personalWsID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to check personal workspace Zammad links: %w", err)
+ }
+ if hasLinks {
+ return nil, ErrUserOffboardingHasZammadTicketLinks
+ }
+ }
+
// a) Anonymize user record
if _, err := tx.Exec(`
UPDATE users SET
@@ -51,13 +86,6 @@ func OffboardUser(db database.Database, userID int, notificationDeleter UserNoti
}
// b) Delete personal workspace (items cascade via FK)
- var personalWsID *int
- row := tx.QueryRow(`SELECT id FROM workspaces WHERE is_personal = true AND owner_id = ?`, userID)
- var wsID int
- if err := row.Scan(&wsID); err == nil {
- personalWsID = &wsID
- }
- itemRepo := repository.NewItemRepository(db)
if personalWsID != nil {
if err := itemRepo.DeleteByWorkspaceTx(tx, *personalWsID); err != nil {
return nil, fmt.Errorf("failed to delete personal workspace items: %w", err)
diff --git a/internal/services/workspace_service.go b/internal/services/workspace_service.go
index ec02dd6ae..0fec81ec6 100644
--- a/internal/services/workspace_service.go
+++ b/internal/services/workspace_service.go
@@ -13,21 +13,27 @@ import (
"windshift/internal/repository"
)
+var ErrWorkspaceHasZammadTicketLinks = errors.New("workspace has linked Zammad tickets")
+
// WorkspaceService encapsulates workspace business logic used by both HTTP handlers
// and other services.
type WorkspaceService struct {
- db database.Database
- repo *repository.WorkspaceRepository
- templates *repository.WorkspaceTemplateRepository
- access WorkspaceSourceAccess
+ db database.Database
+ repo *repository.WorkspaceRepository
+ itemRepo *repository.ItemRepository
+ templates *repository.WorkspaceTemplateRepository
+ zammadRepo *repository.ZammadRepository
+ access WorkspaceSourceAccess
}
// NewWorkspaceService creates a new WorkspaceService.
func NewWorkspaceService(db database.Database) *WorkspaceService {
return &WorkspaceService{
- db: db,
- repo: repository.NewWorkspaceRepository(db),
- templates: repository.NewWorkspaceTemplateRepository(db),
+ db: db,
+ repo: repository.NewWorkspaceRepository(db),
+ itemRepo: repository.NewItemRepository(db),
+ templates: repository.NewWorkspaceTemplateRepository(db),
+ zammadRepo: repository.NewZammadRepository(db),
}
}
@@ -342,21 +348,39 @@ func nullableUpdateValue[T any](update NullableUpdate[T]) any {
// Delete removes a workspace by ID.
func (s *WorkspaceService) Delete(id int) error {
- // Check workspace exists
- exists, err := s.repo.Exists(id)
- if err != nil {
- return fmt.Errorf("failed to check workspace existence: %w", err)
- }
- if !exists {
- return fmt.Errorf("workspace not found: %d: %w", id, repository.ErrNotFound)
- }
+ if err := database.WithTx(s.db, func(tx database.Tx) error {
+ exists, err := s.repo.LockForDeleteTx(tx, id)
+ if err != nil {
+ return fmt.Errorf("check workspace existence: %w", err)
+ }
+ if !exists {
+ return fmt.Errorf("workspace not found: %d: %w", id, repository.ErrNotFound)
+ }
+ if err := s.itemRepo.LockWorkspaceItemsTx(tx, id); err != nil {
+ return err
+ }
- // Delete workspace (cascade will handle related records)
- err = s.repo.Delete(id)
- if err != nil {
- return fmt.Errorf("failed to delete workspace: %w", err)
+ hasZammadLinks, err := s.zammadRepo.HasTicketLinksForWorkspaceTx(tx, id)
+ if err != nil {
+ return err
+ }
+ if hasZammadLinks {
+ return ErrWorkspaceHasZammadTicketLinks
+ }
+
+ if err := s.repo.DeleteTx(tx, id); err != nil {
+ return fmt.Errorf("delete workspace: %w", err)
+ }
+ if err := s.repo.DropItemSequenceTx(tx, int64(id)); err != nil {
+ return fmt.Errorf("drop workspace item sequence: %w", err)
+ }
+ return nil
+ }); err != nil {
+ return err
}
+ repository.InvalidateItemListCountCache(s.db, id)
+
return nil
}
diff --git a/internal/services/zammad_service.go b/internal/services/zammad_service.go
index 29f175d26..ea1a18cfa 100644
--- a/internal/services/zammad_service.go
+++ b/internal/services/zammad_service.go
@@ -1401,7 +1401,7 @@ func (s *ZammadService) UnlinkTicket(ctx context.Context, linkID string) (*model
if err != nil {
return nil, err
}
- connection, client, err := s.client(ctx, link.ProviderID, item.WorkspaceID)
+ connection, client, err := s.clientForUnlink(ctx, link.ProviderID, item.WorkspaceID)
if err != nil {
return nil, err
}
@@ -1925,6 +1925,27 @@ func (s *ZammadService) client(ctx context.Context, id string, workspaceID int)
return connection, client, nil
}
+// clientForUnlink permits cleanup through a disabled connection while keeping
+// its workspace and managed-credential boundaries intact.
+func (s *ZammadService) clientForUnlink(ctx context.Context, id string, workspaceID int) (*models.ZammadConnection, *zammad.Client, error) {
+ connection, err := s.repo.GetConnection(id)
+ if err != nil {
+ return nil, nil, err
+ }
+ scoped, err := s.repo.IsConnectionScopedToWorkspace(id, workspaceID)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !scoped {
+ return nil, nil, ErrCredentialScopeMismatch
+ }
+ client, err := s.clientForConnection(ctx, connection, workspaceID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return connection, client, nil
+}
+
func (s *ZammadService) clientForConnection(ctx context.Context, connection *models.ZammadConnection, workspaceID int) (*zammad.Client, error) {
transport := s.transportOverride
if transport == nil {
diff --git a/internal/services/zammad_service_test.go b/internal/services/zammad_service_test.go
index 89075afe8..8db4e8d35 100644
--- a/internal/services/zammad_service_test.go
+++ b/internal/services/zammad_service_test.go
@@ -750,6 +750,197 @@ func TestZammadUnlinkClearsOnlyExactRemoteCorrelationWithoutDeletingTicket(t *te
}
}
+func TestZammadDisabledConnectionCanBeUnlinkedAndDeleted(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ disabled := false
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.UnlinkTicket(context.Background(), link.ID); err != nil {
+ t.Fatalf("unlink through disabled connection failed: %v", err)
+ }
+ if f.transport.ticket["windshift_item_key"] != "" {
+ t.Fatalf("disabled-connection unlink left remote correlation: %#v", f.transport.ticket)
+ }
+ if _, err := f.service.GetTicketLink(link.ID); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("disabled-connection unlink retained local link: %v", err)
+ }
+ if err := f.service.DeleteConnection(f.connection.ProviderID); err != nil {
+ t.Fatalf("connection remained undeletable after unlink: %v", err)
+ }
+}
+
+func TestZammadUnlinkStillRequiresWorkspaceScope(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite("DELETE FROM zammad_connection_workspaces WHERE provider_id = ?", f.connection.ProviderID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.UnlinkTicket(context.Background(), link.ID); !errors.Is(err, ErrCredentialScopeMismatch) {
+ t.Fatalf("out-of-scope unlink returned %v", err)
+ }
+ if _, err := f.service.GetTicketLink(link.ID); err != nil {
+ t.Fatalf("out-of-scope unlink removed local link: %v", err)
+ }
+ if f.transport.ticket["windshift_item_key"] != link.CorrelationKey {
+ t.Fatalf("out-of-scope unlink changed remote correlation: %#v", f.transport.ticket)
+ }
+}
+
+func TestZammadUnlinkStillRequiresEnabledCredential(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite("UPDATE action_credentials SET is_enabled = false WHERE id = ?", f.connection.CredentialID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.UnlinkTicket(context.Background(), link.ID); !errors.Is(err, ErrCredentialDisabled) {
+ t.Fatalf("disabled-credential unlink returned %v", err)
+ }
+ if _, err := f.service.GetTicketLink(link.ID); err != nil {
+ t.Fatalf("disabled-credential unlink removed local link: %v", err)
+ }
+ if f.transport.ticket["windshift_item_key"] != link.CorrelationKey {
+ t.Fatalf("disabled-credential unlink changed remote correlation: %#v", f.transport.ticket)
+ }
+}
+
+func TestZammadLinkedItemDeletionRequiresExplicitUnlink(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := f.db.ExecWrite("DELETE FROM items WHERE id = ?", f.item1); err == nil {
+ t.Fatal("database allowed direct deletion of a Zammad-linked item")
+ }
+ crud := NewItemCRUDService(f.db)
+ if _, err := crud.Delete(f.item1); !errors.Is(err, ErrItemHasZammadTicketLinks) {
+ t.Fatalf("linked item deletion returned %v", err)
+ }
+ if _, err := repository.NewItemRepository(f.db).FindByID(f.item1); err != nil {
+ t.Fatalf("rejected deletion removed item: %v", err)
+ }
+ if _, err := f.service.GetTicketLink(link.ID); err != nil {
+ t.Fatalf("rejected deletion removed Zammad link: %v", err)
+ }
+ var genericLinkCount int
+ if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE id = ?", link.ItemIntegrationLinkID).Scan(&genericLinkCount); err != nil || genericLinkCount != 1 {
+ t.Fatalf("rejected deletion removed generic item link: count=%d err=%v", genericLinkCount, err)
+ }
+
+ if _, err := f.service.UnlinkTicket(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := crud.Delete(f.item1); err != nil {
+ t.Fatalf("item remained undeletable after explicit unlink: %v", err)
+ }
+ if _, err := repository.NewItemRepository(f.db).FindByID(f.item1); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("item remained after unlink and delete: %v", err)
+ }
+}
+
+func TestZammadCascadeDeletionIsBlockedByLinkedDescendant(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ childID := mustInsertID(t, f.db, `INSERT INTO items
+ (workspace_id, workspace_item_number, parent_id, title, description, frac_index, status_id, creator_id, last_active_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
+ f.workspace1, 50, f.item1, "Linked child", "Synthetic child", "a0V", f.openStatus, f.actorID)
+ if _, err := f.service.CreateTicket(context.Background(), childID, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := NewItemCRUDService(f.db).Delete(f.item1); !errors.Is(err, ErrItemHasZammadTicketLinks) {
+ t.Fatalf("cascade with linked descendant returned %v", err)
+ }
+ itemRepo := repository.NewItemRepository(f.db)
+ if _, err := itemRepo.FindByID(f.item1); err != nil {
+ t.Fatalf("rejected cascade removed root item: %v", err)
+ }
+ if _, err := itemRepo.FindByID(childID); err != nil {
+ t.Fatalf("rejected cascade removed linked descendant: %v", err)
+ }
+}
+
+func TestZammadLinkedItemsBlockWorkspaceDeletion(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ workspaceService := NewWorkspaceService(f.db)
+ if err := workspaceService.Delete(f.workspace1); !errors.Is(err, ErrWorkspaceHasZammadTicketLinks) {
+ t.Fatalf("workspace deletion with linked item returned %v", err)
+ }
+ if exists, err := repository.NewWorkspaceRepository(f.db).Exists(f.workspace1); err != nil || !exists {
+ t.Fatalf("rejected workspace deletion removed workspace: exists=%t err=%v", exists, err)
+ }
+ if _, err := repository.NewItemRepository(f.db).FindByID(f.item1); err != nil {
+ t.Fatalf("rejected workspace deletion removed item: %v", err)
+ }
+ if _, err := f.service.GetTicketLink(link.ID); err != nil {
+ t.Fatalf("rejected workspace deletion removed Zammad link: %v", err)
+ }
+
+ if _, err := f.service.UnlinkTicket(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := workspaceService.Delete(f.workspace1); err != nil {
+ t.Fatalf("workspace remained undeletable after explicit unlink: %v", err)
+ }
+ if exists, err := repository.NewWorkspaceRepository(f.db).Exists(f.workspace1); err != nil || exists {
+ t.Fatalf("workspace remained after unlink and delete: exists=%t err=%v", exists, err)
+ }
+}
+
+func TestZammadLinkedPersonalWorkspaceBlocksUserOffboardingBeforeMutation(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ if _, err := f.db.ExecWrite("UPDATE workspaces SET is_personal = true, owner_id = ? WHERE id = ?", f.actorID, f.workspace1); err != nil {
+ t.Fatal(err)
+ }
+ link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var beforeEmail, beforeUsername string
+ var beforeActive bool
+ if err := f.db.QueryRow("SELECT email, username, is_active FROM users WHERE id = ?", f.actorID).Scan(&beforeEmail, &beforeUsername, &beforeActive); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := OffboardUser(f.db, f.actorID, nil); !errors.Is(err, ErrUserOffboardingHasZammadTicketLinks) {
+ t.Fatalf("offboarding with linked personal item returned %v", err)
+ }
+ var afterEmail, afterUsername string
+ var afterActive bool
+ if err := f.db.QueryRow("SELECT email, username, is_active FROM users WHERE id = ?", f.actorID).Scan(&afterEmail, &afterUsername, &afterActive); err != nil {
+ t.Fatal(err)
+ }
+ if beforeEmail != afterEmail || beforeUsername != afterUsername || beforeActive != afterActive {
+ t.Fatalf("blocked offboarding mutated user: before=(%q,%q,%t) after=(%q,%q,%t)", beforeEmail, beforeUsername, beforeActive, afterEmail, afterUsername, afterActive)
+ }
+ if exists, err := repository.NewWorkspaceRepository(f.db).Exists(f.workspace1); err != nil || !exists {
+ t.Fatalf("blocked offboarding removed personal workspace: exists=%t err=%v", exists, err)
+ }
+ if _, err := repository.NewItemRepository(f.db).FindByID(f.item1); err != nil {
+ t.Fatalf("blocked offboarding removed item: %v", err)
+ }
+ if _, err := f.service.GetTicketLink(link.ID); err != nil {
+ t.Fatalf("blocked offboarding removed Zammad link: %v", err)
+ }
+}
+
func TestZammadUnlinkUpstreamFailurePreservesLocalLink(t *testing.T) {
f := newZammadServiceFixture(t, nil)
link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID})
From 87189b213b85fe455a3d5544bb2d602e2fdc707e Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 05:00:27 +0200
Subject: [PATCH 04/11] fix: persist Zammad default group selection
---
.../settings/ZammadConnectionManager.spec.js | 118 ++++++++++++++++++
.../settings/ZammadConnectionManager.svelte | 4 +-
frontend/vite.config.js | 2 +
3 files changed, 122 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/lib/settings/ZammadConnectionManager.spec.js
diff --git a/frontend/src/lib/settings/ZammadConnectionManager.spec.js b/frontend/src/lib/settings/ZammadConnectionManager.spec.js
new file mode 100644
index 000000000..8a0dad239
--- /dev/null
+++ b/frontend/src/lib/settings/ZammadConnectionManager.spec.js
@@ -0,0 +1,118 @@
+/** @vitest-environment jsdom */
+
+import '@testing-library/jest-dom/vitest';
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import ZammadConnectionManager from './ZammadConnectionManager.svelte';
+
+const mocks = vi.hoisted(() => ({
+ getConnections: vi.fn(),
+ getWorkspaces: vi.fn(),
+ getStatuses: vi.fn(),
+ testConnection: vi.fn(),
+ updateConnection: vi.fn(),
+}));
+
+vi.mock('../api.js', () => ({
+ api: {
+ zammadConnections: {
+ getAll: mocks.getConnections,
+ test: mocks.testConnection,
+ update: mocks.updateConnection,
+ },
+ workspaces: { getAll: mocks.getWorkspaces },
+ statuses: { getAll: mocks.getStatuses },
+ },
+}));
+
+vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key }));
+vi.mock('../stores/toasts.svelte.js', () => ({
+ successToast: vi.fn(),
+ errorToast: vi.fn(),
+ warningToast: vi.fn(),
+}));
+vi.mock('../composables/useConfirm.js', () => ({ confirm: vi.fn() }));
+
+const connection = {
+ id: 'zammad-dev',
+ slug: 'zammad-dev',
+ name: 'Zammad Dev',
+ enabled: true,
+ base_url: 'https://zammad.example.test',
+ auth_method: 'oauth',
+ oauth_client_id: 'client-id',
+ has_oauth_client_secret: true,
+ oauth_connected: true,
+ default_group_id: 0,
+ default_group_name: 'Windshift',
+ allowed_group_ids: [2, 3],
+ default_customer: 'windshift@example.test',
+ correlation_field: 'windshift_item_key',
+ closed_state_ids: [4],
+ applies_to_all_workspaces: true,
+ workspace_ids: [],
+};
+
+describe('ZammadConnectionManager', () => {
+ beforeAll(() => {
+ Object.defineProperty(Element.prototype, 'animate', {
+ configurable: true,
+ value: vi.fn(() => {
+ const animation = {
+ cancel: vi.fn(),
+ currentTime: 0,
+ effect: {},
+ onfinish: null,
+ playState: 'finished',
+ };
+ queueMicrotask(() => animation.onfinish?.());
+ return animation;
+ }),
+ });
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getConnections.mockResolvedValue([connection]);
+ mocks.getWorkspaces.mockResolvedValue([]);
+ mocks.getStatuses.mockResolvedValue([]);
+ mocks.testConnection.mockResolvedValue({
+ metadata: {
+ groups: [
+ { id: 2, name: 'Windshift' },
+ { id: 3, name: 'Windshift Escalation' },
+ ],
+ states: [],
+ correlation_field_verified: true,
+ },
+ });
+ mocks.updateConnection.mockResolvedValue(connection);
+ });
+
+ afterEach(cleanup);
+
+ it('persists the selected default group ID and name', async () => {
+ const { container } = render(ZammadConnectionManager);
+ await screen.findByText('Zammad Dev');
+
+ await fireEvent.click(screen.getByRole('button', { name: 'zammad.testConnection' }));
+ await waitFor(() => expect(mocks.testConnection).toHaveBeenCalledWith('zammad-dev'));
+
+ const editButton = container.querySelector('svg.lucide-pen')?.closest('button');
+ expect(editButton).toBeTruthy();
+ await fireEvent.click(editButton);
+
+ const dialog = screen.getByRole('dialog');
+ await fireEvent.change(within(dialog).getAllByRole('combobox')[0], {
+ target: { value: '2' },
+ });
+ await fireEvent.click(within(dialog).getByRole('button', { name: 'common.update' }));
+
+ await waitFor(() =>
+ expect(mocks.updateConnection).toHaveBeenCalledWith(
+ 'zammad-dev',
+ expect.objectContaining({ default_group_id: 2, default_group_name: 'Windshift' })
+ )
+ );
+ });
+});
diff --git a/frontend/src/lib/settings/ZammadConnectionManager.svelte b/frontend/src/lib/settings/ZammadConnectionManager.svelte
index 3069b1732..761558c85 100644
--- a/frontend/src/lib/settings/ZammadConnectionManager.svelte
+++ b/frontend/src/lib/settings/ZammadConnectionManager.svelte
@@ -247,8 +247,8 @@
: form.allowed_group_ids.filter((groupId) => groupId !== id);
}
- function selectDefaultGroup(event) {
- form.default_group_id = event.currentTarget.value;
+ function selectDefaultGroup(value) {
+ form.default_group_id = value;
const group = metadataByConnection[editing?.id]?.groups?.find(
(entry) => entry.id === Number(form.default_group_id),
);
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 0baad8934..659496787 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,5 +1,6 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import tailwindcss from '@tailwindcss/vite';
+import { svelteTesting } from '@testing-library/svelte/vite';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import { excalidrawAssetsPlugin } from './scripts/excalidraw-assets.js';
@@ -30,6 +31,7 @@ export default defineConfig({
base: './',
plugins: [
svelte(), // Uses svelte.config.js for preprocessors
+ svelteTesting(),
tailwindcss(),
excalidrawAssetsPlugin(),
...(enableBundleAnalyzer
From f0b360e5a148ad80461e26db3864b0b98548d77e Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 10:47:05 +0200
Subject: [PATCH 05/11] feat: improve Zammad ticket synchronization
---
docs/zammad-integration.md | 5 +-
frontend/src/lib/api/integrations.js | 1 +
frontend/src/lib/api/integrations.spec.js | 25 ++++
.../composables/useItemEventStream.svelte.js | 4 +-
.../src/lib/features/items/ItemDetail.svelte | 2 +
.../lib/features/items/ZammadItemPanel.svelte | 7 +
frontend/src/lib/locales/ar/zammad.js | 3 +-
frontend/src/lib/locales/de/zammad.js | 4 +
frontend/src/lib/locales/en/zammad.js | 6 +-
frontend/src/lib/locales/es/zammad.js | 3 +-
frontend/src/lib/locales/pt-BR/zammad.js | 3 +-
frontend/src/lib/locales/ru/index.js | 2 +
frontend/src/lib/locales/ru/zammad.js | 105 +++++++++++++
frontend/src/lib/locales/zh-CN/zammad.js | 1 +
.../settings/ZammadConnectionManager.svelte | 31 +++-
.../database/schema/integrations_postgres.sql | 1 -
internal/handlers/zammad.go | 15 ++
internal/handlers/zammad_test.go | 18 +++
internal/logger/audit.go | 1 +
internal/repository/zammad_repository.go | 31 ++++
internal/routes/integrations.go | 1 +
internal/scheduler/zammad_sync_scheduler.go | 84 +++++++++--
.../scheduler/zammad_sync_scheduler_test.go | 140 ++++++++++++++++++
internal/server/server.go | 1 +
internal/services/action_service.go | 39 +----
internal/services/item_change_publisher.go | 1 +
internal/services/zammad_service.go | 63 +++++++-
internal/services/zammad_service_test.go | 113 ++++++++++++++
28 files changed, 649 insertions(+), 61 deletions(-)
create mode 100644 frontend/src/lib/locales/ru/zammad.js
create mode 100644 internal/scheduler/zammad_sync_scheduler_test.go
diff --git a/docs/zammad-integration.md b/docs/zammad-integration.md
index 707aaa752..e190840d3 100644
--- a/docs/zammad-integration.md
+++ b/docs/zammad-integration.md
@@ -13,6 +13,8 @@ Admin connection endpoints are `GET|POST /api/admin/zammad-connections`, `GET|PU
They list, create, inspect, update, delete, test, and authorize connections without returning any token or client secret.
The OAuth callback is `GET /api/integrations/zammad/oauth/callback`.
The explicit administrator retry is `POST /api/admin/zammad-ticket-links/{linkId}/retry-create`.
+`POST /api/admin/zammad-ticket-links/refresh` queues an asynchronous synchronization of every complete ticket link on enabled, authorized connections.
+Concurrent system-wide refresh requests are coalesced into the already queued or running job.
Workspace and item routes are `GET /api/workspaces/{workspaceId}/zammad-connections`, `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/metadata`, and `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/owners?group_id={groupId}`.
Ticket routes are `GET /api/items/{id}/zammad-links`, `POST /api/items/{id}/zammad-tickets`, `POST /api/items/{id}/zammad-ticket-links`, `PUT /api/zammad-ticket-links/{linkId}`, `DELETE /api/zammad-ticket-links/{linkId}`, and `POST /api/zammad-ticket-links/{linkId}/refresh`.
@@ -75,7 +77,8 @@ Deleting a connection is rejected while ticket links still exist, so each link m
## Refresh, backoff, and recovery
`POST /api/zammad-ticket-links/{linkId}/refresh` performs an immediate remote read and updates the stored snapshot.
-A background scheduler also polls linked tickets approximately every two minutes.
+A background scheduler also polls linked tickets approximately every two minutes after the previous run completes.
+Successful refreshes and persisted synchronization failures publish an item event so an open item panel reloads its Zammad snapshot without a page refresh.
Failed synchronization records a sanitized error and schedules another attempt after a retry delay.
Fair ordering by the most recent attempt prevents repeatedly failing links from starving healthy links.
Remote response bodies and credentials are never exposed to clients.
diff --git a/frontend/src/lib/api/integrations.js b/frontend/src/lib/api/integrations.js
index 5c2007f66..53f7ccc55 100644
--- a/frontend/src/lib/api/integrations.js
+++ b/frontend/src/lib/api/integrations.js
@@ -63,6 +63,7 @@ export const zammadConnections = {
}),
delete: (id) => fetchAPI(`/admin/zammad-connections/${id}`, { method: 'DELETE' }),
test: (id) => fetchAPI(`/admin/zammad-connections/${id}/test`, { method: 'POST' }),
+ refreshAllTickets: () => fetchAPI('/admin/zammad-ticket-links/refresh', { method: 'POST' }),
startOAuth: (id) => fetchAPI(`/admin/zammad-connections/${id}/oauth/start`, { method: 'POST' }),
forWorkspace: (workspaceId) => fetchAPI(`/workspaces/${workspaceId}/zammad-connections`),
metadata: (workspaceId, id) =>
diff --git a/frontend/src/lib/api/integrations.spec.js b/frontend/src/lib/api/integrations.spec.js
index e6d70ab18..3a75828a2 100644
--- a/frontend/src/lib/api/integrations.spec.js
+++ b/frontend/src/lib/api/integrations.spec.js
@@ -28,6 +28,31 @@ describe('zammadConnections.startOAuth', () => {
});
});
+describe('zammadConnections.refreshAllTickets', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('starts a system-wide ticket refresh', async () => {
+ const result = { started: true };
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(result), {
+ status: 202,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(zammadConnections.refreshAllTickets()).resolves.toEqual(result);
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, options] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/admin/zammad-ticket-links/refresh');
+ expect(options.method).toBe('POST');
+ expect(options.body).toBeUndefined();
+ });
+});
+
describe('Zammad ticket-link API', () => {
afterEach(() => {
vi.unstubAllGlobals();
diff --git a/frontend/src/lib/composables/useItemEventStream.svelte.js b/frontend/src/lib/composables/useItemEventStream.svelte.js
index 0e02f86ec..37582b2ba 100644
--- a/frontend/src/lib/composables/useItemEventStream.svelte.js
+++ b/frontend/src/lib/composables/useItemEventStream.svelte.js
@@ -9,7 +9,7 @@ const DEBOUNCE_MS = 250;
* initial load. Polling remains the fallback while disconnected.
*
* @param {() => (number|string|null|undefined)} getItemId
- * @param {{ onReconcile?: Function, onItem?: Function, onChildren?: Function, onComment?: Function, onLinks?: Function, onDeleted?: Function }} handlers
+ * @param {{ onReconcile?: Function, onItem?: Function, onChildren?: Function, onComment?: Function, onLinks?: Function, onZammad?: Function, onDeleted?: Function }} handlers
* @returns {{ readonly connected: boolean }}
*/
export function useItemEventStream(getItemId, handlers = {}) {
@@ -37,6 +37,7 @@ export function useItemEventStream(getItemId, handlers = {}) {
if (kinds.has('children')) handlers.onChildren?.();
if (kinds.has('comment')) handlers.onComment?.();
if (kinds.has('links')) handlers.onLinks?.();
+ if (kinds.has('zammad')) handlers.onZammad?.();
if (kinds.has('deleted')) handlers.onDeleted?.();
};
const schedule = (...kinds) => {
@@ -69,6 +70,7 @@ export function useItemEventStream(getItemId, handlers = {}) {
handlers.onDeleted?.();
});
es.addEventListener('link', () => schedule('links'));
+ es.addEventListener('zammad', () => schedule('zammad'));
// The browser auto-reconnects (honoring the server's retry hint). Until it
// does, mark disconnected so the components' pollers resume as the fallback.
es.onerror = () => {
diff --git a/frontend/src/lib/features/items/ItemDetail.svelte b/frontend/src/lib/features/items/ItemDetail.svelte
index ecf991b97..9cce1e993 100644
--- a/frontend/src/lib/features/items/ItemDetail.svelte
+++ b/frontend/src/lib/features/items/ItemDetail.svelte
@@ -86,6 +86,7 @@ import NativeSelect from '../../components/NativeSelect.svelte';
}
window.dispatchEvent(new CustomEvent('item-comments-changed', { detail: { itemId } }));
window.dispatchEvent(new CustomEvent('item-scm-links-changed', { detail: { itemId } }));
+ window.dispatchEvent(new CustomEvent('item-zammad-links-changed', { detail: { itemId } }));
},
onItem: () => itemDetailStore.refreshCurrentItem().catch((err) => console.error('SSE item refresh failed:', err)),
onChildren: () => itemDetailStore.loadChildItems().catch((err) => console.error('SSE children refresh failed:', err)),
@@ -96,6 +97,7 @@ import NativeSelect from '../../components/NativeSelect.svelte';
itemDetailStore.loadLinks().catch((err) => console.error('SSE links refresh failed:', err));
window.dispatchEvent(new CustomEvent('item-scm-links-changed', { detail: { itemId } }));
},
+ onZammad: () => window.dispatchEvent(new CustomEvent('item-zammad-links-changed', { detail: { itemId } })),
// The viewed item was deleted (its own topic published `deleted`). This is
// authoritative — mark it gone so the view closes instead of refetching
// (which would 404) and showing stale data.
diff --git a/frontend/src/lib/features/items/ZammadItemPanel.svelte b/frontend/src/lib/features/items/ZammadItemPanel.svelte
index 347e3db11..efeb56ebe 100644
--- a/frontend/src/lib/features/items/ZammadItemPanel.svelte
+++ b/frontend/src/lib/features/items/ZammadItemPanel.svelte
@@ -1,4 +1,5 @@
+
+
+
+ {#if failed}
+
{t('zammad.returnLinkFailed')}
+
+ {:else}
+
+
{t('zammad.returnLinkResolving')}
+ {/if}
+
+
diff --git a/frontend/src/lib/locales/ar/zammad.js b/frontend/src/lib/locales/ar/zammad.js
index cc2a3f3ce..2a505b89d 100644
--- a/frontend/src/lib/locales/ar/zammad.js
+++ b/frontend/src/lib/locales/ar/zammad.js
@@ -19,6 +19,6 @@ export default { zammad: {
connectionUnavailable: 'يتطلب اتصال Zammad واحد أو أكثر تفويضًا، ولا يمكن استخدامه الآن لإجراءات التذاكر.',
loadLinksFailed: 'تعذر تحميل روابط تذاكر Zammad', loadMetadataFailed: 'تعذر تحميل مجموعات Zammad وحالاتها', loadOwnersFailed: 'تعذر تحميل مسؤولي Zammad', ticketCreated: 'تم إنشاء تذكرة Zammad',
ticketCreationStarted: 'إنشاء تذكرة Zammad قيد التنفيذ بالفعل', ticketCreationInProgress: 'يجري إنشاء التذكرة. تصبح التحديثات متاحة بعد ربط التذكرة.', ticketCreateFailed: 'تعذر إنشاء تذكرة Zammad', linkExistingTicket: 'ربط تذكرة موجودة', linkExistingTicketConfirm: 'اربط تذكرة Zammad موجودة باستخدام رقم التذكرة.', ticketNumberLabel: 'رقم التذكرة', ticketNumberPlaceholder: 'مثال: 12345', ticketLinked: 'تم ربط تذكرة Zammad', ticketLinkFailed: 'تعذر ربط تذكرة Zammad', editTicket: 'تعديل تذكرة Zammad', ticketUpdated: 'تم تحديث تذكرة Zammad', ticketUpdateFailed: 'تعذر تحديث تذكرة Zammad', ticketRefreshed: 'تم تحديث حالة تذكرة Zammad',
- ticketRefreshFailed: 'تعذر تحديث تذكرة Zammad', retrySync: 'إعادة محاولة المزامنة', refreshTicket: 'تحديث حالة التذكرة', syncFailedNoTicket: 'فشلت المزامنة. تصبح التحديثات متاحة بعد ربط تذكرة Zammad.', creationUncertainNoTicket: 'نتيجة إنشاء التذكرة غير مؤكدة. تحقق من Zammad واربط التذكرة الموجودة بالرقم إذا لزم الأمر.', removeTicketLink: 'إزالة التعيين', removeTicketLinkConfirm: 'ستتم إزالة التعيين إلى Zammad رقم {number} وحقل الربط في Windshift. ستبقى تذكرة Zammad نفسها موجودة.', ticketLinkRemoved: 'تمت إزالة تعيين تذكرة Zammad', ticketLinkRemoveFailed: 'تعذر إزالة تعيين تذكرة Zammad', ticketNumber: 'Zammad رقم {number}', lastSynced: 'تمت المزامنة: {time}',
+ ticketRefreshFailed: 'تعذر تحديث تذكرة Zammad', retrySync: 'إعادة محاولة المزامنة', refreshTicket: 'تحديث حالة التذكرة', syncFailedNoTicket: 'فشلت المزامنة. تصبح التحديثات متاحة بعد ربط تذكرة Zammad.', creationUncertainNoTicket: 'نتيجة إنشاء التذكرة غير مؤكدة. تحقق من Zammad واربط التذكرة الموجودة بالرقم إذا لزم الأمر.', removeTicketLink: 'إزالة التعيين', removeTicketLinkConfirm: 'ستتم إزالة التعيين إلى Zammad رقم {number} وحقل الربط في Windshift. ستبقى تذكرة Zammad نفسها موجودة.', ticketLinkRemoved: 'تمت إزالة تعيين تذكرة Zammad', ticketLinkRemoveFailed: 'تعذر إزالة تعيين تذكرة Zammad', ticketNumber: 'Zammad رقم {number}', lastSynced: 'تمت المزامنة: {time}', returnLinkResolving: 'جار فتح عنصر Windshift المرتبط...', returnLinkFailed: 'تعذر فتح عنصر Windshift المرتبط. ربما لم يعد موجودًا أو ليست لديك صلاحية الوصول إليه.',
syncState: { pending: 'إنشاء التذكرة معلق', creating: 'جار إنشاء التذكرة', linked: 'التذكرة مرتبطة', sync_failed: 'فشلت المزامنة', creation_uncertain: 'نتيجة الإنشاء غير مؤكدة؛ المحاولات الجديدة تبحث فقط عن التذكرة الموجودة' },
} };
diff --git a/frontend/src/lib/locales/de/zammad.js b/frontend/src/lib/locales/de/zammad.js
index 612fbac77..2c3619600 100644
--- a/frontend/src/lib/locales/de/zammad.js
+++ b/frontend/src/lib/locales/de/zammad.js
@@ -95,6 +95,8 @@ export default { zammad: {
ticketLinkRemoveFailed: 'Zammad-Ticketzuordnung konnte nicht entfernt werden',
ticketNumber: 'Zammad #{number}',
lastSynced: 'Synchronisiert: {time}',
+ returnLinkResolving: 'Der verknüpfte Windshift-Eintrag wird geöffnet...',
+ returnLinkFailed: 'Der verknüpfte Windshift-Eintrag konnte nicht geöffnet werden. Er existiert möglicherweise nicht mehr oder du hast keinen Zugriff.',
syncState: {
pending: 'Ticketerstellung ausstehend',
creating: 'Ticket wird erstellt',
diff --git a/frontend/src/lib/locales/en/zammad.js b/frontend/src/lib/locales/en/zammad.js
index f1a0af2b8..705eeae5e 100644
--- a/frontend/src/lib/locales/en/zammad.js
+++ b/frontend/src/lib/locales/en/zammad.js
@@ -95,6 +95,8 @@ export default { zammad: {
ticketLinkRemoveFailed: 'Failed to remove Zammad ticket assignment',
ticketNumber: 'Zammad #{number}',
lastSynced: 'Synced {time}',
+ returnLinkResolving: 'Opening the linked Windshift item...',
+ returnLinkFailed: 'The linked Windshift item could not be opened. It may no longer exist or you may not have access.',
syncState: {
pending: 'Ticket creation pending',
creating: 'Creating ticket',
diff --git a/frontend/src/lib/locales/es/zammad.js b/frontend/src/lib/locales/es/zammad.js
index fa17f5bb2..c4cd9775e 100644
--- a/frontend/src/lib/locales/es/zammad.js
+++ b/frontend/src/lib/locales/es/zammad.js
@@ -26,5 +26,5 @@ export default { zammad: {
ticketCreateFailed: 'No se pudo crear el ticket de Zammad', ticketRefreshed: 'Estado del ticket de Zammad actualizado',
linkExistingTicket: 'Enlazar ticket existente', linkExistingTicketConfirm: 'Enlaza un ticket de Zammad existente mediante su número.', ticketNumberLabel: 'Número de ticket', ticketNumberPlaceholder: 'Por ejemplo, 12345', ticketLinked: 'Ticket de Zammad enlazado', ticketLinkFailed: 'No se pudo enlazar el ticket de Zammad', editTicket: 'Editar ticket de Zammad', ticketUpdated: 'Ticket de Zammad actualizado', ticketUpdateFailed: 'No se pudo actualizar el ticket de Zammad',
ticketRefreshFailed: 'No se pudo actualizar el ticket de Zammad', retrySync: 'Reintentar sincronización', refreshTicket: 'Actualizar estado del ticket', syncFailedNoTicket: 'La sincronización falló. La actualización estará disponible cuando se haya enlazado un ticket de Zammad.', creationUncertainNoTicket: 'El resultado de la creación del ticket es incierto. Comprueba Zammad y enlaza el ticket existente por número si es necesario.', removeTicketLink: 'Eliminar asignación', removeTicketLinkConfirm: 'Se eliminarán la asignación a Zammad n.º {number} y el campo de correlación de Windshift. El ticket de Zammad seguirá existiendo.', ticketLinkRemoved: 'Asignación de ticket de Zammad eliminada', ticketLinkRemoveFailed: 'No se pudo eliminar la asignación de ticket de Zammad', ticketNumber: 'Zammad n.º {number}',
- lastSynced: 'Sincronizado: {time}', syncState: { pending: 'Creación pendiente', creating: 'Creando ticket', linked: 'Ticket enlazado', sync_failed: 'Sincronización fallida', creation_uncertain: 'Resultado de creación incierto; los reintentos solo buscan el ticket existente' },
+ lastSynced: 'Sincronizado: {time}', returnLinkResolving: 'Abriendo el elemento de Windshift enlazado...', returnLinkFailed: 'No se pudo abrir el elemento de Windshift enlazado. Puede que ya no exista o que no tengas acceso.', syncState: { pending: 'Creación pendiente', creating: 'Creando ticket', linked: 'Ticket enlazado', sync_failed: 'Sincronización fallida', creation_uncertain: 'Resultado de creación incierto; los reintentos solo buscan el ticket existente' },
} };
diff --git a/frontend/src/lib/locales/pt-BR/zammad.js b/frontend/src/lib/locales/pt-BR/zammad.js
index bdc3441de..126d31daa 100644
--- a/frontend/src/lib/locales/pt-BR/zammad.js
+++ b/frontend/src/lib/locales/pt-BR/zammad.js
@@ -26,5 +26,5 @@ export default { zammad: {
ticketCreateFailed: 'Não foi possível criar o ticket do Zammad', ticketRefreshed: 'Status do ticket do Zammad atualizado',
linkExistingTicket: 'Vincular ticket existente', linkExistingTicketConfirm: 'Vincule um ticket existente do Zammad pelo número do ticket.', ticketNumberLabel: 'Número do ticket', ticketNumberPlaceholder: 'Por exemplo, 12345', ticketLinked: 'Ticket do Zammad vinculado', ticketLinkFailed: 'Não foi possível vincular o ticket do Zammad', editTicket: 'Editar ticket do Zammad', ticketUpdated: 'Ticket do Zammad atualizado', ticketUpdateFailed: 'Não foi possível atualizar o ticket do Zammad',
ticketRefreshFailed: 'Não foi possível atualizar o ticket do Zammad', retrySync: 'Tentar sincronização novamente', refreshTicket: 'Atualizar status do ticket', syncFailedNoTicket: 'A sincronização falhou. A atualização estará disponível após vincular um ticket do Zammad.', creationUncertainNoTicket: 'O resultado da criação do ticket é incerto. Verifique o Zammad e vincule o ticket existente pelo número se necessário.', removeTicketLink: 'Remover atribuição', removeTicketLinkConfirm: 'A atribuição ao Zammad nº {number} e o campo de correlação do Windshift serão removidos. O ticket do Zammad continuará existindo.', ticketLinkRemoved: 'Atribuição de ticket do Zammad removida', ticketLinkRemoveFailed: 'Não foi possível remover a atribuição de ticket do Zammad', ticketNumber: 'Zammad nº {number}',
- lastSynced: 'Sincronizado: {time}', syncState: { pending: 'Criação pendente', creating: 'Criando ticket', linked: 'Ticket vinculado', sync_failed: 'Falha na sincronização', creation_uncertain: 'Resultado da criação incerto; novas tentativas apenas procuram o ticket existente' },
+ lastSynced: 'Sincronizado: {time}', returnLinkResolving: 'Abrindo o item vinculado no Windshift...', returnLinkFailed: 'Não foi possível abrir o item vinculado no Windshift. Talvez ele não exista mais ou você não tenha acesso.', syncState: { pending: 'Criação pendente', creating: 'Criando ticket', linked: 'Ticket vinculado', sync_failed: 'Falha na sincronização', creation_uncertain: 'Resultado da criação incerto; novas tentativas apenas procuram o ticket existente' },
} };
diff --git a/frontend/src/lib/locales/ru/zammad.js b/frontend/src/lib/locales/ru/zammad.js
index b4b32ad30..4de734d5f 100644
--- a/frontend/src/lib/locales/ru/zammad.js
+++ b/frontend/src/lib/locales/ru/zammad.js
@@ -95,6 +95,8 @@ export default { zammad: {
ticketLinkRemoveFailed: 'Не удалось удалить связь с заявкой Zammad',
ticketNumber: 'Zammad №{number}',
lastSynced: 'Синхронизировано: {time}',
+ returnLinkResolving: 'Открывается связанный элемент Windshift...',
+ returnLinkFailed: 'Не удалось открыть связанный элемент Windshift. Возможно, он больше не существует или у вас нет доступа.',
syncState: {
pending: 'Ожидается создание заявки',
creating: 'Заявка создаётся',
diff --git a/frontend/src/lib/locales/zh-CN/zammad.js b/frontend/src/lib/locales/zh-CN/zammad.js
index bfcedc42c..4b999eca8 100644
--- a/frontend/src/lib/locales/zh-CN/zammad.js
+++ b/frontend/src/lib/locales/zh-CN/zammad.js
@@ -19,6 +19,6 @@ export default { zammad: {
connectionUnavailable: '一个或多个 Zammad 连接需要授权,暂时不能用于工单操作。',
loadLinksFailed: '无法加载 Zammad 工单链接', loadMetadataFailed: '无法加载 Zammad 群组和状态', loadOwnersFailed: '无法加载 Zammad 负责人', ticketCreated: '已创建 Zammad 工单',
ticketCreationStarted: 'Zammad 工单创建已在进行中', ticketCreationInProgress: '工单正在创建。关联工单后才可以刷新。', ticketCreateFailed: '无法创建 Zammad 工单', linkExistingTicket: '关联现有工单', linkExistingTicketConfirm: '通过工单编号关联现有的 Zammad 工单。', ticketNumberLabel: '工单编号', ticketNumberPlaceholder: '例如,12345', ticketLinked: '已关联 Zammad 工单', ticketLinkFailed: '无法关联 Zammad 工单', editTicket: '编辑 Zammad 工单', ticketUpdated: '已更新 Zammad 工单', ticketUpdateFailed: '无法更新 Zammad 工单', ticketRefreshed: '已刷新 Zammad 工单状态',
- ticketRefreshFailed: '无法刷新 Zammad 工单', retrySync: '重试同步', refreshTicket: '刷新工单状态', syncFailedNoTicket: '同步失败。关联 Zammad 工单后才可以刷新。', creationUncertainNoTicket: '工单创建结果不确定。请检查 Zammad,并在需要时通过编号关联现有工单。', removeTicketLink: '移除关联', removeTicketLinkConfirm: '将移除与 Zammad #{number} 的关联及 Windshift 关联字段。Zammad 工单本身不会被删除。', ticketLinkRemoved: '已移除 Zammad 工单关联', ticketLinkRemoveFailed: '无法移除 Zammad 工单关联', ticketNumber: 'Zammad #{number}', lastSynced: '同步于 {time}',
+ ticketRefreshFailed: '无法刷新 Zammad 工单', retrySync: '重试同步', refreshTicket: '刷新工单状态', syncFailedNoTicket: '同步失败。关联 Zammad 工单后才可以刷新。', creationUncertainNoTicket: '工单创建结果不确定。请检查 Zammad,并在需要时通过编号关联现有工单。', removeTicketLink: '移除关联', removeTicketLinkConfirm: '将移除与 Zammad #{number} 的关联及 Windshift 关联字段。Zammad 工单本身不会被删除。', ticketLinkRemoved: '已移除 Zammad 工单关联', ticketLinkRemoveFailed: '无法移除 Zammad 工单关联', ticketNumber: 'Zammad #{number}', lastSynced: '同步于 {time}', returnLinkResolving: '正在打开关联的 Windshift 条目...', returnLinkFailed: '无法打开关联的 Windshift 条目。该条目可能已不存在,或者你没有访问权限。',
syncState: { pending: '等待创建工单', creating: '正在创建工单', linked: '工单已链接', sync_failed: '同步失败', creation_uncertain: '创建结果不确定;重试只会查找现有工单' },
} };
diff --git a/frontend/src/lib/pages/mainAppRoutes.js b/frontend/src/lib/pages/mainAppRoutes.js
index a196e124d..de716d2b1 100644
--- a/frontend/src/lib/pages/mainAppRoutes.js
+++ b/frontend/src/lib/pages/mainAppRoutes.js
@@ -49,6 +49,7 @@ export const MAIN_APP_COMPONENT_LOADERS = {
homepage: () => import('./Homepage.svelte'),
licenses: () => import('./Licenses.svelte'),
'item-detail': () => import('../features/items/ItemDetail.svelte'),
+ 'zammad-resolve': () => import('../features/items/ZammadLinkResolver.svelte'),
'personal-task-detail': () => import('../features/personal/PersonalTaskDetail.svelte'),
'workspace-detail': () => import('../workspaces/WorkspaceWelcome.svelte'),
'workspace-overview': () => import('../workspaces/WorkspaceWelcome.svelte'),
@@ -288,6 +289,10 @@ export const MAIN_APP_ROUTE_CONFIG = {
};
},
}),
+ 'zammad-resolve': route('Resolving Zammad link...', 'Failed to resolve Zammad link', {
+ wrapper: 'surface-full',
+ getProps: (currentRoute) => ({ correlationKey: currentRoute.params.correlationKey }),
+ }),
'personal-task-detail': route('Loading Task...', 'Failed to load Task', {
getProps: (currentRoute, context) => ({
workspaceId: currentRoute.path.startsWith('/personal')
diff --git a/frontend/src/lib/router.js b/frontend/src/lib/router.js
index 8f029c617..5416405d8 100644
--- a/frontend/src/lib/router.js
+++ b/frontend/src/lib/router.js
@@ -41,6 +41,7 @@ const routes = {
// Stable key routes used by CLI/browser deep links.
'/workspace/:workspaceKey/item/:itemNumber': 'item-detail',
'/item/:itemKey': 'item-detail',
+ '/zammad/:correlationKey': 'zammad-resolve',
// Routes without collection (show all workspace items)
'/workspaces/:id/board': 'workspace-board',
'/workspaces/:id/board/configure': 'workspace-board-config',
diff --git a/internal/handlers/zammad.go b/internal/handlers/zammad.go
index 6a12518af..cc52abc6c 100644
--- a/internal/handlers/zammad.go
+++ b/internal/handlers/zammad.go
@@ -288,6 +288,27 @@ func (h *ZammadHandler) GetItemLinks(w http.ResponseWriter, r *http.Request) {
respondJSONOK(w, responses)
}
+// ResolveTicketLink turns the opaque correlation key stored in Zammad into a
+// current Windshift item destination. Item permissions are checked before the
+// destination is disclosed so the link cannot be used to enumerate items.
+func (h *ZammadHandler) ResolveTicketLink(w http.ResponseWriter, r *http.Request) {
+ user, ok := RequireAuth(w, r)
+ if !ok {
+ return
+ }
+ itemID, workspaceID, err := h.service.ResolveTicketLink(r.PathValue("correlationKey"))
+ if err != nil {
+ respondNotFound(w, r, "Item")
+ return
+ }
+ allowed, err := h.permissionService.HasWorkspacePermission(user.ID, workspaceID, models.PermissionItemView)
+ if err != nil || !allowed {
+ respondNotFound(w, r, "Item")
+ return
+ }
+ respondJSONOK(w, map[string]int{"workspace_id": workspaceID, "item_id": itemID})
+}
+
func (h *ZammadHandler) CreateTicket(w http.ResponseWriter, r *http.Request) {
itemID, ok := requireIDParam(w, r, "id")
if !ok {
diff --git a/internal/handlers/zammad_test.go b/internal/handlers/zammad_test.go
index 95a8f4668..ac51337da 100644
--- a/internal/handlers/zammad_test.go
+++ b/internal/handlers/zammad_test.go
@@ -171,6 +171,105 @@ func TestZammadWorkspaceOwnersRequireItemEditPermission(t *testing.T) {
}
}
+func TestZammadTicketLinkResolverRequiresViewPermission(t *testing.T) {
+ handler, db, user, workspaceID := newZammadHandlerTest(t)
+ var statusID int
+ if err := db.QueryRow(`SELECT id FROM statuses ORDER BY id LIMIT 1`).Scan(&statusID); err != nil {
+ t.Fatal(err)
+ }
+ itemResult, err := db.ExecWrite(`INSERT INTO items
+ (workspace_id, workspace_item_number, title, description, frac_index, status_id, creator_id, last_active_at)
+ VALUES (?, 1, 'Resolver target', '', 'a0', ?, ?, CURRENT_TIMESTAMP)`, workspaceID, statusID, user.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ itemID, _ := itemResult.LastInsertId()
+ if _, err := db.ExecWrite(`INSERT INTO action_credentials
+ (id, name, credential_type, applies_to_all_workspaces, encrypted_secret, is_enabled)
+ VALUES (1, 'Resolver credential', 'custom_header', true, 'test-ciphertext', true)`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.ExecWrite(`INSERT INTO integration_providers
+ (id, slug, name, provider_type, enabled)
+ VALUES ('zammad-resolver', 'zammad-resolver', 'Zammad resolver', 'zammad', true)`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.ExecWrite(`INSERT INTO zammad_connections
+ (provider_id, credential_id, base_url, default_customer)
+ VALUES ('zammad-resolver', 1, 'https://zammad.example.test', 'robot@example.test')`); err != nil {
+ t.Fatal(err)
+ }
+ correlationKey := "windshift:zammad-resolver:PRI-1"
+ if _, err := db.ExecWrite(`INSERT INTO zammad_ticket_links
+ (id, item_id, provider_id, correlation_key, sync_state, created_by)
+ VALUES ('resolver-link', ?, 'zammad-resolver', ?, ?, ?)`, itemID, correlationKey, models.ZammadSyncLinked, user.ID); err != nil {
+ t.Fatal(err)
+ }
+ movedWorkspaceResult, err := db.ExecWrite(`INSERT INTO workspaces (name, key) VALUES ('Moved target', 'MOV')`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ movedWorkspaceID, _ := movedWorkspaceResult.LastInsertId()
+ if _, err := db.ExecWrite(`UPDATE items SET workspace_id = ? WHERE id = ?`, movedWorkspaceID, itemID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.ExecWrite(`INSERT INTO user_workspace_roles (user_id, workspace_id, role_id, granted_by)
+ SELECT ?, ?, id, ? FROM workspace_roles WHERE name = 'Viewer'`, user.ID, movedWorkspaceID, user.ID); err != nil {
+ t.Fatal(err)
+ }
+ config := services.DefaultPermissionCacheConfig()
+ config.WarmupOnStartup = false
+ config.PreWarmActive = false
+ permissionService, err := services.NewPermissionService(db, config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = permissionService.Close() })
+ handler.permissionService = permissionService
+
+ request := authenticatedZammadRequest(http.MethodGet, "/api/zammad-ticket-links/resolve/"+correlationKey, nil, user)
+ request.SetPathValue("correlationKey", correlationKey)
+ recorder := httptest.NewRecorder()
+ handler.ResolveTicketLink(recorder, request)
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("viewer could not resolve linked item: status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+ var destination struct {
+ WorkspaceID int `json:"workspace_id"`
+ ItemID int `json:"item_id"`
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &destination); err != nil {
+ t.Fatal(err)
+ }
+ if destination.WorkspaceID != int(movedWorkspaceID) || destination.ItemID != int(itemID) {
+ t.Fatalf("unexpected resolver destination: %#v", destination)
+ }
+
+ deniedResult, err := db.ExecWrite(`INSERT INTO users (email, username, first_name, last_name)
+ VALUES ('denied@example.test', 'denied', 'Denied', 'User')`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ deniedID, _ := deniedResult.LastInsertId()
+ deniedRequest := authenticatedZammadRequest(http.MethodGet, "/api/zammad-ticket-links/resolve/"+correlationKey, nil, &models.User{ID: int(deniedID)})
+ deniedRequest.SetPathValue("correlationKey", correlationKey)
+ deniedRecorder := httptest.NewRecorder()
+ handler.ResolveTicketLink(deniedRecorder, deniedRequest)
+ if deniedRecorder.Code != http.StatusNotFound {
+ t.Fatalf("resolver disclosed an item without view permission: status=%d body=%s", deniedRecorder.Code, deniedRecorder.Body.String())
+ }
+
+ missingKey := "windshift:zammad-resolver:missing"
+ missingRequest := authenticatedZammadRequest(http.MethodGet, "/api/zammad-ticket-links/resolve/"+missingKey, nil, user)
+ missingRequest.SetPathValue("correlationKey", missingKey)
+ missingRecorder := httptest.NewRecorder()
+ handler.ResolveTicketLink(missingRecorder, missingRequest)
+ if missingRecorder.Code != deniedRecorder.Code || missingRecorder.Body.String() != deniedRecorder.Body.String() {
+ t.Fatalf("resolver distinguished an unauthorized target from a missing one: denied=%d %s missing=%d %s",
+ deniedRecorder.Code, deniedRecorder.Body.String(), missingRecorder.Code, missingRecorder.Body.String())
+ }
+}
+
func TestZammadHandlerMapsRemoteAuthenticationFailureToBadGateway(t *testing.T) {
handler, _, _, _ := newZammadHandlerTest(t)
recorder := httptest.NewRecorder()
diff --git a/internal/repository/zammad_repository.go b/internal/repository/zammad_repository.go
index 1331463db..59f64333d 100644
--- a/internal/repository/zammad_repository.go
+++ b/internal/repository/zammad_repository.go
@@ -623,6 +623,19 @@ func (r *ZammadRepository) GetTicketLinkForItem(itemID int, providerID string) (
return link, err
}
+// GetItemDestinationByCorrelationKey resolves the durable key and current
+// workspace in one snapshot through the provider/key uniqueness boundary.
+func (r *ZammadRepository) GetItemDestinationByCorrelationKey(providerID, correlationKey string) (itemID, workspaceID int, err error) {
+ err = r.db.QueryRow(`SELECT ztl.item_id, i.workspace_id
+ FROM zammad_ticket_links ztl
+ JOIN items i ON i.id = ztl.item_id
+ WHERE ztl.provider_id = ? AND ztl.correlation_key = ?`, providerID, correlationKey).Scan(&itemID, &workspaceID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, 0, ErrNotFound
+ }
+ return itemID, workspaceID, err
+}
+
func (r *ZammadRepository) CreatePendingTicketLinkTx(tx database.Tx, link *models.ZammadTicketLink) error {
_, err := tx.Exec(`INSERT INTO zammad_ticket_links
(id, item_id, provider_id, group_id, group_name, correlation_key,
diff --git a/internal/repository/zammad_repository_test.go b/internal/repository/zammad_repository_test.go
index 2f38c4664..d9f919393 100644
--- a/internal/repository/zammad_repository_test.go
+++ b/internal/repository/zammad_repository_test.go
@@ -77,6 +77,18 @@ func (f *zammadLeaseFixture) makeComplete(t *testing.T) {
}
}
+func TestZammadCorrelationKeyResolvesCurrentItem(t *testing.T) {
+ f := newZammadLeaseFixture(t)
+
+ itemID, workspaceID, err := f.repo.GetItemDestinationByCorrelationKey("zammad-test", "ZRT-1")
+ if err != nil || itemID != 1 || workspaceID != 1 {
+ t.Fatalf("resolve correlation key: item=%d workspace=%d err=%v", itemID, workspaceID, err)
+ }
+ if _, _, err := f.repo.GetItemDestinationByCorrelationKey("zammad-test", "missing"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("missing correlation key must fail closed: %v", err)
+ }
+}
+
func TestZammadSyncLeaseOwnerTakeoverProtectsSnapshotReleaseAndDelete(t *testing.T) {
f := newZammadLeaseFixture(t)
f.makeComplete(t)
diff --git a/internal/routes/integrations.go b/internal/routes/integrations.go
index 74c44a1f2..a84c05076 100644
--- a/internal/routes/integrations.go
+++ b/internal/routes/integrations.go
@@ -58,6 +58,7 @@ func RegisterIntegrationRoutes(deps *Deps) {
api.HandleH("GET /workspaces/{workspaceId}/zammad-connections/{id}/metadata", auth(http.HandlerFunc(deps.Integrations.Zammad.GetWorkspaceMetadata)))
api.HandleH("GET /workspaces/{workspaceId}/zammad-connections/{id}/owners", auth(http.HandlerFunc(deps.Integrations.Zammad.GetWorkspaceOwners)))
api.HandleH("GET /items/{id}/zammad-links", auth(http.HandlerFunc(deps.Integrations.Zammad.GetItemLinks)))
+ api.HandleH("GET /zammad-ticket-links/resolve/{correlationKey}", auth(http.HandlerFunc(deps.Integrations.Zammad.ResolveTicketLink)))
api.HandleH("POST /items/{id}/zammad-tickets", auth(http.HandlerFunc(deps.Integrations.Zammad.CreateTicket)))
api.HandleH("POST /items/{id}/zammad-ticket-links", auth(http.HandlerFunc(deps.Integrations.Zammad.LinkExistingTicket)))
api.HandleH("PUT /zammad-ticket-links/{linkId}", auth(http.HandlerFunc(deps.Integrations.Zammad.UpdateTicketLink)))
diff --git a/internal/services/zammad_service.go b/internal/services/zammad_service.go
index c9b3f1d07..e61600ad9 100644
--- a/internal/services/zammad_service.go
+++ b/internal/services/zammad_service.go
@@ -908,6 +908,22 @@ func (s *ZammadService) GetTicketLink(id string) (*models.ZammadTicketLink, erro
return s.repo.GetTicketLink(id)
}
+func (s *ZammadService) ResolveTicketLink(correlationKey string) (itemID, workspaceID int, err error) {
+ correlationKey = strings.TrimSpace(correlationKey)
+ if correlationKey == "" || len(correlationKey) > 512 {
+ return 0, 0, repository.ErrNotFound
+ }
+ providerAndItem, ok := strings.CutPrefix(correlationKey, "windshift:")
+ if !ok {
+ return 0, 0, repository.ErrNotFound
+ }
+ providerID, itemKey, ok := strings.Cut(providerAndItem, ":")
+ if !ok || providerID == "" || itemKey == "" {
+ return 0, 0, repository.ErrNotFound
+ }
+ return s.repo.GetItemDestinationByCorrelationKey(providerID, correlationKey)
+}
+
// LinkExistingTicket attaches a remote ticket without creating a second one.
// The local reservation is written before the remote correlation field so a
// competing item cannot claim the same provider/ticket pair.
From 34f44615381bc84f598b1b66eb273ec9fb6b8a40 Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 15:04:25 +0200
Subject: [PATCH 07/11] feat: add Zammad support overview
---
docs/zammad-integration.md | 8 +-
frontend/src/lib/api/integrations.js | 4 +
frontend/src/lib/api/integrations.spec.js | 30 +++
.../lib/features/items/ZammadItemPanel.svelte | 139 ++++++++++-
.../features/items/ZammadLinkResolver.spec.js | 140 ++++++++++-
.../lib/features/items/zammadPanelContext.js | 18 ++
.../features/items/zammadPanelContext.spec.js | 86 +++++++
frontend/src/lib/locales/de/navigation.js | 5 +
frontend/src/lib/locales/de/zammad.js | 29 +++
frontend/src/lib/locales/en/navigation.js | 1 +
frontend/src/lib/locales/en/zammad.js | 29 +++
frontend/src/lib/locales/ru/navigation.js | 1 +
frontend/src/lib/locales/ru/zammad.js | 29 +++
frontend/src/lib/services/widgetRegistry.js | 12 +
frontend/src/lib/utils/icons.js | 2 +
frontend/src/lib/utils/zammadObservations.js | 49 ++++
.../lib/widgets/ZammadSupportWidget.svelte | 212 ++++++++++++++++
.../lib/workspaces/WorkspaceWelcome.svelte | 3 +
internal/database/migrations.go | 39 +++
internal/database/schema/integrations.sql | 13 +
.../database/schema/integrations_postgres.sql | 12 +
internal/database/zammad_schema_test.go | 25 +-
internal/handlers/workspaces_settings.go | 31 +--
internal/handlers/workspaces_settings_test.go | 9 +
internal/handlers/zammad.go | 54 +++++
internal/models/zammad.go | 38 +++
internal/repository/zammad_repository.go | 227 ++++++++++++++++++
internal/routes/integrations.go | 2 +
internal/services/zammad_service.go | 85 +++++++
internal/services/zammad_service_test.go | 90 +++++++
30 files changed, 1395 insertions(+), 27 deletions(-)
create mode 100644 frontend/src/lib/utils/zammadObservations.js
create mode 100644 frontend/src/lib/widgets/ZammadSupportWidget.svelte
create mode 100644 internal/handlers/workspaces_settings_test.go
diff --git a/docs/zammad-integration.md b/docs/zammad-integration.md
index 047d96fca..b61795d17 100644
--- a/docs/zammad-integration.md
+++ b/docs/zammad-integration.md
@@ -16,8 +16,12 @@ The explicit administrator retry is `POST /api/admin/zammad-ticket-links/{linkId
`POST /api/admin/zammad-ticket-links/refresh` queues an asynchronous synchronization of every complete ticket link on enabled, authorized connections.
Concurrent system-wide refresh requests are coalesced into the already queued or running job.
-Workspace and item routes are `GET /api/workspaces/{workspaceId}/zammad-connections`, `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/metadata`, and `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/owners?group_id={groupId}`.
-Ticket routes are `GET /api/items/{id}/zammad-links`, `GET /api/zammad-ticket-links/resolve/{correlationKey}`, `POST /api/items/{id}/zammad-tickets`, `POST /api/items/{id}/zammad-ticket-links`, `PUT /api/zammad-ticket-links/{linkId}`, `DELETE /api/zammad-ticket-links/{linkId}`, and `POST /api/zammad-ticket-links/{linkId}/refresh`.
+Workspace and item routes are `GET /api/workspaces/{workspaceId}/zammad-connections`, `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/metadata`, `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/owners?group_id={groupId}`, and `GET /api/workspaces/{workspaceId}/zammad-overview?limit={limit}`.
+Ticket routes are `GET /api/items/{id}/zammad-links`, `GET /api/items/{id}/zammad-history?limit={limit}`, `GET /api/zammad-ticket-links/resolve/{correlationKey}`, `POST /api/items/{id}/zammad-tickets`, `POST /api/items/{id}/zammad-ticket-links`, `PUT /api/zammad-ticket-links/{linkId}`, `DELETE /api/zammad-ticket-links/{linkId}`, and `POST /api/zammad-ticket-links/{linkId}/refresh`.
+The optional history and overview `limit` is an integer from 1 through 100 and defaults to 6 for item history and 5 for the workspace overview.
+Item history returns an `events` array containing only status, group, and owner changes observed during Windshift synchronization, including old and new IDs and point-in-time names, the ticket number, item identity, and observation time.
+The workspace overview returns current linked-ticket totals, active, closed, unassigned, unknown-status, synchronization-failure, and uncertain-creation counts, a per-connection status distribution, and the most recent observed changes.
+These responses are a polling-derived Windshift observation and are not a complete Zammad audit trail, so they do not claim the original Zammad change time, actor, or intermediate states between polls.
Create-ticket requests contain `connection_id` and may contain `group_id`.
Link-existing requests contain `connection_id` and `ticket_number`.
diff --git a/frontend/src/lib/api/integrations.js b/frontend/src/lib/api/integrations.js
index f0b7db5de..7efede0e2 100644
--- a/frontend/src/lib/api/integrations.js
+++ b/frontend/src/lib/api/integrations.js
@@ -78,6 +78,10 @@ export const zammadTickets = {
resolve: (correlationKey) =>
fetchAPI(`/zammad-ticket-links/resolve/${encodeURIComponent(correlationKey)}`),
forItem: (itemId) => fetchAPI(`/items/${itemId}/zammad-links`),
+ history: (itemId, { limit = 6 } = {}) =>
+ fetchAPI(`/items/${itemId}/zammad-history?limit=${encodeURIComponent(limit)}`),
+ workspaceOverview: (workspaceId, { limit = 5 } = {}) =>
+ fetchAPI(`/workspaces/${workspaceId}/zammad-overview?limit=${encodeURIComponent(limit)}`),
create: (itemId, data) =>
fetchAPI(`/items/${itemId}/zammad-tickets`, {
method: 'POST',
diff --git a/frontend/src/lib/api/integrations.spec.js b/frontend/src/lib/api/integrations.spec.js
index b4df2c377..662253fd7 100644
--- a/frontend/src/lib/api/integrations.spec.js
+++ b/frontend/src/lib/api/integrations.spec.js
@@ -76,6 +76,36 @@ describe('Zammad ticket-link API', () => {
);
});
+ it('loads a compact, observed Zammad timeline for one item', async () => {
+ const history = { events: [{ id: 'event-id', field: 'status' }] };
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(history), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(zammadTickets.history(99, { limit: 6 })).resolves.toEqual(history);
+
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/items/99/zammad-history?limit=6');
+ });
+
+ it('loads the workspace-wide Zammad support overview', async () => {
+ const overview = { total: 3, recent_changes: [] };
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(overview), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(zammadTickets.workspaceOverview(23, { limit: 5 })).resolves.toEqual(overview);
+
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/workspaces/23/zammad-overview?limit=5');
+ });
+
it('loads assignable owners for the selected group', async () => {
const owners = [
{ id: 1, name: 'Not assigned' },
diff --git a/frontend/src/lib/features/items/ZammadItemPanel.svelte b/frontend/src/lib/features/items/ZammadItemPanel.svelte
index efeb56ebe..21129e86b 100644
--- a/frontend/src/lib/features/items/ZammadItemPanel.svelte
+++ b/frontend/src/lib/features/items/ZammadItemPanel.svelte
@@ -9,6 +9,8 @@
AlertTriangle,
Edit2,
Trash2,
+ ChevronDown,
+ ChevronUp,
} from '@lucide/svelte';
import { api } from '../../api.js';
import Button from '../../components/Button.svelte';
@@ -19,12 +21,16 @@
import FormField from '../../components/FormField.svelte';
import Input from '../../components/Input.svelte';
import { t } from '../../stores/i18n.svelte.js';
+ import { authStore } from '../../stores';
import { successToast, errorToast } from '../../stores/toasts.svelte.js';
import { confirm } from '../../composables/useConfirm.js';
import { safeHref } from '../../utils/sanitize';
+ import { formatDateTimeLocale, getUserTimezone } from '../../utils/dateFormatter.js';
+ import { getZammadObservedValueLabel } from '../../utils/zammadObservations.js';
import {
isCurrentZammadMetadataRequest,
isCurrentZammadPanelContext,
+ isCurrentZammadTimelineRequest,
} from './zammadPanelContext.js';
let { itemId, workspaceId, canEdit = false } = $props();
@@ -62,6 +68,14 @@
let metadataVersion = 0;
let editVersion = 0;
let ownersVersion = 0;
+ let timelineEvents = $state([]);
+ let timelineLoading = $state(false);
+ let timelineError = $state('');
+ let showTimeline = $state(false);
+ let timelineLoaded = $state(false);
+ let timelineVersion = 0;
+ let timezone = $derived(getUserTimezone(authStore.currentUser));
+ const observedTimelineFields = new Set(['status', 'group', 'owner']);
let usableConnections = $derived(connections.filter(isConnectionUsable));
let unavailableConnections = $derived(connections.filter((connection) => !isConnectionUsable(connection)));
@@ -105,6 +119,7 @@
metadataVersion += 1;
editVersion += 1;
ownersVersion += 1;
+ timelineVersion += 1;
connections = [];
links = [];
metadata = { groups: [], states: [] };
@@ -133,6 +148,11 @@
error = '';
formError = '';
editError = '';
+ timelineEvents = [];
+ timelineLoading = false;
+ timelineError = '';
+ showTimeline = false;
+ timelineLoaded = false;
}
function isConnectionUsable(connection) {
@@ -155,6 +175,76 @@
function replaceLink(updated) {
links = [updated, ...links.filter((entry) => entry.id !== updated.id)];
+ invalidateTimeline();
+ }
+
+ function invalidateTimeline() {
+ timelineLoaded = false;
+ if (showTimeline) void loadTimeline(contextVersion);
+ }
+
+ function timelineFieldLabel(field) {
+ const fields = {
+ status: 'status',
+ owner: 'owner',
+ group: 'group',
+ };
+ return t(`zammad.timeline.field.${fields[field]}`);
+ }
+
+ function timelineValueLabel(value) {
+ return getZammadObservedValueLabel(value, t);
+ }
+
+ function timelineChangeLabel(event) {
+ return t('zammad.timeline.change', {
+ field: timelineFieldLabel(event.field),
+ from: timelineValueLabel(event.old_value),
+ to: timelineValueLabel(event.new_value),
+ });
+ }
+
+ async function toggleTimeline() {
+ showTimeline = !showTimeline;
+ if (showTimeline && !timelineLoaded && !timelineLoading) {
+ await loadTimeline(contextVersion);
+ }
+ }
+
+ async function loadTimeline(version = contextVersion) {
+ const currentItemId = itemId;
+ const currentWorkspaceId = workspaceId;
+ const requestVersion = ++timelineVersion;
+ if (!currentItemId) return;
+ const isCurrentTimelineRequest = () =>
+ requestVersion === timelineVersion &&
+ isCurrentZammadTimelineRequest(
+ version,
+ contextVersion,
+ currentItemId,
+ itemId,
+ currentWorkspaceId,
+ workspaceId,
+ );
+ timelineLoading = true;
+ timelineError = '';
+ try {
+ const response = await api.zammadTickets.history(currentItemId, { limit: 6 });
+ if (!isCurrentTimelineRequest()) return;
+ timelineEvents = Array.isArray(response?.events)
+ ? response.events.filter((event) => observedTimelineFields.has(event?.field)).slice(0, 6)
+ : [];
+ timelineLoaded = true;
+ } catch (err) {
+ if (!isCurrentTimelineRequest()) return;
+ console.error('Failed to load Zammad timeline:', err);
+ timelineEvents = [];
+ timelineError = t('zammad.timeline.loadFailed');
+ } finally {
+ if (isCurrentTimelineRequest()) {
+ timelineLoading = false;
+ }
+ }
}
function selectedEditConnection() {
@@ -188,6 +278,7 @@
if (currentVersion !== loadVersion || !isCurrentContext(version, currentItemId, currentWorkspaceId)) return;
connections = loadedConnections;
links = loadedLinks;
+ invalidateTimeline();
} catch (err) {
if (currentVersion !== loadVersion || !isCurrentContext(version, currentItemId, currentWorkspaceId)) return;
console.error('Failed to load Zammad links:', err);
@@ -545,20 +636,20 @@
{t('zammad.status')}: {link.last_status_name || t('zammad.unknown')}
{t('zammad.group')}: {link.group_name || t('zammad.unknown')}
{t('zammad.owner')}: {link.owner_name || t('zammad.unassignedOwner')}
- {link.last_synced_at ? t('zammad.lastSynced', { time: new Date(link.last_synced_at).toLocaleString() }) : t('zammad.notSynced')}
+ {link.last_synced_at ? t('zammad.lastSynced', { time: formatDateTimeLocale(link.last_synced_at, timezone) }) : t('zammad.notSynced')}
{#if canEdit}
{#if linkConnectionUsable && link.ticket_id && link.sync_state !== 'creating'}
-
@@ -576,6 +667,46 @@
{/each}
+
+
+ {t('zammad.timeline.toggle')}
+ {#if showTimeline}{:else}{/if}
+
+ {#if showTimeline}
+
+
{t('zammad.timeline.observedHint')}
+ {#if timelineLoading}
+
+ {:else if timelineError}
+
+ {timelineError}
+ loadTimeline(contextVersion)}>{t('common.retry')}
+
+ {:else if timelineEvents.length === 0}
+
{t('zammad.timeline.empty')}
+ {:else}
+
+ {#each timelineEvents as event (event.id)}
+ -
+
+
+
{t('zammad.ticketNumber', { number: event.ticket_number })}
+
{timelineChangeLabel(event)}
+
+
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
{/if}
{/if}
diff --git a/frontend/src/lib/features/items/ZammadLinkResolver.spec.js b/frontend/src/lib/features/items/ZammadLinkResolver.spec.js
index 1b1487cac..8bb548c91 100644
--- a/frontend/src/lib/features/items/ZammadLinkResolver.spec.js
+++ b/frontend/src/lib/features/items/ZammadLinkResolver.spec.js
@@ -3,17 +3,38 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import ZammadSupportWidget from '../../widgets/ZammadSupportWidget.svelte';
import ZammadLinkResolver from './ZammadLinkResolver.svelte';
-const mocks = vi.hoisted(() => ({ resolve: vi.fn(), navigate: vi.fn() }));
+const mocks = vi.hoisted(() => ({
+ resolve: vi.fn(),
+ workspaceOverview: vi.fn(),
+ navigate: vi.fn(),
+}));
-vi.mock('../../api.js', () => ({ api: { zammadTickets: { resolve: mocks.resolve } } }));
+vi.mock('../../api.js', () => ({
+ api: {
+ zammadTickets: {
+ resolve: mocks.resolve,
+ workspaceOverview: mocks.workspaceOverview,
+ },
+ },
+}));
vi.mock('../../router.js', () => ({ navigate: mocks.navigate }));
-vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key }));
+vi.mock('../../stores/i18n.svelte.js', () => ({
+ t: (key, params = {}) =>
+ key === 'zammad.overview.statusWithConnection'
+ ? `${params.connection} · ${params.status}`
+ : key,
+}));
+vi.mock('../../stores', () => ({ authStore: { currentUser: { timezone: 'UTC' } } }));
describe('ZammadLinkResolver', () => {
beforeEach(() => vi.clearAllMocks());
- afterEach(cleanup);
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
it('replaces the resolver route with the current item destination', async () => {
mocks.resolve.mockResolvedValue({ workspace_id: 7, item_id: 42 });
@@ -37,4 +58,115 @@ describe('ZammadLinkResolver', () => {
await fireEvent.click(retry);
await waitFor(() => expect(mocks.navigate).toHaveBeenCalledOnce());
});
+
+ it('renders the workspace-wide support widget from status metadata only', async () => {
+ mocks.workspaceOverview.mockResolvedValue({
+ total: 4,
+ active: 2,
+ closed: 1,
+ unassigned: 1,
+ sync_failed: 1,
+ creation_uncertain: 0,
+ unknown_status: 0,
+ by_status: [
+ {
+ connection_id: 'connection-1',
+ connection_name: 'Primary helpdesk',
+ id: 3,
+ name: 'open',
+ count: 1,
+ closed: false,
+ },
+ {
+ connection_id: 'connection-2',
+ connection_name: 'Second helpdesk',
+ id: 3,
+ name: 'open',
+ count: 1,
+ closed: false,
+ },
+ ],
+ recent_changes: [
+ {
+ id: 'event-1',
+ item_id: 42,
+ item_key: 'OPS-42',
+ ticket_number: '12345',
+ field: 'status',
+ old_value: { id: 1, name: 'new' },
+ new_value: { id: 2, name: 'open' },
+ observed_at: '2026-08-31T10:00:00Z',
+ },
+ {
+ id: 'event-2',
+ item_id: 43,
+ item_key: 'OPS-43',
+ ticket_number: '12346',
+ field: 'priority',
+ observed_at: '2026-08-31T11:00:00Z',
+ },
+ ],
+ });
+
+ render(ZammadSupportWidget, { workspaceId: 7 });
+
+ await waitFor(() => expect(mocks.workspaceOverview).toHaveBeenCalledWith(7, { limit: 5 }));
+ expect(await screen.findByTestId('zammad-support-overview')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'OPS-42' })).toHaveAttribute(
+ 'href',
+ '/workspaces/7/items/42'
+ );
+ expect(screen.queryByRole('link', { name: 'OPS-43' })).not.toBeInTheDocument();
+ expect(screen.getByText('Primary helpdesk · open: 1')).toBeInTheDocument();
+ expect(screen.getByText('Second helpdesk · open: 1')).toBeInTheDocument();
+ expect(screen.queryByText(/article|customer|subject/i)).not.toBeInTheDocument();
+ });
+
+ it('refreshes the workspace-wide overview on demand', async () => {
+ mocks.workspaceOverview.mockResolvedValue({
+ total: 1,
+ active: 1,
+ closed: 0,
+ unassigned: 0,
+ sync_failed: 0,
+ creation_uncertain: 0,
+ unknown_status: 0,
+ by_status: [],
+ recent_changes: [],
+ });
+
+ render(ZammadSupportWidget, { workspaceId: 7 });
+
+ await screen.findByTestId('zammad-support-overview');
+ await fireEvent.click(screen.getByRole('button', { name: 'zammad.overview.refresh' }));
+
+ await waitFor(() => expect(mocks.workspaceOverview).toHaveBeenCalledTimes(2));
+ expect(mocks.workspaceOverview).toHaveBeenLastCalledWith(7, { limit: 5 });
+ });
+
+ it('refreshes the overview at the synchronization cadence and clears the interval on unmount', async () => {
+ vi.useFakeTimers();
+ mocks.workspaceOverview.mockResolvedValue({
+ total: 1,
+ active: 1,
+ closed: 0,
+ unassigned: 0,
+ sync_failed: 0,
+ creation_uncertain: 0,
+ unknown_status: 0,
+ by_status: [],
+ recent_changes: [],
+ });
+
+ const { unmount } = render(ZammadSupportWidget, { workspaceId: 7 });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(mocks.workspaceOverview).toHaveBeenCalledOnce();
+
+ await vi.advanceTimersByTimeAsync(120_000);
+ expect(mocks.workspaceOverview).toHaveBeenCalledTimes(2);
+
+ unmount();
+ await vi.advanceTimersByTimeAsync(120_000);
+ expect(mocks.workspaceOverview).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/frontend/src/lib/features/items/zammadPanelContext.js b/frontend/src/lib/features/items/zammadPanelContext.js
index 90aacbc38..67b21931a 100644
--- a/frontend/src/lib/features/items/zammadPanelContext.js
+++ b/frontend/src/lib/features/items/zammadPanelContext.js
@@ -28,3 +28,21 @@ export function isCurrentZammadMetadataRequest(
dialogMode === 'create'
);
}
+
+export function isCurrentZammadTimelineRequest(
+ requestVersion,
+ currentVersion,
+ requestItemId,
+ itemId,
+ requestWorkspaceId,
+ workspaceId
+) {
+ return isCurrentZammadPanelContext(
+ requestVersion,
+ currentVersion,
+ requestItemId,
+ itemId,
+ requestWorkspaceId,
+ workspaceId
+ );
+}
diff --git a/frontend/src/lib/features/items/zammadPanelContext.spec.js b/frontend/src/lib/features/items/zammadPanelContext.spec.js
index e1f1675f9..658af4a44 100644
--- a/frontend/src/lib/features/items/zammadPanelContext.spec.js
+++ b/frontend/src/lib/features/items/zammadPanelContext.spec.js
@@ -1,7 +1,19 @@
import { describe, expect, it } from 'vitest';
+import {
+ getWidgetMetadata,
+ getWidgetMinWidth,
+ widgetCategories,
+} from '../../services/widgetRegistry.js';
+import {
+ getZammadObservedValueLabel,
+ getZammadStatusBucketDisplayLabel,
+ getZammadStatusBucketLabel,
+ isCurrentZammadWorkspaceOverviewRequest,
+} from '../../utils/zammadObservations.js';
import {
isCurrentZammadMetadataRequest,
isCurrentZammadPanelContext,
+ isCurrentZammadTimelineRequest,
} from './zammadPanelContext.js';
describe('Zammad item panel context guards', () => {
@@ -22,6 +34,80 @@ describe('Zammad item panel context guards', () => {
});
});
+describe('Zammad timeline request guards', () => {
+ it('rejects a timeline response from a former item context', () => {
+ expect(
+ isCurrentZammadTimelineRequest(2, 2, 'old-item', 'new-item', 'workspace', 'workspace')
+ ).toBe(false);
+ });
+
+ it('accepts a timeline response only for the current item and workspace', () => {
+ expect(isCurrentZammadTimelineRequest(2, 2, 'item', 'item', 'workspace', 'workspace')).toBe(
+ true
+ );
+ });
+});
+
+describe('Zammad support overview widget registration', () => {
+ it('registers the workspace-wide support overview as a wide additional widget', () => {
+ expect(getWidgetMetadata('zammad-support-overview')).toMatchObject({
+ category: widgetCategories.ADDITIONAL,
+ nameKey: 'workspaceDashboard.widgets.zammadSupportOverview.name',
+ defaultWidth: 3,
+ maxWidth: 3,
+ });
+ expect(getWidgetMinWidth('zammad-support-overview')).toBe(2);
+ });
+});
+
+describe('Zammad observation labels', () => {
+ const translate = (key, params = {}) => `${key}:${params.id ?? ''}`;
+
+ it('uses an ID fallback for observed values with no display name', () => {
+ expect(getZammadObservedValueLabel({ id: 17, name: ' ' }, translate)).toBe(
+ 'zammad.timeline.valueIdFallback:17'
+ );
+ });
+
+ it('keeps an absent observed value distinct from an ID-only value', () => {
+ expect(getZammadObservedValueLabel({ id: 0, name: '' }, translate)).toBe(
+ 'zammad.timeline.initialValue:'
+ );
+ });
+
+ it('labels nameless status buckets with their ID or an unknown fallback', () => {
+ expect(getZammadStatusBucketLabel({ id: 9, name: '' }, translate)).toBe(
+ 'zammad.overview.statusIdFallback:9'
+ );
+ expect(getZammadStatusBucketLabel({ id: null, name: '' }, translate)).toBe(
+ 'zammad.overview.unknownStatusBucket:'
+ );
+ });
+
+ it('adds the readable connection name to a status bucket label', () => {
+ const displayTranslate = (key, params = {}) =>
+ key === 'zammad.overview.statusWithConnection'
+ ? `${params.connection} · ${params.status}`
+ : `${key}:${params.id ?? ''}`;
+ expect(
+ getZammadStatusBucketDisplayLabel(
+ { id: 2, name: 'open', connection_name: 'Second helpdesk' },
+ displayTranslate
+ )
+ ).toBe('Second helpdesk · open');
+ });
+});
+
+describe('Zammad workspace overview request guards', () => {
+ it('rejects a stale response for the same workspace after a newer refresh', () => {
+ expect(isCurrentZammadWorkspaceOverviewRequest(1, 2, 7, 7)).toBe(false);
+ });
+
+ it('accepts matching versions even when the workspace ID changes representation', () => {
+ expect(isCurrentZammadWorkspaceOverviewRequest(2, 2, 7, '7')).toBe(true);
+ });
+});
+
describe('Zammad metadata request guards', () => {
it('rejects create metadata after switching to the link dialog', () => {
expect(isCurrentZammadMetadataRequest(1, 1, 'connection', 'connection', true, 'link')).toBe(
diff --git a/frontend/src/lib/locales/de/navigation.js b/frontend/src/lib/locales/de/navigation.js
index d26340650..874466e07 100644
--- a/frontend/src/lib/locales/de/navigation.js
+++ b/frontend/src/lib/locales/de/navigation.js
@@ -425,4 +425,9 @@ export default {
selectWorkspace: 'Wählen Sie einen Workspace zum Starten',
noWorkspacesAvailable: 'Es sind noch keine Workspaces verfügbar. Bitte kontaktieren Sie Ihren Administrator, um Zugang zu einem Workspace zu erhalten.',
},
+ workspaceDashboard: {
+ widgets: {
+ zammadSupportOverview: { name: 'Zammad-Supportübersicht', description: 'Workspaceweiter Stand verknüpfter Tickets und beobachtete Änderungen' },
+ },
+ },
};
diff --git a/frontend/src/lib/locales/de/zammad.js b/frontend/src/lib/locales/de/zammad.js
index 2c3619600..2997ffaf9 100644
--- a/frontend/src/lib/locales/de/zammad.js
+++ b/frontend/src/lib/locales/de/zammad.js
@@ -95,6 +95,35 @@ export default { zammad: {
ticketLinkRemoveFailed: 'Zammad-Ticketzuordnung konnte nicht entfernt werden',
ticketNumber: 'Zammad #{number}',
lastSynced: 'Synchronisiert: {time}',
+ timeline: {
+ toggle: 'Beobachtete Änderungen',
+ observedHint: 'Von Windshift bei der Synchronisierung beobachtete Änderungen. Dies ist kein vollständiger Zammad-Audit-Verlauf.',
+ empty: 'Bisher keine Ticketänderungen beobachtet.',
+ loadFailed: 'Beobachtete Zammad-Änderungen konnten nicht geladen werden',
+ initialValue: 'nicht gesetzt',
+ valueIdFallback: 'ID {id}',
+ change: '{field}: von {from} auf {to}',
+ field: { status: 'Status', group: 'Gruppe', owner: 'Verantwortlich', changed: 'Ticketfeld' },
+ },
+ overview: {
+ loading: 'Workspaceweite Zammad-Supportübersicht wird geladen…',
+ loadFailed: 'Zammad-Supportübersicht konnte nicht geladen werden',
+ emptyTitle: 'Keine verknüpften Zammad-Tickets',
+ emptyDescription: 'Dieser Workspace hat keine verknüpften Zammad-Tickets.',
+ observedHint: 'Workspaceweiter, von Windshift bei der Synchronisierung beobachteter Ticketstand.',
+ refresh: 'Aktualisieren',
+ byStatus: 'Statusverteilung',
+ statusWithConnection: '{status} ({connection})',
+ statusIdFallback: 'Status-ID {id}',
+ unknownStatusBucket: 'Unbekannter Status',
+ recentChanges: 'Zuletzt beobachtete Änderungen',
+ noRecentChanges: 'Bisher keine Änderungen beobachtet.',
+ syncStateHint: 'Einige Ticketverknüpfungen benötigen Nacharbeit in Zammad oder den Verbindungseinstellungen.',
+ metrics: {
+ total: 'Gesamt', active: 'Aktiv', closed: 'Geschlossen', unassigned: 'Nicht zugewiesen',
+ syncFailed: 'Synchronisierung fehlgeschlagen', creationUncertain: 'Erstellung unklar', unknownStatus: 'Unbekannter Status',
+ },
+ },
returnLinkResolving: 'Der verknüpfte Windshift-Eintrag wird geöffnet...',
returnLinkFailed: 'Der verknüpfte Windshift-Eintrag konnte nicht geöffnet werden. Er existiert möglicherweise nicht mehr oder du hast keinen Zugriff.',
syncState: {
diff --git a/frontend/src/lib/locales/en/navigation.js b/frontend/src/lib/locales/en/navigation.js
index 4599feb0b..d43cb6c30 100644
--- a/frontend/src/lib/locales/en/navigation.js
+++ b/frontend/src/lib/locales/en/navigation.js
@@ -344,6 +344,7 @@ export default {
upcomingDeadlines: { name: 'Upcoming Deadlines', description: 'Items with approaching due dates' },
iterationTimeline: { name: 'Iteration Timeline', description: 'Current and upcoming iteration schedule' },
testCoverage: { name: 'Test Coverage', description: 'Requirements covered by test cases' },
+ zammadSupportOverview: { name: 'Zammad support overview', description: 'Workspace-wide linked-ticket status and observed changes' },
},
customization: {
widgets: 'Widgets', builtIn: 'Built-in widgets', builtInDescription: 'Core workspace metrics and charts',
diff --git a/frontend/src/lib/locales/en/zammad.js b/frontend/src/lib/locales/en/zammad.js
index 705eeae5e..e1bbffb8f 100644
--- a/frontend/src/lib/locales/en/zammad.js
+++ b/frontend/src/lib/locales/en/zammad.js
@@ -95,6 +95,35 @@ export default { zammad: {
ticketLinkRemoveFailed: 'Failed to remove Zammad ticket assignment',
ticketNumber: 'Zammad #{number}',
lastSynced: 'Synced {time}',
+ timeline: {
+ toggle: 'Observed changes',
+ observedHint: 'Changes observed by Windshift during synchronization. This is not a complete Zammad audit trail.',
+ empty: 'No observed ticket changes yet.',
+ loadFailed: 'Failed to load observed Zammad changes',
+ initialValue: 'not set',
+ valueIdFallback: 'ID {id}',
+ change: '{field}: {from} -> {to}',
+ field: { status: 'Status', group: 'Group', owner: 'Owner', changed: 'Ticket field' },
+ },
+ overview: {
+ loading: 'Loading workspace-wide Zammad support status…',
+ loadFailed: 'Failed to load Zammad support overview',
+ emptyTitle: 'No linked Zammad tickets',
+ emptyDescription: 'This workspace has no linked Zammad tickets.',
+ observedHint: 'Workspace-wide ticket status observed by Windshift during synchronization.',
+ refresh: 'Refresh',
+ byStatus: 'Status distribution',
+ statusWithConnection: '{connection} · {status}',
+ statusIdFallback: 'Status ID {id}',
+ unknownStatusBucket: 'Unknown status',
+ recentChanges: 'Recent observed changes',
+ noRecentChanges: 'No observed changes yet.',
+ syncStateHint: 'Some ticket links need follow-up in Zammad or its connection settings.',
+ metrics: {
+ total: 'Total', active: 'Active', closed: 'Closed', unassigned: 'Unassigned',
+ syncFailed: 'Sync failed', creationUncertain: 'Creation uncertain', unknownStatus: 'Unknown status',
+ },
+ },
returnLinkResolving: 'Opening the linked Windshift item...',
returnLinkFailed: 'The linked Windshift item could not be opened. It may no longer exist or you may not have access.',
syncState: {
diff --git a/frontend/src/lib/locales/ru/navigation.js b/frontend/src/lib/locales/ru/navigation.js
index 67cd7aaf5..f581f8d95 100644
--- a/frontend/src/lib/locales/ru/navigation.js
+++ b/frontend/src/lib/locales/ru/navigation.js
@@ -334,6 +334,7 @@ export default {
upcomingDeadlines: { name: 'Ближайшие сроки', description: 'Элементы с приближающимся сроком' },
iterationTimeline: { name: 'График итераций', description: 'Текущие и ближайшие итерации' },
testCoverage: { name: 'Покрытие тестами', description: 'Требования, связанные с тест-кейсами' },
+ zammadSupportOverview: { name: 'Обзор поддержки Zammad', description: 'Статус связанных заявок всего пространства и наблюдаемые изменения' },
},
customization: {
widgets: 'Виджеты', builtIn: 'Основные виджеты', builtInDescription: 'Основные показатели и графики пространства',
diff --git a/frontend/src/lib/locales/ru/zammad.js b/frontend/src/lib/locales/ru/zammad.js
index 4de734d5f..1eeba2db9 100644
--- a/frontend/src/lib/locales/ru/zammad.js
+++ b/frontend/src/lib/locales/ru/zammad.js
@@ -95,6 +95,35 @@ export default { zammad: {
ticketLinkRemoveFailed: 'Не удалось удалить связь с заявкой Zammad',
ticketNumber: 'Zammad №{number}',
lastSynced: 'Синхронизировано: {time}',
+ timeline: {
+ toggle: 'Наблюдаемые изменения',
+ observedHint: 'Изменения, замеченные Windshift во время синхронизации. Это не полный журнал аудита Zammad.',
+ empty: 'Наблюдаемых изменений заявки пока нет.',
+ loadFailed: 'Не удалось загрузить наблюдаемые изменения Zammad',
+ initialValue: 'не задано',
+ valueIdFallback: 'ID {id}',
+ change: '{field}: с {from} на {to}',
+ field: { status: 'Статус', group: 'Группа', owner: 'Ответственный', changed: 'Поле заявки' },
+ },
+ overview: {
+ loading: 'Загружается обзор поддержки Zammad для рабочего пространства…',
+ loadFailed: 'Не удалось загрузить обзор поддержки Zammad',
+ emptyTitle: 'Нет связанных заявок Zammad',
+ emptyDescription: 'В этом рабочем пространстве нет связанных заявок Zammad.',
+ observedHint: 'Состояние заявок всего рабочего пространства, наблюдаемое Windshift при синхронизации.',
+ refresh: 'Обновить',
+ byStatus: 'Распределение по статусам',
+ statusWithConnection: '{status} ({connection})',
+ statusIdFallback: 'ID статуса {id}',
+ unknownStatusBucket: 'Неизвестный статус',
+ recentChanges: 'Последние наблюдаемые изменения',
+ noRecentChanges: 'Наблюдаемых изменений пока нет.',
+ syncStateHint: 'Некоторые связи заявок требуют проверки в Zammad или настройках подключения.',
+ metrics: {
+ total: 'Всего', active: 'Активные', closed: 'Закрытые', unassigned: 'Не назначены',
+ syncFailed: 'Ошибка синхронизации', creationUncertain: 'Создание не подтверждено', unknownStatus: 'Неизвестный статус',
+ },
+ },
returnLinkResolving: 'Открывается связанный элемент Windshift...',
returnLinkFailed: 'Не удалось открыть связанный элемент Windshift. Возможно, он больше не существует или у вас нет доступа.',
syncState: {
diff --git a/frontend/src/lib/services/widgetRegistry.js b/frontend/src/lib/services/widgetRegistry.js
index 228b3ba8b..104ae489d 100644
--- a/frontend/src/lib/services/widgetRegistry.js
+++ b/frontend/src/lib/services/widgetRegistry.js
@@ -108,6 +108,18 @@ export const widgetRegistry = [
defaultWidth: 2,
maxWidth: 3,
},
+ {
+ type: 'zammad-support-overview',
+ name: 'Zammad support overview',
+ description: 'Current linked-ticket status and recent observed changes',
+ nameKey: 'workspaceDashboard.widgets.zammadSupportOverview.name',
+ descriptionKey: 'workspaceDashboard.widgets.zammadSupportOverview.description',
+ category: widgetCategories.ADDITIONAL,
+ icon: 'TicketCheck',
+ minWidth: 2,
+ defaultWidth: 3,
+ maxWidth: 3,
+ },
// Additional widgets (calendar/timeline widgets)
{
diff --git a/frontend/src/lib/utils/icons.js b/frontend/src/lib/utils/icons.js
index 1f81a8782..876f03e8e 100644
--- a/frontend/src/lib/utils/icons.js
+++ b/frontend/src/lib/utils/icons.js
@@ -94,6 +94,7 @@ import {
Tag,
Target,
Terminal,
+ TicketCheck,
Trash,
Truck,
Upload,
@@ -262,6 +263,7 @@ export const workspaceIconMap = {
Watch,
Cloud,
BarChart,
+ TicketCheck,
};
/**
diff --git a/frontend/src/lib/utils/zammadObservations.js b/frontend/src/lib/utils/zammadObservations.js
new file mode 100644
index 000000000..f25571492
--- /dev/null
+++ b/frontend/src/lib/utils/zammadObservations.js
@@ -0,0 +1,49 @@
+function nonEmptyName(value) {
+ return typeof value?.name === 'string' ? value.name.trim() : '';
+}
+
+function positiveId(value) {
+ const id = Number(value?.id);
+ return Number.isFinite(id) && id > 0 ? value.id : null;
+}
+
+export function getZammadObservedValueLabel(value, translate) {
+ const name = nonEmptyName(value);
+ if (name) return name;
+
+ const id = positiveId(value);
+ if (id !== null) return translate('zammad.timeline.valueIdFallback', { id });
+
+ return translate('zammad.timeline.initialValue');
+}
+
+export function getZammadStatusBucketLabel(status, translate) {
+ const name = nonEmptyName(status);
+ if (name) return name;
+
+ const id = positiveId(status);
+ if (id !== null) return translate('zammad.overview.statusIdFallback', { id });
+
+ return translate('zammad.overview.unknownStatusBucket');
+}
+
+export function getZammadStatusBucketDisplayLabel(status, translate) {
+ const statusLabel = getZammadStatusBucketLabel(status, translate);
+ const connectionName =
+ typeof status?.connection_name === 'string' ? status.connection_name.trim() : '';
+ if (!connectionName) return statusLabel;
+
+ return translate('zammad.overview.statusWithConnection', {
+ connection: connectionName,
+ status: statusLabel,
+ });
+}
+
+export function isCurrentZammadWorkspaceOverviewRequest(
+ requestVersion,
+ currentVersion,
+ requestWorkspaceId,
+ workspaceId
+) {
+ return requestVersion === currentVersion && String(requestWorkspaceId) === String(workspaceId);
+}
diff --git a/frontend/src/lib/widgets/ZammadSupportWidget.svelte b/frontend/src/lib/widgets/ZammadSupportWidget.svelte
new file mode 100644
index 000000000..fb5703e16
--- /dev/null
+++ b/frontend/src/lib/widgets/ZammadSupportWidget.svelte
@@ -0,0 +1,212 @@
+
+
+ loadOverview(workspaceId)}
+>
+ {#snippet children()}
+
+
+
{t('zammad.overview.observedHint')}
+
loadOverview(workspaceId)}
+ disabled={loading}
+ title={t('zammad.overview.refresh')}
+ aria-label={t('zammad.overview.refresh')}
+ style="color: var(--ds-link);"
+ >
+
+ {t('zammad.overview.refresh')}
+
+
+
+
+ {#each metricKeys as [key, label]}
+
+
- {t(`zammad.overview.metrics.${label}`)}
+ - {overview[key]}
+
+ {/each}
+
+
+
+ {t('zammad.overview.byStatus')}
+
+ {#each overview.by_status as status}
+ -
+ {statusBucketLabel(status)}: {status.count}
+
+ {/each}
+
+
+
+
+ {t('zammad.overview.recentChanges')}
+ {#if overview.recent_changes.length === 0}
+ {t('zammad.overview.noRecentChanges')}
+ {:else}
+
+ {#each overview.recent_changes as change (change.id)}
+ -
+
+
+
+ {change.item_key}
+
+
· {t('zammad.ticketNumber', { number: change.ticket_number })}
+
{changeLabel(change)}
+
+
+
+ {/each}
+
+ {/if}
+
+
+ {#if overview.sync_failed > 0 || overview.creation_uncertain > 0 || overview.unknown_status > 0}
+
+
+ {t('zammad.overview.syncStateHint')}
+
+ {/if}
+
+ {/snippet}
+
diff --git a/frontend/src/lib/workspaces/WorkspaceWelcome.svelte b/frontend/src/lib/workspaces/WorkspaceWelcome.svelte
index 6a87cb300..f741e367a 100644
--- a/frontend/src/lib/workspaces/WorkspaceWelcome.svelte
+++ b/frontend/src/lib/workspaces/WorkspaceWelcome.svelte
@@ -50,6 +50,7 @@
import UpcomingDeadlinesWidget from '../widgets/UpcomingDeadlinesWidget.svelte';
import IterationTimelineWidget from '../widgets/IterationTimelineWidget.svelte';
import TestCoverageWidget from '../widgets/TestCoverageWidget.svelte';
+ import ZammadSupportWidget from '../widgets/ZammadSupportWidget.svelte';
import SavedSearchWidget from '../widgets/dashboard/SavedSearchWidget.svelte';
// Customization sidebar
@@ -859,6 +860,8 @@
{:else if widget.type === 'test-coverage'}
+ {:else if widget.type === 'zammad-support-overview'}
+
{:else if widget.type === 'saved-search'}
100 {
+ respondValidationError(w, r, "limit must be between 1 and 100")
+ return 0, false
+ }
+ return limit, true
+}
+
// ResolveTicketLink turns the opaque correlation key stored in Zammad into a
// current Windshift item destination. Item permissions are checked before the
// destination is disclosed so the link cannot be used to enumerate items.
diff --git a/internal/models/zammad.go b/internal/models/zammad.go
index 700aee094..8cf75f864 100644
--- a/internal/models/zammad.go
+++ b/internal/models/zammad.go
@@ -208,3 +208,41 @@ func (link *ZammadTicketLink) Response() ZammadTicketLinkResponse {
CreatedAt: link.CreatedAt, UpdatedAt: link.UpdatedAt,
}
}
+
+type ZammadTicketChangeValue struct {
+ ID int `json:"id,omitempty"`
+ Name string `json:"name,omitempty"`
+}
+
+type ZammadTicketChange struct {
+ ID string `json:"id"`
+ LinkID string `json:"link_id"`
+ ItemID int `json:"item_id"`
+ ItemKey string `json:"item_key"`
+ TicketNumber string `json:"ticket_number,omitempty"`
+ Field string `json:"field"`
+ OldValue ZammadTicketChangeValue `json:"old_value"`
+ NewValue ZammadTicketChangeValue `json:"new_value"`
+ ObservedAt time.Time `json:"observed_at"`
+}
+
+type ZammadOverviewBucket struct {
+ ConnectionID string `json:"connection_id"`
+ ConnectionName string `json:"connection_name"`
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Count int `json:"count"`
+ Closed bool `json:"closed,omitempty"`
+}
+
+type ZammadWorkspaceOverview struct {
+ Total int `json:"total"`
+ Active int `json:"active"`
+ Closed int `json:"closed"`
+ Unassigned int `json:"unassigned"`
+ SyncFailed int `json:"sync_failed"`
+ CreationUncertain int `json:"creation_uncertain"`
+ UnknownStatus int `json:"unknown_status"`
+ ByStatus []ZammadOverviewBucket `json:"by_status"`
+ RecentChanges []ZammadTicketChange `json:"recent_changes"`
+}
diff --git a/internal/repository/zammad_repository.go b/internal/repository/zammad_repository.go
index 59f64333d..6120b2a78 100644
--- a/internal/repository/zammad_repository.go
+++ b/internal/repository/zammad_repository.go
@@ -10,6 +10,8 @@ import (
"windshift/internal/database"
"windshift/internal/models"
+
+ "uuid"
)
type ZammadRepository struct {
@@ -49,6 +51,17 @@ type ZammadConnectionMutationSnapshot struct {
CompletionStatusID *int
}
+type ZammadWorkspaceTicketLink struct {
+ Link *models.ZammadTicketLink
+ ItemKey string
+ ClosedStateIDs []int
+}
+
+type zammadReadQueryer interface {
+ Query(query string, args ...any) (*sql.Rows, error)
+ QueryRow(query string, args ...any) *sql.Row
+}
+
func NewZammadRepository(db database.Database) *ZammadRepository {
return &ZammadRepository{db: db}
}
@@ -623,6 +636,164 @@ func (r *ZammadRepository) GetTicketLinkForItem(itemID int, providerID string) (
return link, err
}
+func (r *ZammadRepository) ListTicketChangesForItem(itemID, limit int) ([]models.ZammadTicketChange, error) {
+ rows, err := r.db.Query(`SELECT ztc.id, ztc.ticket_link_id, ztl.item_id,
+ w.key || '-' || CAST(i.workspace_item_number AS TEXT), ztl.ticket_number,
+ ztc.field_name, ztc.old_value_id, ztc.old_value_name, ztc.new_value_id, ztc.new_value_name, ztc.observed_at
+ FROM zammad_ticket_changes ztc
+ JOIN zammad_ticket_links ztl ON ztl.id = ztc.ticket_link_id
+ JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id
+ JOIN items i ON i.id = ztl.item_id
+ JOIN workspaces w ON w.id = i.workspace_id
+ WHERE ztl.item_id = ? AND (
+ zc.applies_to_all_workspaces = true OR EXISTS (
+ SELECT 1 FROM zammad_connection_workspaces zcw
+ WHERE zcw.provider_id = ztl.provider_id AND zcw.workspace_id = i.workspace_id
+ )
+ )
+ ORDER BY ztc.observed_at DESC, ztc.id DESC LIMIT ?`, itemID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ return scanZammadTicketChanges(rows)
+}
+
+func (r *ZammadRepository) ListRecentTicketChangesForWorkspace(workspaceID, limit int) ([]models.ZammadTicketChange, error) {
+ return listRecentTicketChangesForWorkspace(r.db, workspaceID, limit)
+}
+
+func (r *ZammadRepository) ListRecentTicketChangesForWorkspaceTx(tx database.Tx, workspaceID, limit int) ([]models.ZammadTicketChange, error) {
+ return listRecentTicketChangesForWorkspace(tx, workspaceID, limit)
+}
+
+func listRecentTicketChangesForWorkspace(q zammadReadQueryer, workspaceID, limit int) ([]models.ZammadTicketChange, error) {
+ rows, err := q.Query(`SELECT ztc.id, ztc.ticket_link_id, ztl.item_id,
+ w.key || '-' || CAST(i.workspace_item_number AS TEXT), ztl.ticket_number,
+ ztc.field_name, ztc.old_value_id, ztc.old_value_name, ztc.new_value_id, ztc.new_value_name, ztc.observed_at
+ FROM zammad_ticket_changes ztc
+ JOIN zammad_ticket_links ztl ON ztl.id = ztc.ticket_link_id
+ JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id
+ JOIN items i ON i.id = ztl.item_id
+ JOIN workspaces w ON w.id = i.workspace_id
+ WHERE i.workspace_id = ? AND (
+ zc.applies_to_all_workspaces = true OR EXISTS (
+ SELECT 1 FROM zammad_connection_workspaces zcw
+ WHERE zcw.provider_id = ztl.provider_id AND zcw.workspace_id = i.workspace_id
+ )
+ )
+ ORDER BY ztc.observed_at DESC, ztc.id DESC LIMIT ?`, workspaceID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ return scanZammadTicketChanges(rows)
+}
+
+func scanZammadTicketChanges(rows *sql.Rows) ([]models.ZammadTicketChange, error) {
+ changes := []models.ZammadTicketChange{}
+ for rows.Next() {
+ var change models.ZammadTicketChange
+ var oldID, newID sql.NullInt64
+ var oldName, newName sql.NullString
+ if err := rows.Scan(&change.ID, &change.LinkID, &change.ItemID, &change.ItemKey, &change.TicketNumber,
+ &change.Field, &oldID, &oldName, &newID, &newName, &change.ObservedAt); err != nil {
+ return nil, err
+ }
+ if oldID.Valid {
+ change.OldValue.ID = int(oldID.Int64)
+ }
+ if oldName.Valid {
+ change.OldValue.Name = oldName.String
+ }
+ if newID.Valid {
+ change.NewValue.ID = int(newID.Int64)
+ }
+ if newName.Valid {
+ change.NewValue.Name = newName.String
+ }
+ changes = append(changes, change)
+ }
+ return changes, rows.Err()
+}
+
+func (r *ZammadRepository) ListTicketLinksForWorkspace(workspaceID int) ([]ZammadWorkspaceTicketLink, error) {
+ return listTicketLinksForWorkspace(r.db, workspaceID)
+}
+
+func (r *ZammadRepository) ListTicketLinksForWorkspaceTx(tx database.Tx, workspaceID int) ([]ZammadWorkspaceTicketLink, error) {
+ return listTicketLinksForWorkspace(tx, workspaceID)
+}
+
+func listTicketLinksForWorkspace(q zammadReadQueryer, workspaceID int) ([]ZammadWorkspaceTicketLink, error) {
+ rows, err := q.Query(`SELECT `+zammadTicketLinkColumns+`,
+ w.key || '-' || CAST(i.workspace_item_number AS TEXT), zc.closed_state_ids
+ FROM zammad_ticket_links ztl
+ JOIN integration_providers ip ON ip.id = ztl.provider_id
+ JOIN items i ON i.id = ztl.item_id
+ JOIN workspaces w ON w.id = i.workspace_id
+ JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id
+ WHERE i.workspace_id = ? AND ztl.ticket_id IS NOT NULL AND ztl.item_integration_link_id IS NOT NULL
+ AND (
+ zc.applies_to_all_workspaces = true OR EXISTS (
+ SELECT 1 FROM zammad_connection_workspaces zcw
+ WHERE zcw.provider_id = ztl.provider_id AND zcw.workspace_id = i.workspace_id
+ )
+ )`, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ result := []ZammadWorkspaceTicketLink{}
+ for rows.Next() {
+ var itemKey, closedJSON string
+ link, err := scanZammadTicketLink(zammadWorkspaceTicketLinkScanner{rows: rows, itemKey: &itemKey, closedJSON: &closedJSON})
+ if err != nil {
+ return nil, err
+ }
+ entry := ZammadWorkspaceTicketLink{Link: link, ItemKey: itemKey}
+ if err := json.Unmarshal([]byte(closedJSON), &entry.ClosedStateIDs); err != nil {
+ return nil, err
+ }
+ result = append(result, entry)
+ }
+ return result, rows.Err()
+}
+
+type zammadWorkspaceTicketLinkScanner struct {
+ rows *sql.Rows
+ itemKey *string
+ closedJSON *string
+}
+
+func (s zammadWorkspaceTicketLinkScanner) Scan(dest ...any) error {
+ return s.rows.Scan(append(dest, s.itemKey, s.closedJSON)...)
+}
+
+func (r *ZammadRepository) CountProblemTicketLinksForWorkspace(workspaceID int) (syncFailed, creationUncertain int, err error) {
+ return countProblemTicketLinksForWorkspace(r.db, workspaceID)
+}
+
+func (r *ZammadRepository) CountProblemTicketLinksForWorkspaceTx(tx database.Tx, workspaceID int) (syncFailed, creationUncertain int, err error) {
+ return countProblemTicketLinksForWorkspace(tx, workspaceID)
+}
+
+func countProblemTicketLinksForWorkspace(q zammadReadQueryer, workspaceID int) (syncFailed, creationUncertain int, err error) {
+ err = q.QueryRow(`SELECT
+ COALESCE(SUM(CASE WHEN ztl.sync_state = ? THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN ztl.sync_state = ? THEN 1 ELSE 0 END), 0)
+ FROM zammad_ticket_links ztl
+ JOIN items i ON i.id = ztl.item_id
+ JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id
+ WHERE i.workspace_id = ? AND (
+ zc.applies_to_all_workspaces = true OR EXISTS (
+ SELECT 1 FROM zammad_connection_workspaces zcw
+ WHERE zcw.provider_id = ztl.provider_id AND zcw.workspace_id = i.workspace_id
+ )
+ )`, models.ZammadSyncFailed, models.ZammadSyncUncertain, workspaceID).Scan(&syncFailed, &creationUncertain)
+ return syncFailed, creationUncertain, err
+}
+
// GetItemDestinationByCorrelationKey resolves the durable key and current
// workspace in one snapshot through the provider/key uniqueness boundary.
func (r *ZammadRepository) GetItemDestinationByCorrelationKey(providerID, correlationKey string) (itemID, workspaceID int, err error) {
@@ -905,6 +1076,41 @@ func (r *ZammadRepository) UpdateTicketLinkSyncTx(tx database.Tx, id, syncOwner
if syncOwner == "" {
return ErrInvalidInput
}
+ var previousStatusID, previousGroupID, previousOwnerID sql.NullInt64
+ var previousStatusName, previousGroupName, previousOwnerName sql.NullString
+ query := `SELECT last_status_id, last_status_name, group_id, group_name, owner_id, owner_name
+ FROM zammad_ticket_links WHERE id = ? AND item_integration_link_id IS NOT NULL AND sync_lock_owner = ?`
+ if r.db.GetDriverName() == "postgres" {
+ query += " FOR UPDATE"
+ }
+ if err := tx.QueryRow(query, id, syncOwner).Scan(&previousStatusID, &previousStatusName, &previousGroupID, &previousGroupName, &previousOwnerID, &previousOwnerName); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrConcurrentUpdate
+ }
+ return err
+ }
+ changes := []struct {
+ field string
+ oldID sql.NullInt64
+ oldName sql.NullString
+ newID int
+ newName string
+ }{
+ {"status", previousStatusID, previousStatusName, statusID, statusName},
+ {"owner", previousOwnerID, previousOwnerName, ownerID, ownerName},
+ {"group", previousGroupID, previousGroupName, groupID, groupName},
+ }
+ for _, change := range changes {
+ if !nullableIDChanged(change.oldID, change.newID) {
+ continue
+ }
+ if _, err := tx.ExecWrite(`INSERT INTO zammad_ticket_changes
+ (id, ticket_link_id, field_name, old_value_id, old_value_name, new_value_id, new_value_name, observed_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, uuid.New().String(), id, change.field,
+ nullableIntValue(change.oldID), nullableStringValue(change.oldName), nullablePositiveInt(change.newID), change.newName, now); err != nil {
+ return err
+ }
+ }
state := models.ZammadSyncLinked
var nextAttempt any
if safeError != "" {
@@ -935,6 +1141,27 @@ func (r *ZammadRepository) UpdateTicketLinkSyncTx(tx database.Tx, id, syncOwner
return nil
}
+func nullableIDChanged(previous sql.NullInt64, next int) bool {
+ if previous.Valid != (next > 0) {
+ return true
+ }
+ return previous.Valid && previous.Int64 != int64(next)
+}
+
+func nullableIntValue(value sql.NullInt64) any {
+ if !value.Valid {
+ return nil
+ }
+ return value.Int64
+}
+
+func nullableStringValue(value sql.NullString) string {
+ if !value.Valid {
+ return ""
+ }
+ return value.String
+}
+
func (r *ZammadRepository) ListDueTicketLinks(before time.Time, limit int) ([]*models.ZammadTicketLink, error) {
rows, err := r.db.Query(`SELECT `+zammadTicketLinkColumns+`
FROM zammad_ticket_links ztl
diff --git a/internal/routes/integrations.go b/internal/routes/integrations.go
index a84c05076..4d88dfab7 100644
--- a/internal/routes/integrations.go
+++ b/internal/routes/integrations.go
@@ -57,7 +57,9 @@ func RegisterIntegrationRoutes(deps *Deps) {
api.HandleH("GET /workspaces/{workspaceId}/zammad-connections", auth(http.HandlerFunc(deps.Integrations.Zammad.ListWorkspaceConnections)))
api.HandleH("GET /workspaces/{workspaceId}/zammad-connections/{id}/metadata", auth(http.HandlerFunc(deps.Integrations.Zammad.GetWorkspaceMetadata)))
api.HandleH("GET /workspaces/{workspaceId}/zammad-connections/{id}/owners", auth(http.HandlerFunc(deps.Integrations.Zammad.GetWorkspaceOwners)))
+ api.HandleH("GET /workspaces/{workspaceId}/zammad-overview", auth(http.HandlerFunc(deps.Integrations.Zammad.GetWorkspaceOverview)))
api.HandleH("GET /items/{id}/zammad-links", auth(http.HandlerFunc(deps.Integrations.Zammad.GetItemLinks)))
+ api.HandleH("GET /items/{id}/zammad-history", auth(http.HandlerFunc(deps.Integrations.Zammad.GetItemHistory)))
api.HandleH("GET /zammad-ticket-links/resolve/{correlationKey}", auth(http.HandlerFunc(deps.Integrations.Zammad.ResolveTicketLink)))
api.HandleH("POST /items/{id}/zammad-tickets", auth(http.HandlerFunc(deps.Integrations.Zammad.CreateTicket)))
api.HandleH("POST /items/{id}/zammad-ticket-links", auth(http.HandlerFunc(deps.Integrations.Zammad.LinkExistingTicket)))
diff --git a/internal/services/zammad_service.go b/internal/services/zammad_service.go
index e61600ad9..3f16472f6 100644
--- a/internal/services/zammad_service.go
+++ b/internal/services/zammad_service.go
@@ -2,6 +2,7 @@ package services
import (
"context"
+ "database/sql"
"encoding/json"
"errors"
"fmt"
@@ -9,6 +10,7 @@ import (
"net/url"
"regexp"
"slices"
+ "sort"
"strconv"
"strings"
"time"
@@ -908,6 +910,89 @@ func (s *ZammadService) GetTicketLink(id string) (*models.ZammadTicketLink, erro
return s.repo.GetTicketLink(id)
}
+func (s *ZammadService) TicketHistoryForItem(itemID, limit int) ([]models.ZammadTicketChange, error) {
+ return s.repo.ListTicketChangesForItem(itemID, limit)
+}
+
+func (s *ZammadService) WorkspaceOverview(workspaceID, recentLimit int) (*models.ZammadWorkspaceOverview, error) {
+ ctx := context.Background()
+ var tx database.Tx
+ var err error
+ if database.IsPostgresDriver(s.db.GetDriverName()) {
+ tx, err = s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
+ } else {
+ // The SQLite wrapper normally starts transactions on its single writer
+ // connection. This is a read-only snapshot, so keep it on the read pool
+ // instead of serializing unrelated synchronization writes.
+ var sqliteTx *sql.Tx
+ sqliteTx, err = s.db.GetDB().BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
+ if err == nil {
+ tx = database.NewSQLiteTx(sqliteTx)
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = tx.Rollback() }()
+ links, err := s.repo.ListTicketLinksForWorkspaceTx(tx, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ recent, err := s.repo.ListRecentTicketChangesForWorkspaceTx(tx, workspaceID, recentLimit)
+ if err != nil {
+ return nil, err
+ }
+ syncFailed, creationUncertain, err := s.repo.CountProblemTicketLinksForWorkspaceTx(tx, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ overview := &models.ZammadWorkspaceOverview{ByStatus: []models.ZammadOverviewBucket{}, RecentChanges: recent}
+ overview.SyncFailed = syncFailed
+ overview.CreationUncertain = creationUncertain
+ statusBuckets := map[string]*models.ZammadOverviewBucket{}
+ for _, entry := range links {
+ link := entry.Link
+ overview.Total++
+ closed := link.LastStatusID > 0 && slices.Contains(entry.ClosedStateIDs, link.LastStatusID)
+ if link.LastStatusID <= 0 {
+ overview.UnknownStatus++
+ } else if closed {
+ overview.Closed++
+ } else {
+ overview.Active++
+ }
+ if link.OwnerID <= 1 {
+ overview.Unassigned++
+ }
+ key := fmt.Sprintf("%s:%d:%t", link.ProviderID, link.LastStatusID, closed)
+ bucket := statusBuckets[key]
+ if bucket == nil {
+ bucket = &models.ZammadOverviewBucket{
+ ConnectionID: link.ProviderID, ConnectionName: link.ProviderName,
+ ID: link.LastStatusID, Name: link.LastStatusName, Closed: closed,
+ }
+ statusBuckets[key] = bucket
+ }
+ bucket.Count++
+ }
+ for _, bucket := range statusBuckets {
+ overview.ByStatus = append(overview.ByStatus, *bucket)
+ }
+ sort.Slice(overview.ByStatus, func(i, j int) bool {
+ if overview.ByStatus[i].ConnectionID != overview.ByStatus[j].ConnectionID {
+ return overview.ByStatus[i].ConnectionID < overview.ByStatus[j].ConnectionID
+ }
+ if overview.ByStatus[i].Name == overview.ByStatus[j].Name {
+ return overview.ByStatus[i].ID < overview.ByStatus[j].ID
+ }
+ return overview.ByStatus[i].Name < overview.ByStatus[j].Name
+ })
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
+ return overview, nil
+}
+
func (s *ZammadService) ResolveTicketLink(correlationKey string) (itemID, workspaceID int, err error) {
correlationKey = strings.TrimSpace(correlationKey)
if correlationKey == "" || len(correlationKey) > 512 {
diff --git a/internal/services/zammad_service_test.go b/internal/services/zammad_service_test.go
index 37ed3c2db..9c22267a3 100644
--- a/internal/services/zammad_service_test.go
+++ b/internal/services/zammad_service_test.go
@@ -1144,6 +1144,96 @@ func TestZammadSyncPersistsRemoteGroupAndOwner(t *testing.T) {
if synced.GroupID != 8 || synced.GroupName != "Escalations" || synced.OwnerID != 99 || synced.OwnerName != "Grace Hopper" || synced.LastSyncedAt == nil {
t.Fatalf("sync did not retain remote group/owner state: %#v", synced)
}
+ history, err := f.service.TicketHistoryForItem(f.item1, 10)
+ if err != nil || len(history) != 3 {
+ t.Fatalf("expected one event per observed status/group/owner change: history=%#v err=%v", history, err)
+ }
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ history, err = f.service.TicketHistoryForItem(f.item1, 10)
+ if err != nil || len(history) != 3 {
+ t.Fatalf("identical snapshot duplicated observed changes: history=%#v err=%v", history, err)
+ }
+ if _, err := f.db.ExecWrite(`UPDATE items SET workspace_id = ? WHERE id = ?`, f.workspace2, f.item1); err != nil {
+ t.Fatal(err)
+ }
+ history, err = f.service.TicketHistoryForItem(f.item1, 10)
+ if err != nil || len(history) != 0 {
+ t.Fatalf("history remained visible after the item moved outside the connection scope: history=%#v err=%v", history, err)
+ }
+ overview, err := f.service.WorkspaceOverview(f.workspace2, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if overview.Total != 0 || len(overview.RecentChanges) != 0 {
+ t.Fatalf("workspace overview disclosed a connection that is unavailable there: %#v", overview)
+ }
+}
+
+func TestZammadWorkspaceOverviewCountsOnlyCompleteTicketLinks(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.service.CreateTicket(context.Background(), f.item2, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err == nil {
+ t.Fatal("out-of-scope ticket creation unexpectedly succeeded")
+ }
+ overview, err := f.service.WorkspaceOverview(f.workspace1, 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if overview.Total != 1 || overview.Active != 1 || overview.Closed != 0 || overview.UnknownStatus != 0 || overview.Unassigned != 1 {
+ t.Fatalf("unexpected overview for complete link: %#v", overview)
+ }
+ if len(overview.ByStatus) != 1 || overview.ByStatus[0].Count != 1 {
+ t.Fatalf("overview omitted linked ticket status: %#v", overview.ByStatus)
+ }
+}
+
+func TestZammadWorkspaceOverviewSeparatesSameStatusAcrossConnections(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err != nil {
+ t.Fatal(err)
+ }
+ second, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "second-helpdesk", Name: "Second helpdesk", BaseURL: "https://second-zammad.example.test",
+ APIToken: "synthetic-zammad-token", DefaultGroupID: 7, DefaultGroupName: "Support",
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite(`INSERT INTO item_integration_links
+ (id, item_id, integration_provider_id, external_id, external_url, title, link_type, linked_by)
+ VALUES (?, ?, ?, ?, ?, ?, 'ticket', ?)`, "second-link-external", strconv.Itoa(f.item1), second.ProviderID, "902", "https://second-zammad.example.test/#ticket/zoom/902", "Zammad #902", strconv.Itoa(f.actorID)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite(`INSERT INTO zammad_ticket_links
+ (id, item_id, provider_id, item_integration_link_id, ticket_id, ticket_number, group_id, group_name, correlation_key, sync_state, last_status_id, last_status_name, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "second-link", f.item1, second.ProviderID, "second-link-external", 902, "902", 7, "Support", "windshift:second-helpdesk:PRI-49", models.ZammadSyncLinked, 2, "open", f.actorID); err != nil {
+ t.Fatal(err)
+ }
+ overview, err := f.service.WorkspaceOverview(f.workspace1, 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if overview.Total != 2 || len(overview.ByStatus) != 2 {
+ t.Fatalf("same status IDs from distinct connections were merged: %#v", overview)
+ }
+ seenConnections := map[string]bool{}
+ for _, bucket := range overview.ByStatus {
+ if bucket.ID != 2 || bucket.Count != 1 {
+ t.Fatalf("unexpected bucket: %#v", bucket)
+ }
+ if bucket.ConnectionName == "" {
+ t.Fatalf("bucket response omitted the readable connection name: %#v", bucket)
+ }
+ seenConnections[bucket.ConnectionID] = true
+ }
+ if !seenConnections[f.connection.ProviderID] || !seenConnections[second.ProviderID] {
+ t.Fatalf("bucket response omitted connection identity: %#v", overview.ByStatus)
+ }
}
func TestZammadSyncRejectsTicketMovedToDisallowedGroupBeforeSnapshotOrCompletion(t *testing.T) {
From 8994f85bd6f00caa285fab8b62a19a8a33e4f6e7 Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 16:00:58 +0200
Subject: [PATCH 08/11] feat: enrich Zammad support overview
---
.../features/items/ZammadLinkResolver.spec.js | 32 +++++---
.../lib/widgets/ZammadSupportWidget.svelte | 52 ++++++++++---
internal/integrations/zammad/client.go | 2 +
internal/integrations/zammad/client_test.go | 8 +-
internal/models/zammad.go | 4 +
internal/repository/zammad_repository.go | 76 +++++++++++++++----
internal/repository/zammad_repository_test.go | 12 +--
internal/services/zammad_service.go | 28 ++++---
internal/services/zammad_service_test.go | 21 +++--
9 files changed, 175 insertions(+), 60 deletions(-)
diff --git a/frontend/src/lib/features/items/ZammadLinkResolver.spec.js b/frontend/src/lib/features/items/ZammadLinkResolver.spec.js
index 8bb548c91..660dc7cb4 100644
--- a/frontend/src/lib/features/items/ZammadLinkResolver.spec.js
+++ b/frontend/src/lib/features/items/ZammadLinkResolver.spec.js
@@ -22,10 +22,13 @@ vi.mock('../../api.js', () => ({
}));
vi.mock('../../router.js', () => ({ navigate: mocks.navigate }));
vi.mock('../../stores/i18n.svelte.js', () => ({
- t: (key, params = {}) =>
- key === 'zammad.overview.statusWithConnection'
- ? `${params.connection} · ${params.status}`
- : key,
+ t: (key, params = {}) => {
+ if (key === 'zammad.overview.statusWithConnection') {
+ return `${params.connection} · ${params.status}`;
+ }
+ if (key === 'zammad.ticketNumber') return `Zammad #${params.number}`;
+ return key;
+ },
}));
vi.mock('../../stores', () => ({ authStore: { currentUser: { timezone: 'UTC' } } }));
@@ -59,7 +62,7 @@ describe('ZammadLinkResolver', () => {
await waitFor(() => expect(mocks.navigate).toHaveBeenCalledOnce());
});
- it('renders the workspace-wide support widget from status metadata only', async () => {
+ it('renders the workspace-wide support widget with actionable ticket context', async () => {
mocks.workspaceOverview.mockResolvedValue({
total: 4,
active: 2,
@@ -91,7 +94,11 @@ describe('ZammadLinkResolver', () => {
id: 'event-1',
item_id: 42,
item_key: 'OPS-42',
+ ticket_title: 'VPN access after password change',
ticket_number: '12345',
+ ticket_url: 'https://zammad.example.test/#ticket/zoom/42',
+ current_group: { id: 7, name: 'IT / Network' },
+ current_owner: { id: 99, name: 'Grace Hopper' },
field: 'status',
old_value: { id: 1, name: 'new' },
new_value: { id: 2, name: 'open' },
@@ -112,14 +119,19 @@ describe('ZammadLinkResolver', () => {
await waitFor(() => expect(mocks.workspaceOverview).toHaveBeenCalledWith(7, { limit: 5 }));
expect(await screen.findByTestId('zammad-support-overview')).toBeInTheDocument();
- expect(screen.getByRole('link', { name: 'OPS-42' })).toHaveAttribute(
- 'href',
- '/workspaces/7/items/42'
- );
+ expect(
+ screen.getByRole('link', { name: /OPS-42 VPN access after password change/ })
+ ).toHaveAttribute('href', '/workspaces/7/items/42');
+ const zammadLink = screen.getByRole('link', { name: /Zammad #12345/ });
+ expect(zammadLink).toHaveAttribute('href', 'https://zammad.example.test/#ticket/zoom/42');
+ expect(zammadLink).toHaveAttribute('target', '_blank');
+ expect(zammadLink).toHaveAttribute('rel', 'noopener noreferrer');
+ expect(screen.getByText('IT / Network')).toBeInTheDocument();
+ expect(screen.getByText('Grace Hopper')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'OPS-43' })).not.toBeInTheDocument();
expect(screen.getByText('Primary helpdesk · open: 1')).toBeInTheDocument();
expect(screen.getByText('Second helpdesk · open: 1')).toBeInTheDocument();
- expect(screen.queryByText(/article|customer|subject/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/article|customer/i)).not.toBeInTheDocument();
});
it('refreshes the workspace-wide overview on demand', async () => {
diff --git a/frontend/src/lib/widgets/ZammadSupportWidget.svelte b/frontend/src/lib/widgets/ZammadSupportWidget.svelte
index fb5703e16..2b17799e9 100644
--- a/frontend/src/lib/widgets/ZammadSupportWidget.svelte
+++ b/frontend/src/lib/widgets/ZammadSupportWidget.svelte
@@ -1,10 +1,11 @@