From 0947fe525089db19aa689ae1e4fec6cb114d0099 Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 02:53:48 +0200
Subject: [PATCH 01/10] 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 40932ff86..86043e19a 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}
+
+
+ {t('zammad.linkExistingTicket')}
+ {t('zammad.createTicket')}
+
+ {/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}
+ openEditDialog(link)} disabled={savingEdit} title={t('zammad.editTicket')}>
+
+
+ refresh(link)} disabled={refreshingId === link.id} title={t('zammad.refreshTicket')}>
+ {#if refreshingId === link.id} {:else} {/if}
+
+ {/if}
+ removeLink(link)} disabled={removingId === link.id} title={t('zammad.removeTicketLink')}>
+ {#if removingId === link.id} {:else} {/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}
+
+ {t('common.cancel')}
+ {#if dialogMode === 'create'}
+
+ {#if creating} {/if}
+ {t('zammad.createTicket')}
+
+ {:else}
+
+ {#if linking} {/if}
+ {t('zammad.linkExistingTicket')}
+
+ {/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}
+
+ {t('common.cancel')}
+
+ {#if savingEdit} {/if}
+ {t('common.save')}
+
+
+
+
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()}
+
+
+ {t('zammad.addConnection')}
+
+ {/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}
+
+
+
testConnection(connection)} disabled={testingId === connection.id || oauthAuthorizationRequired(connection)}>
+ {#if testingId === connection.id} {:else} {/if}
+ {t('zammad.testConnection')}
+
+ {#if connection.auth_method === 'oauth'}
+
startOAuth(connection)} disabled={authorizingId === connection.id}>
+ {#if authorizingId === connection.id} {/if}
+ {connection.oauth_connected || connection.reauthorization_required ? t('zammad.reauthorizeOAuth') : t('zammad.connectOAuth')}
+
+ {/if}
+
openEdit(connection)}>
+
remove(connection)}>
+
+
+ {#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 a8abf3f72..5a54a0996 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
@@ -1037,6 +1034,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.
@@ -1616,6 +1629,7 @@ func (s *Server) initialize() error {
OAuth: integrationOAuthHandler,
ItemLinks: integrationItemLinksHandler,
TodoistSync: todoistSyncHandler,
+ Zammad: zammadHandler,
},
Pages: routes.PageHandlers{
Page: pageHandler,
@@ -1987,6 +2001,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()
@@ -2120,6 +2139,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 86a107266ba4b78fbf77e434153d4ef65fc25746 Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 04:14:41 +0200
Subject: [PATCH 02/10] 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 86043e19a..d0ad0e0b1 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'}
openEditDialog(link)} disabled={savingEdit} title={t('zammad.editTicket')}>
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 5fd068dc7e9ba0337b7f4c19f73167ac31610759 Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 04:45:57 +0200
Subject: [PATCH 03/10] 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, 609 insertions(+), 50 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'}
openEditDialog(link)} disabled={savingEdit} title={t('zammad.editTicket')}>
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 63ada563e..9a8421247 100644
--- a/internal/handlers/workspaces_handler.go
+++ b/internal/handlers/workspaces_handler.go
@@ -536,13 +536,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 92011ca95..7f64ab9d8 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 a472bd1b5..a927bbafa 100644
--- a/internal/restapi/v1/handlers/workspaces.go
+++ b/internal/restapi/v1/handlers/workspaces.go
@@ -393,6 +393,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) {
@@ -420,6 +421,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 5bf9819ea..3528462c5 100644
--- a/internal/services/user_offboard.go
+++ b/internal/services/user_offboard.go
@@ -17,6 +17,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.
@@ -35,6 +39,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
@@ -58,15 +91,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
- } else if !errors.Is(err, sql.ErrNoRows) {
- return nil, fmt.Errorf("failed to load personal workspace: %w", err)
- }
- 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 5e39a4ccacac619e523c0297366ddd2eaec1935c Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 05:00:27 +0200
Subject: [PATCH 04/10] 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 20f618f64ca7311dca1b8e7996743c6c09418bed Mon Sep 17 00:00:00 2001
From: Optic00
Date: Mon, 31 Aug 2026 10:47:05 +0200
Subject: [PATCH 05/10] 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')}
+
{t('common.retry')}
+ {: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 9769cc838..9413358a8 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 603a8e91c5a0dc90d09e7ed91aaafe5b5035bf75 Mon Sep 17 00:00:00 2001
From: Optic00
Date: Tue, 1 Sep 2026 00:40:32 +0200
Subject: [PATCH 07/10] refactor Zammad integration architecture
---
docs/zammad-integration.md | 26 +-
frontend/src/lib/api/integrations.js | 3 +-
frontend/src/lib/api/integrations.spec.js | 2 +-
.../lib/features/items/ZammadItemPanel.svelte | 2 +-
frontend/src/lib/locales/de/zammad.js | 5 +-
frontend/src/lib/locales/en/zammad.js | 5 +-
frontend/src/lib/locales/ru/zammad.js | 1 +
.../settings/ZammadConnectionManager.spec.js | 5 +-
.../settings/ZammadConnectionManager.svelte | 44 ++-
internal/aitools/items.go | 4 +-
internal/database/migrations.go | 90 +++++
internal/database/schema/integrations.sql | 13 +-
.../database/schema/integrations_postgres.sql | 11 +-
internal/database/zammad_schema_test.go | 41 ++-
internal/handlers/integration_item_links.go | 19 +-
internal/handlers/integration_oauth.go | 141 +++++++-
internal/handlers/integration_providers.go | 12 +-
internal/handlers/items.go | 4 +-
internal/handlers/users.go | 4 +-
internal/handlers/workspaces_handler.go | 4 +-
internal/handlers/zammad.go | 90 ++---
internal/handlers/zammad_test.go | 45 ++-
internal/integrations/catalog.go | 90 +++++
internal/integrations/oauth.go | 25 ++
internal/integrations/zammad/client.go | 12 +-
internal/logger/audit.go | 33 +-
internal/models/zammad.go | 15 +-
.../integration_provider_repository.go | 32 +-
.../repository/zammad_oauth_repository.go | 6 +-
internal/repository/zammad_repository.go | 147 +++-----
internal/restapi/v1/handlers/items.go | 6 +-
internal/restapi/v1/handlers/workspaces.go | 6 +-
internal/routes/integrations.go | 5 +-
internal/server/server.go | 2 +-
internal/services/integration_link_guards.go | 72 ++++
internal/services/item_crud_service.go | 34 +-
.../services/item_workspace_move_service.go | 8 +-
internal/services/user_offboard.go | 15 +-
internal/services/workspace_service.go | 30 +-
internal/services/zammad_service.go | 332 +++++++++++-------
internal/services/zammad_service_test.go | 256 ++++++++++----
41 files changed, 1169 insertions(+), 528 deletions(-)
create mode 100644 internal/integrations/catalog.go
create mode 100644 internal/integrations/oauth.go
create mode 100644 internal/services/integration_link_guards.go
diff --git a/docs/zammad-integration.md b/docs/zammad-integration.md
index 047d96fca..6108fdea6 100644
--- a/docs/zammad-integration.md
+++ b/docs/zammad-integration.md
@@ -9,10 +9,12 @@ The routes in this integration use the application API prefix `/api` and the sam
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`.
+Admin connection endpoints are `GET|POST /api/admin/zammad-connections`, `GET|PUT|DELETE /api/admin/zammad-connections/{id}`, and `POST /api/admin/zammad-connections/{id}/test`.
+System-owned OAuth starts through the shared provider endpoint `POST /api/admin/integration-providers/{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 OAuth callback uses the shared system-provider route `GET /api/integrations/oauth/system/zammad/callback`.
The explicit administrator retry is `POST /api/admin/zammad-ticket-links/{linkId}/retry-create`.
+The administrator-only disaster-recovery action is `POST /api/admin/zammad-ticket-links/{linkId}/detach-local`.
`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.
@@ -28,7 +30,7 @@ The create and link responses contain the link ID, ticket identity and URL when
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`
+`https:///api/integrations/oauth/system/zammad/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.
@@ -46,8 +48,14 @@ Use a dedicated active Zammad user with the `ticket.agent` role for the integrat
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.
+Zammad role and group permissions must enforce the same boundary as `allowed_groups` in the Windshift connection.
The default group must be active and included in that allowed set.
+Store every allowed group as an ID and name pair, for example `{"id":7,"name":"Support"}`.
+Windshift persists this small catalog and does not call Zammad's administrator-only `GET /api/v1/groups` endpoint during ticket creation, linking, updating, or synchronization.
+The legacy `allowed_group_ids` request field is accepted only as an upgrade bridge; new clients should send `allowed_groups`.
+Because the least-privilege service account cannot list all groups, connection tests report the persisted group catalog as unverified and validate only the ticket-state endpoint used at runtime.
+During an upgrade, all legacy group IDs remain authorized for existing links, but only a group whose stored name is known can be selected for new ticket creation.
+An administrator must confirm and store the `ID:name` pair before creating tickets in a legacy non-default group.
Create an active text 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.
@@ -78,14 +86,18 @@ Automatic retries are search-only and never issue another create request while t
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.
+Linking an existing ticket verifies that it exists, belongs to a configured allowed 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.
+The selected group must be in the persisted allowlist, the selected state must be active, 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.
+If Zammad is permanently unavailable or its credentials can no longer be recovered, a system administrator can use the explicit `detach-local` action.
+It removes the typed Zammad link and the generic item link without contacting Zammad, records a distinct audit event, and allows the Windshift item or connection to be removed afterward.
+This is a recovery operation, not a normal unlink: the correlation value remains on the remote ticket and must be cleared manually in Zammad before that ticket is linked again.
+
## Refresh, backoff, and recovery
`POST /api/zammad-ticket-links/{linkId}/refresh` performs an immediate remote read and updates the stored snapshot.
@@ -94,7 +106,7 @@ Successful refreshes and persisted synchronization failures publish an item even
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.
+If a ticket is moved outside the connection's persisted allowed groups, 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.
diff --git a/frontend/src/lib/api/integrations.js b/frontend/src/lib/api/integrations.js
index f0b7db5de..d03f9ee68 100644
--- a/frontend/src/lib/api/integrations.js
+++ b/frontend/src/lib/api/integrations.js
@@ -64,7 +64,8 @@ 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' }),
+ startOAuth: (id) =>
+ fetchAPI(`/admin/integration-providers/${id}/oauth/start`, { method: 'POST' }),
forWorkspace: (workspaceId) => fetchAPI(`/workspaces/${workspaceId}/zammad-connections`),
metadata: (workspaceId, id) =>
fetchAPI(`/workspaces/${workspaceId}/zammad-connections/${id}/metadata`),
diff --git a/frontend/src/lib/api/integrations.spec.js b/frontend/src/lib/api/integrations.spec.js
index b4df2c377..4004432bd 100644
--- a/frontend/src/lib/api/integrations.spec.js
+++ b/frontend/src/lib/api/integrations.spec.js
@@ -22,7 +22,7 @@ describe('zammadConnections.startOAuth', () => {
expect(fetchMock).toHaveBeenCalledOnce();
const [url, options] = fetchMock.mock.calls[0];
- expect(url).toBe('/api/admin/zammad-connections/connection-id/oauth/start');
+ expect(url).toBe('/api/admin/integration-providers/connection-id/oauth/start');
expect(options.method).toBe('POST');
expect(options.body).toBeUndefined();
});
diff --git a/frontend/src/lib/features/items/ZammadItemPanel.svelte b/frontend/src/lib/features/items/ZammadItemPanel.svelte
index efeb56ebe..99c846ad2 100644
--- a/frontend/src/lib/features/items/ZammadItemPanel.svelte
+++ b/frontend/src/lib/features/items/ZammadItemPanel.svelte
@@ -141,7 +141,7 @@
}
function usableGroups(connection, loadedMetadata) {
- const allowedIds = connection?.allowed_group_ids || [];
+ const allowedIds = (connection?.allowed_groups || []).map((group) => group.id);
return loadedMetadata.groups.filter((group) => {
if (group.active === false) return false;
if (allowedIds.length > 0) return allowedIds.includes(group.id);
diff --git a/frontend/src/lib/locales/de/zammad.js b/frontend/src/lib/locales/de/zammad.js
index 2c3619600..77ebf02cc 100644
--- a/frontend/src/lib/locales/de/zammad.js
+++ b/frontend/src/lib/locales/de/zammad.js
@@ -42,10 +42,11 @@ export default { zammad: {
oauthConnectionFailed: 'Zammad-OAuth-Autorisierung fehlgeschlagen',
oauthAuthorizationFailed: 'Zammad-OAuth-Autorisierung konnte nicht gestartet werden',
correlationFieldNotVerified: 'Eingeschränkte Servicekonto-Berechtigungen verhindern die automatische Prüfung des Korrelationsfelds. Vor der Ticketerstellung dessen Existenz in Zammad prüfen.',
+ groupCatalogNotVerified: 'Eingeschränkte Servicekonto-Berechtigungen verhindern die automatische Prüfung der konfigurierten Gruppen. Umbenannte oder entfernte Gruppen vor Änderungen der Gruppenrichtlinie in Zammad prüfen.',
defaultCustomer: 'Standardkunde für Tickets',
defaultGroup: 'Standardgruppe',
allowedGroups: 'Erlaubte Ticketgruppen',
- allowedGroupIds: 'IDs erlaubter Zammad-Gruppen',
+ allowedGroupIds: 'Erlaubte Gruppen (ID:Name)',
correlationField: 'Korrelationsfeld',
correlationFieldHint: 'Dieses Textfeld in Zammad-Tickets anlegen, damit Wiederholungen ein bereits erstelltes Ticket finden.',
completionStatus: 'Abschlussstatus in Windshift',
@@ -68,7 +69,7 @@ export default { zammad: {
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',
+ loadMetadataFailed: 'Konfigurierte 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',
diff --git a/frontend/src/lib/locales/en/zammad.js b/frontend/src/lib/locales/en/zammad.js
index 705eeae5e..d617612eb 100644
--- a/frontend/src/lib/locales/en/zammad.js
+++ b/frontend/src/lib/locales/en/zammad.js
@@ -42,10 +42,11 @@ export default { zammad: {
oauthConnectionFailed: 'Zammad OAuth authorization failed',
oauthAuthorizationFailed: 'Could not start Zammad OAuth authorization',
correlationFieldNotVerified: 'Restricted service-account permissions prevent automatic verification of the correlation field. Verify that it exists in Zammad before creating tickets.',
+ groupCatalogNotVerified: 'Restricted service-account permissions prevent automatic verification of configured groups. Confirm renamed or removed groups in Zammad before changing the group policy.',
defaultCustomer: 'Default ticket customer',
defaultGroup: 'Default group',
allowedGroups: 'Allowed ticket groups',
- allowedGroupIds: 'Allowed Zammad group IDs',
+ allowedGroupIds: 'Allowed groups (ID:name)',
correlationField: 'Correlation field',
correlationFieldHint: 'Create this text field on Zammad tickets so retries can find an already-created ticket.',
completionStatus: 'Windshift completion status',
@@ -68,7 +69,7 @@ export default { zammad: {
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',
+ loadMetadataFailed: 'Failed to load configured Zammad groups and states',
loadOwnersFailed: 'Failed to load Zammad owners',
ticketCreated: 'Zammad ticket created',
ticketCreationStarted: 'Zammad ticket creation is already in progress',
diff --git a/frontend/src/lib/locales/ru/zammad.js b/frontend/src/lib/locales/ru/zammad.js
index 4de734d5f..b527ba166 100644
--- a/frontend/src/lib/locales/ru/zammad.js
+++ b/frontend/src/lib/locales/ru/zammad.js
@@ -42,6 +42,7 @@ export default { zammad: {
oauthConnectionFailed: 'Не удалось авторизоваться через OAuth Zammad',
oauthAuthorizationFailed: 'Не удалось запустить авторизацию OAuth Zammad',
correlationFieldNotVerified: 'Ограниченные права сервисной учётной записи не позволяют автоматически проверить поле корреляции. Перед созданием заявок убедитесь, что оно существует в Zammad.',
+ groupCatalogNotVerified: 'Ограниченные права сервисной учётной записи не позволяют автоматически проверить настроенные группы. Перед изменением политики групп проверьте в Zammad переименованные или удалённые группы.',
defaultCustomer: 'Клиент заявки по умолчанию',
defaultGroup: 'Группа по умолчанию',
allowedGroups: 'Разрешённые группы заявок',
diff --git a/frontend/src/lib/settings/ZammadConnectionManager.spec.js b/frontend/src/lib/settings/ZammadConnectionManager.spec.js
index 8a0dad239..c468d29b7 100644
--- a/frontend/src/lib/settings/ZammadConnectionManager.spec.js
+++ b/frontend/src/lib/settings/ZammadConnectionManager.spec.js
@@ -45,7 +45,10 @@ const connection = {
oauth_connected: true,
default_group_id: 0,
default_group_name: 'Windshift',
- allowed_group_ids: [2, 3],
+ allowed_groups: [
+ { id: 2, name: 'Support' },
+ { id: 3, name: 'Escalations' },
+ ],
default_customer: 'windshift@example.test',
correlation_field: 'windshift_item_key',
closed_state_ids: [4],
diff --git a/frontend/src/lib/settings/ZammadConnectionManager.svelte b/frontend/src/lib/settings/ZammadConnectionManager.svelte
index 7e4a1b372..06fec558d 100644
--- a/frontend/src/lib/settings/ZammadConnectionManager.svelte
+++ b/frontend/src/lib/settings/ZammadConnectionManager.svelte
@@ -44,7 +44,7 @@
has_oauth_client_secret: false,
default_group_id: '',
default_group_name: '',
- allowed_group_ids: [],
+ allowed_groups: [],
default_customer: '',
correlation_field: 'windshift_item_key',
closed_state_ids: [],
@@ -111,7 +111,7 @@
has_oauth_client_secret: connection.has_oauth_client_secret === true,
default_group_id: connection.default_group_id || '',
default_group_name: connection.default_group_name || '',
- allowed_group_ids: connection.allowed_group_ids || [],
+ allowed_groups: connection.allowed_groups || [],
default_customer: connection.default_customer || '',
correlation_field: connection.correlation_field || 'windshift_item_key',
closed_state_ids: connection.closed_state_ids || [],
@@ -131,6 +131,9 @@
completion_status_id: Number(form.completion_status_id) || undefined,
clear_completion_status: false,
};
+ if (data.allowed_groups.length === 0 && data.default_group_id && data.default_group_name.trim()) {
+ data.allowed_groups = [{ id: data.default_group_id, name: data.default_group_name.trim() }];
+ }
delete data.has_oauth_client_secret;
if (data.auth_method === 'oauth') {
delete data.api_token;
@@ -167,6 +170,9 @@
const result = await api.zammadConnections.test(connection.id);
metadataByConnection = { ...metadataByConnection, [connection.id]: result.metadata };
successToast(t('zammad.connectionTestSucceeded'));
+ if (result.metadata?.group_catalog_verified === false) {
+ warningToast(t('zammad.groupCatalogNotVerified'));
+ }
if (result.metadata?.correlation_field_verified === false) {
warningToast(t('zammad.correlationFieldNotVerified'));
}
@@ -257,9 +263,10 @@
}
function toggleAllowedGroup(id, checked) {
- form.allowed_group_ids = checked
- ? [...new Set([...form.allowed_group_ids, id])]
- : form.allowed_group_ids.filter((groupId) => groupId !== id);
+ const group = metadataByConnection[editing?.id]?.groups?.find((entry) => entry.id === id);
+ form.allowed_groups = checked
+ ? [...form.allowed_groups.filter((entry) => entry.id !== id), { id, name: group?.name || '' }]
+ : form.allowed_groups.filter((entry) => entry.id !== id);
}
function selectDefaultGroup(value) {
@@ -269,6 +276,17 @@
);
form.default_group_name = group?.name || '';
}
+
+ function serializeAllowedGroups(groups) {
+ return groups.map((group) => `${group.id}:${group.name}`).join(', ');
+ }
+
+ function parseAllowedGroups(value) {
+ return value.split(',').map((entry) => {
+ const [rawId, ...nameParts] = entry.split(':');
+ return { id: Number(rawId.trim()), name: nameParts.join(':').trim() };
+ }).filter((group) => group.id > 0 && group.name);
+ }
@@ -337,6 +355,9 @@
{#if metadataByConnection[connection.id].correlation_field_verified === false}
{t('zammad.correlationFieldNotVerified')}
{/if}
+ {#if metadataByConnection[connection.id].group_catalog_verified === false}
+
{t('zammad.groupCatalogNotVerified')}
+ {/if}
{/if}
{/each}
@@ -416,7 +437,7 @@
{#each metadataByConnection[editing.id].groups as group}
entry.id === group.id)}
onchange={(checked) => toggleAllowedGroup(group.id, checked)}
label={group.name}
size="small"
@@ -426,13 +447,16 @@
{:else}
-
+
+
+
+
(form.allowed_group_ids = event.currentTarget.value.split(',').map(Number).filter(Boolean))}
- placeholder="7,8"
+ value={serializeAllowedGroups(form.allowed_groups)}
+ oninput={(event) => (form.allowed_groups = parseAllowedGroups(event.currentTarget.value))}
+ placeholder="7:Support, 8:Escalations"
/>
{/if}
diff --git a/internal/aitools/items.go b/internal/aitools/items.go
index 238101522..e5754dd01 100644
--- a/internal/aitools/items.go
+++ b/internal/aitools/items.go
@@ -461,8 +461,8 @@ 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
+ if errors.Is(err, services.ErrItemHasProtectedIntegrationLinks) {
+ return map[string]string{"error": "remove all protected integration links from the item or its descendants before deleting them"}, nil
}
return nil, err
}
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index eb98f3211..d58a5b170 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -832,6 +832,96 @@ var Catalog = []Migration{
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE RESTRICT;
`,
},
+ {
+ Version: "20260831_zammad_persisted_group_catalog",
+ Name: "Persist Zammad group names for least-privilege runtime access",
+ CheckSQLite: sqliteColumnCheck("zammad_connections", "allowed_groups"),
+ CheckPostgres: pgColumnCheck("zammad_connections", "allowed_groups"),
+ SQLite: `
+ ALTER TABLE zammad_connections ADD COLUMN allowed_groups TEXT NOT NULL DEFAULT '[]';
+ UPDATE zammad_connections
+ SET allowed_groups = COALESCE((
+ SELECT json_group_array(json_object(
+ 'id', CAST(group_id.value AS INTEGER),
+ 'name', CASE
+ WHEN CAST(group_id.value AS INTEGER) = default_group_id THEN default_group_name
+ ELSE ''
+ END
+ ))
+ FROM json_each(zammad_connections.allowed_group_ids) AS group_id
+ WHERE group_id.type = 'integer' AND CAST(group_id.value AS INTEGER) > 0
+ ), '[]');
+ `,
+ Postgres: `
+ ALTER TABLE zammad_connections ADD COLUMN IF NOT EXISTS allowed_groups TEXT NOT NULL DEFAULT '[]';
+ UPDATE zammad_connections
+ SET allowed_groups = COALESCE((
+ SELECT jsonb_agg(jsonb_build_object(
+ 'id', group_id.value::INTEGER,
+ 'name', CASE
+ WHEN group_id.value::INTEGER = default_group_id THEN default_group_name
+ ELSE ''
+ END
+ ))::TEXT
+ FROM jsonb_array_elements_text(zammad_connections.allowed_group_ids::jsonb) AS group_id(value)
+ WHERE group_id.value ~ '^[1-9][0-9]*$'
+ ), '[]');
+ `,
+ },
+ {
+ Version: "20260901_zammad_canonical_workspace_scope",
+ Name: "Backfill managed-credential scope for existing Zammad connections",
+ CheckSQLite: `SELECT CASE WHEN
+ NOT EXISTS (SELECT 1 FROM pragma_table_info('zammad_connections') WHERE name='applies_to_all_workspaces')
+ OR NOT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name='zammad_connection_workspaces')
+ THEN 1 ELSE 0 END`,
+ CheckPostgres: `SELECT CASE WHEN
+ NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='zammad_connections' AND column_name='applies_to_all_workspaces')
+ OR NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=current_schema() AND table_name='zammad_connection_workspaces')
+ THEN 1 ELSE 0 END`,
+ SQLite: `
+ UPDATE action_credentials
+ SET applies_to_all_workspaces = (
+ SELECT zc.applies_to_all_workspaces FROM zammad_connections zc
+ WHERE zc.credential_id = action_credentials.id
+ )
+ WHERE id IN (SELECT credential_id FROM zammad_connections)
+ AND json_extract(secret_metadata, '$._windshift_managed_credential') = 'v1'
+ AND json_extract(secret_metadata, '$.managed_by') = 'zammad'
+ AND json_extract(secret_metadata, '$.owner_id') = (
+ SELECT zc.provider_id FROM zammad_connections zc
+ WHERE zc.credential_id = action_credentials.id
+ );
+ INSERT OR IGNORE INTO action_credential_workspaces (credential_id, workspace_id)
+ SELECT zc.credential_id, zcw.workspace_id
+ FROM zammad_connections zc
+ JOIN zammad_connection_workspaces zcw ON zcw.provider_id = zc.provider_id
+ JOIN action_credentials ac ON ac.id = zc.credential_id
+ WHERE zc.applies_to_all_workspaces = false
+ AND json_extract(ac.secret_metadata, '$._windshift_managed_credential') = 'v1'
+ AND json_extract(ac.secret_metadata, '$.managed_by') = 'zammad'
+ AND json_extract(ac.secret_metadata, '$.owner_id') = zc.provider_id;
+ `,
+ Postgres: `
+ UPDATE action_credentials ac
+ SET applies_to_all_workspaces = zc.applies_to_all_workspaces
+ FROM zammad_connections zc
+ WHERE zc.credential_id = ac.id
+ AND ac.secret_metadata::jsonb ->> '_windshift_managed_credential' = 'v1'
+ AND ac.secret_metadata::jsonb ->> 'managed_by' = 'zammad'
+ AND ac.secret_metadata::jsonb ->> 'owner_id' = zc.provider_id;
+ INSERT INTO action_credential_workspaces (credential_id, workspace_id)
+ SELECT zc.credential_id, zcw.workspace_id
+ FROM zammad_connections zc
+ JOIN zammad_connection_workspaces zcw ON zcw.provider_id = zc.provider_id
+ JOIN action_credentials ac ON ac.id = zc.credential_id
+ WHERE zc.applies_to_all_workspaces = false
+ AND ac.secret_metadata::jsonb ->> '_windshift_managed_credential' = 'v1'
+ AND ac.secret_metadata::jsonb ->> 'managed_by' = 'zammad'
+ AND ac.secret_metadata::jsonb ->> 'owner_id' = zc.provider_id
+ ON CONFLICT (credential_id, workspace_id) DO NOTHING;
+ `,
+ },
}
func applySQLiteSSOAttributeMappingDefault(db Database) (retErr error) {
diff --git a/internal/database/schema/integrations.sql b/internal/database/schema/integrations.sql
index 0d8edc218..edd96ac67 100644
--- a/internal/database/schema/integrations.sql
+++ b/internal/database/schema/integrations.sql
@@ -148,12 +148,11 @@ CREATE TABLE IF NOT EXISTS zammad_connections (
base_url TEXT NOT NULL,
default_group_id INTEGER,
default_group_name TEXT DEFAULT '',
- allowed_group_ids TEXT NOT NULL DEFAULT '[]',
+ allowed_groups 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,
@@ -194,16 +193,6 @@ CREATE TABLE IF NOT EXISTS zammad_oauth_state (
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,
diff --git a/internal/database/schema/integrations_postgres.sql b/internal/database/schema/integrations_postgres.sql
index b6fa1bed0..3132f3b5c 100644
--- a/internal/database/schema/integrations_postgres.sql
+++ b/internal/database/schema/integrations_postgres.sql
@@ -130,12 +130,11 @@ CREATE TABLE IF NOT EXISTS zammad_connections (
base_url TEXT NOT NULL,
default_group_id INTEGER,
default_group_name TEXT DEFAULT '',
- allowed_group_ids TEXT NOT NULL DEFAULT '[]',
+ allowed_groups 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,
@@ -167,14 +166,6 @@ CREATE TABLE IF NOT EXISTS zammad_oauth_state (
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 RESTRICT,
diff --git a/internal/database/zammad_schema_test.go b/internal/database/zammad_schema_test.go
index 7e3386775..552a8cad0 100644
--- a/internal/database/zammad_schema_test.go
+++ b/internal/database/zammad_schema_test.go
@@ -1,6 +1,7 @@
package database
import (
+ "encoding/json"
"path/filepath"
"strings"
"testing"
@@ -16,7 +17,7 @@ func TestZammadSchemaInitializesOnSQLite(t *testing.T) {
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"} {
+ for _, table := range []string{"zammad_connections", "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)
@@ -26,7 +27,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", "20260831_zammad_ticket_link_item_restrict"} {
+ 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", "20260831_zammad_persisted_group_catalog", "20260901_zammad_canonical_workspace_scope"} {
var migrationCount int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version=?", version).Scan(&migrationCount); err != nil {
t.Fatal(err)
@@ -247,9 +248,10 @@ func TestZammadOAuthSQLiteUpgradePreservesLegacyConnection(t *testing.T) {
}
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 workspaces(id, name, key) VALUES (9100, 'Legacy scope', 'LEGACY')`,
+ `INSERT INTO action_credentials(id, name, credential_type, applies_to_all_workspaces, encrypted_secret, secret_metadata, is_enabled) VALUES (9001, 'legacy API', 'custom_header', true, 'opaque-ciphertext', '{"_windshift_managed_credential":"v1","managed_by":"zammad","owner_id":"legacy-zammad"}', 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)`,
+ `INSERT INTO zammad_connections(provider_id, credential_id, auth_method, base_url, default_group_id, default_group_name, allowed_groups, default_customer, closed_state_ids, created_by) VALUES ('legacy-zammad', 9001, 'api_token', 'https://legacy.example.test', 7, 'Support', '[]', 'robot@example.test', '[]', 9001)`,
} {
if _, err := db.ExecWrite(statement); err != nil {
t.Fatal(err)
@@ -267,14 +269,22 @@ func TestZammadOAuthSQLiteUpgradePreservesLegacyConnection(t *testing.T) {
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;
+ SELECT provider_id, credential_id, base_url, default_group_id, default_group_name, allowed_groups, default_customer, correlation_field, closed_state_ids, completion_status_id, false, 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;
+ CREATE TABLE zammad_connection_workspaces (
+ provider_id TEXT NOT NULL, workspace_id INTEGER NOT NULL,
+ PRIMARY KEY (provider_id, workspace_id)
+ );
+ INSERT INTO zammad_connection_workspaces(provider_id, workspace_id) VALUES ('legacy-zammad', 9100);
+ UPDATE zammad_connections SET allowed_group_ids = '[7,"bad",8,0]';
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';
DELETE FROM schema_migrations WHERE version = '20260831_zammad_connection_config_revision';
+ DELETE FROM schema_migrations WHERE version = '20260831_zammad_persisted_group_catalog';
+ DELETE FROM schema_migrations WHERE version = '20260901_zammad_canonical_workspace_scope';
`)
if _, onErr := db.Exec("PRAGMA foreign_keys=ON"); onErr != nil {
t.Fatal(onErr)
@@ -291,10 +301,29 @@ func TestZammadOAuthSQLiteUpgradePreservesLegacyConnection(t *testing.T) {
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)
}
+ var migratedGroupsJSON string
+ if err := db.QueryRow(`SELECT allowed_groups FROM zammad_connections WHERE provider_id='legacy-zammad'`).Scan(&migratedGroupsJSON); err != nil {
+ t.Fatal(err)
+ }
+ var migratedGroups []struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal([]byte(migratedGroupsJSON), &migratedGroups); err != nil || len(migratedGroups) != 2 || migratedGroups[0].ID != 7 || migratedGroups[0].Name != "Support" || migratedGroups[1].ID != 8 || migratedGroups[1].Name != "" {
+ t.Fatalf("legacy group IDs were not migrated safely: groups=%#v raw=%q err=%v", migratedGroups, migratedGroupsJSON, err)
+ }
+ var appliesToAll bool
+ if err := db.QueryRow(`SELECT applies_to_all_workspaces FROM action_credentials WHERE id=9001`).Scan(&appliesToAll); err != nil || appliesToAll {
+ t.Fatalf("legacy restricted scope was not copied to the managed credential: applies_all=%v err=%v", appliesToAll, err)
+ }
+ var scopeRows int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM action_credential_workspaces WHERE credential_id=9001 AND workspace_id=9100`).Scan(&scopeRows); err != nil || scopeRows != 1 {
+ t.Fatalf("legacy workspace allowlist was not copied to the managed credential: count=%d err=%v", scopeRows, 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)`,
+ `INSERT INTO zammad_connections(provider_id, credential_id, auth_method, base_url, allowed_groups, 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 5b6fa7f38..cc2a5e629 100644
--- a/internal/handlers/integration_item_links.go
+++ b/internal/handlers/integration_item_links.go
@@ -10,6 +10,7 @@ import (
"time"
"windshift/internal/database"
+ "windshift/internal/integrations"
"windshift/internal/integrations/notion"
"windshift/internal/logger"
"windshift/internal/models"
@@ -86,7 +87,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 = ? AND ip.provider_type != 'zammad'
+ WHERE iil.item_id = ?
ORDER BY iil.created_at DESC
`, itemID)
if err != nil {
@@ -117,7 +118,9 @@ func (h *IntegrationItemLinksHandler) GetItemLinks(w http.ResponseWriter, r *htt
if metadata.Valid {
link.LinkMetadata = metadata.String
}
- links = append(links, link)
+ if integrations.SupportsGenericItemLinks(models.IntegrationProviderType(link.ProviderType)) {
+ links = append(links, link)
+ }
}
if err := rows.Err(); err != nil {
respondInternalError(w, r, err)
@@ -170,10 +173,10 @@ func (h *IntegrationItemLinksHandler) CreateItemLink(w http.ResponseWriter, r *h
return
}
- // 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 AND provider_type IN ('notion', 'todoist'))", req.ProviderID).Scan(&providerExists)
- if err != nil || !providerExists {
+ // Verify provider exists, is enabled, and explicitly allows generic links.
+ var providerType models.IntegrationProviderType
+ err := h.db.QueryRow("SELECT provider_type FROM integration_providers WHERE id = ? AND enabled = true", req.ProviderID).Scan(&providerType)
+ if err != nil || !integrations.SupportsGenericItemLinks(providerType) {
respondBadRequest(w, r, "Integration provider not found or disabled")
return
}
@@ -234,7 +237,7 @@ 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) {
+ if !integrations.SupportsGenericItemLinks(models.IntegrationProviderType(link.ProviderType)) {
respondNotFound(w, r, "integration_link")
return
}
@@ -279,7 +282,7 @@ func (h *IntegrationItemLinksHandler) RefreshItemLink(w http.ResponseWriter, r *
}
return
}
- if providerType == models.IntegrationProviderZammad {
+ if !integrations.SupportsGenericItemRefresh(providerType) {
respondNotFound(w, r, "integration_link")
return
}
diff --git a/internal/handlers/integration_oauth.go b/internal/handlers/integration_oauth.go
index 0f249f34f..a8c05c3cb 100644
--- a/internal/handlers/integration_oauth.go
+++ b/internal/handlers/integration_oauth.go
@@ -10,12 +10,16 @@ import (
"log/slog"
"net/http"
"net/url"
+ "strings"
"time"
"windshift/internal/database"
+ "windshift/internal/integrations"
"windshift/internal/integrations/notion"
"windshift/internal/integrations/todoist"
+ "windshift/internal/logger"
"windshift/internal/models"
+ "windshift/internal/repository"
"windshift/internal/sso"
"uuid"
@@ -23,19 +27,74 @@ import (
// IntegrationOAuthHandler handles OAuth flows and user connection management
type IntegrationOAuthHandler struct {
- db database.Database
- encryption *sso.SecretEncryption
- baseURL string
+ db database.Database
+ encryption *sso.SecretEncryption
+ baseURL string
+ systemFlows map[models.IntegrationProviderType]systemOAuthRegistration
+}
+
+type systemOAuthRegistration struct {
+ flow integrations.SystemOAuthFlow
+ auditor *logger.Auditor
}
// NewIntegrationOAuthHandler creates a new integration OAuth handler.
// baseURL: public URL of the application (from config.Load).
func NewIntegrationOAuthHandler(db database.Database, encryption *sso.SecretEncryption, baseURL string) *IntegrationOAuthHandler {
return &IntegrationOAuthHandler{
- db: db,
- encryption: encryption,
- baseURL: baseURL,
+ db: db,
+ encryption: encryption,
+ baseURL: baseURL,
+ systemFlows: make(map[models.IntegrationProviderType]systemOAuthRegistration),
+ }
+}
+
+// RegisterSystemOAuthFlow adds a system-owned provider extension to the same
+// OAuth handler used by user-owned integrations.
+func (h *IntegrationOAuthHandler) RegisterSystemOAuthFlow(providerType models.IntegrationProviderType, flow integrations.SystemOAuthFlow, auditor *logger.Auditor) {
+ capabilities, ok := integrations.Capabilities(providerType)
+ if !ok || !capabilities.OAuth || capabilities.CredentialOwner != integrations.CredentialOwnerSystem || flow == nil {
+ return
+ }
+ h.systemFlows[providerType] = systemOAuthRegistration{flow: flow, auditor: auditor}
+}
+
+// StartSystemOAuth initiates OAuth for an administrator-managed provider row.
+func (h *IntegrationOAuthHandler) StartSystemOAuth(w http.ResponseWriter, r *http.Request) {
+ user, ok := RequireAuth(w, r)
+ if !ok {
+ return
+ }
+ providerID := r.PathValue("id")
+ var providerType models.IntegrationProviderType
+ if err := h.db.QueryRow("SELECT provider_type FROM integration_providers WHERE id = ? AND enabled = true", providerID).Scan(&providerType); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ respondNotFound(w, r, "integration_provider")
+ } else {
+ respondInternalError(w, r, err)
+ }
+ return
+ }
+ registration, registered := h.systemFlows[providerType]
+ if !registered || registration.flow == nil {
+ respondBadRequest(w, r, "OAuth is not supported for this provider")
+ return
+ }
+ authURL, err := registration.flow.StartOAuth(r.Context(), providerID, user.ID, h.baseURL)
+ if err != nil {
+ if errors.Is(err, repository.ErrNotFound) {
+ respondNotFound(w, r, "integration_provider")
+ } else {
+ respondBadRequest(w, r, "OAuth is not configured for this provider")
+ }
+ return
+ }
+ if registration.auditor != nil {
+ registration.auditor.LogWithDetails(r, user, logger.ActionIntegrationProviderUpdate, logger.ResourceIntegrationProvider, nil, providerID, map[string]any{
+ "provider_id": providerID, "provider_type": providerType, "oauth_started": true,
+ })
}
+ respondJSONOK(w, map[string]string{"auth_url": authURL})
}
// StartOAuth initiates the OAuth flow for an integration provider
@@ -55,7 +114,6 @@ 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) {
@@ -65,6 +123,10 @@ func (h *IntegrationOAuthHandler) StartOAuth(w http.ResponseWriter, r *http.Requ
}
return
}
+ if !integrations.SupportsUserOAuth(providerType) {
+ respondNotFound(w, r, "integration_provider")
+ return
+ }
if !clientID.Valid || clientID.String == "" {
respondBadRequest(w, r, "OAuth not configured for this provider")
@@ -159,6 +221,10 @@ func (h *IntegrationOAuthHandler) OAuthCallback(w http.ResponseWriter, r *http.R
h.redirectWithError(w, r, "Provider not found")
return
}
+ if !integrations.SupportsUserOAuth(providerType) {
+ h.redirectWithError(w, r, "Unsupported provider type")
+ return
+ }
if !clientSecretEnc.Valid || clientSecretEnc.String == "" {
h.redirectWithError(w, r, "OAuth secret not configured")
@@ -191,6 +257,61 @@ func (h *IntegrationOAuthHandler) OAuthCallback(w http.ResponseWriter, r *http.R
}
}
+// SystemOAuthCallback completes an administrator-managed provider flow on a
+// path that cannot collide with user-configurable provider slugs.
+func (h *IntegrationOAuthHandler) SystemOAuthCallback(w http.ResponseWriter, r *http.Request) {
+ providerType := models.IntegrationProviderType(r.PathValue("providerType"))
+ registration, ok := h.systemFlows[providerType]
+ if !ok || registration.flow == nil {
+ respondNotFound(w, r, "integration_provider")
+ return
+ }
+ h.handleSystemOAuthCallback(w, r, providerType, registration)
+}
+
+func (h *IntegrationOAuthHandler) handleSystemOAuthCallback(w http.ResponseWriter, r *http.Request, providerType models.IntegrationProviderType, registration systemOAuthRegistration) {
+ state, code := r.URL.Query().Get("state"), r.URL.Query().Get("code")
+ var result *integrations.SystemOAuthCallbackResult
+ var err error
+ if state == "" || code == "" || r.URL.Query().Get("error") != "" {
+ if state != "" {
+ result, _ = registration.flow.ConsumeFailedOAuthCallback(state)
+ }
+ err = errors.New("OAuth callback was rejected")
+ } else {
+ result, err = registration.flow.CompleteOAuth(r.Context(), state, code, h.baseURL)
+ }
+ h.auditSystemOAuthCallback(r, providerType, registration.auditor, result, err == nil)
+ h.redirectSystemOAuthResult(w, r, providerType, err == nil)
+}
+
+func (h *IntegrationOAuthHandler) auditSystemOAuthCallback(r *http.Request, providerType models.IntegrationProviderType, auditor *logger.Auditor, result *integrations.SystemOAuthCallbackResult, success bool) {
+ if auditor == nil || result == nil || result.Initiator == nil {
+ return
+ }
+ details := map[string]any{
+ "provider_id": result.ProviderID, "provider_type": providerType,
+ "oauth_callback": true, "credential_mutation": true, "generation": result.Generation,
+ }
+ if success {
+ auditor.LogWithDetails(r, result.Initiator, logger.ActionIntegrationProviderOAuthCredentialSet, logger.ResourceIntegrationProvider, nil, result.ProviderName, details)
+ return
+ }
+ auditor.LogFailure(r, result.Initiator, logger.ActionIntegrationProviderOAuthCredentialSet, logger.ResourceIntegrationProvider, nil, result.ProviderName, "oauth_callback_failed", details)
+}
+
+func (h *IntegrationOAuthHandler) redirectSystemOAuthResult(w http.ResponseWriter, r *http.Request, providerType models.IntegrationProviderType, success bool) {
+ result := "error"
+ if success {
+ result = "success"
+ }
+ contextPath := ""
+ if publicURL, err := url.Parse(h.baseURL); err == nil {
+ contextPath = strings.TrimRight(publicURL.EscapedPath(), "/")
+ }
+ http.Redirect(w, r, contextPath+"/admin/integration-providers?tab="+url.QueryEscape(string(providerType))+"&oauth="+url.QueryEscape(result), http.StatusFound)
+}
+
func (h *IntegrationOAuthHandler) handleTodoistCallback(w http.ResponseWriter, r *http.Request, clientID, clientSecret, code, providerID, userID, providerSlug string) {
result, err := todoist.ExchangeOAuthCode(clientID, clientSecret, code)
if err != nil {
@@ -365,7 +486,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 AND provider_type IN ('notion', 'todoist')
+ WHERE enabled = true
ORDER BY name
`)
if err != nil {
@@ -387,7 +508,9 @@ func (h *IntegrationOAuthHandler) GetAvailableProviders(w http.ResponseWriter, r
if err := rows.Scan(&p.ID, &p.Slug, &p.Name, &p.ProviderType); err != nil {
continue
}
- providers = append(providers, p)
+ if integrations.SupportsUserOAuth(models.IntegrationProviderType(p.ProviderType)) {
+ providers = append(providers, p)
+ }
}
if err := rows.Err(); err != nil {
respondInternalError(w, r, err)
diff --git a/internal/handlers/integration_providers.go b/internal/handlers/integration_providers.go
index 56295a48c..f2a3678fe 100644
--- a/internal/handlers/integration_providers.go
+++ b/internal/handlers/integration_providers.go
@@ -5,6 +5,7 @@ import (
"net/http"
"time"
+ "windshift/internal/integrations"
"windshift/internal/logger"
"windshift/internal/models"
"windshift/internal/repository"
@@ -101,13 +102,10 @@ func (h *IntegrationProviderHandler) CreateProvider(w http.ResponseWriter, r *ht
return
}
- // Validate provider type
- validTypes := map[string]bool{
- string(models.IntegrationProviderNotion): true,
- string(models.IntegrationProviderTodoist): true,
- }
- if !validTypes[req.ProviderType] {
- respondBadRequest(w, r, "Invalid provider type. Supported: notion, todoist")
+ // Provider capabilities are declared centrally so adding a provider does
+ // not require another hardcoded allowlist in this handler.
+ if !integrations.SupportsAdminProviderCRUD(models.IntegrationProviderType(req.ProviderType)) {
+ respondBadRequest(w, r, "Provider type is not managed by this endpoint")
return
}
diff --git a/internal/handlers/items.go b/internal/handlers/items.go
index e5ff8e3c7..4c77e3098 100644
--- a/internal/handlers/items.go
+++ b/internal/handlers/items.go
@@ -1140,11 +1140,11 @@ func (h *ItemHandler) respondItemDeletionError(w http.ResponseWriter, r *http.Re
respondNotFound(w, r, "item")
return true
}
- if errors.Is(err, services.ErrItemHasZammadTicketLinks) {
+ if errors.Is(err, services.ErrItemHasProtectedIntegrationLinks) {
respondError(w, r, restapi.NewAPIError(
http.StatusConflict,
restapi.ErrCodeConflict,
- "Unlink all Zammad tickets from the affected items before deleting them.",
+ "Remove all protected integration links from the affected items before deleting them.",
))
return true
}
diff --git a/internal/handlers/users.go b/internal/handlers/users.go
index 63a331995..df2700eda 100644
--- a/internal/handlers/users.go
+++ b/internal/handlers/users.go
@@ -667,8 +667,8 @@ func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) {
}
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")
+ if errors.Is(err, services.ErrUserOffboardingHasProtectedIntegrationLinks) {
+ respondConflict(w, r, "Cannot offboard user while their personal workspace contains protected integration links")
return
}
respondInternalError(w, r, err)
diff --git a/internal/handlers/workspaces_handler.go b/internal/handlers/workspaces_handler.go
index 9a8421247..d334a627f 100644
--- a/internal/handlers/workspaces_handler.go
+++ b/internal/handlers/workspaces_handler.go
@@ -538,8 +538,8 @@ func (h *WorkspaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
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.")
+ if errors.Is(err, services.ErrWorkspaceHasProtectedIntegrationLinks) {
+ respondConflict(w, r, "Remove all protected integration links from this workspace before deleting it.")
return
}
respondInternalError(w, r, err)
diff --git a/internal/handlers/zammad.go b/internal/handlers/zammad.go
index cc52abc6c..c69545b58 100644
--- a/internal/handlers/zammad.go
+++ b/internal/handlers/zammad.go
@@ -3,7 +3,6 @@ package handlers
import (
"errors"
"net/http"
- "net/url"
"slices"
"strconv"
"strings"
@@ -22,7 +21,6 @@ type ZammadHandler struct {
service *services.ZammadService
permissionService *services.PermissionService
auditor *logger.Auditor
- publicBaseURL string
syncAllTrigger func() bool
}
@@ -30,69 +28,8 @@ func NewZammadHandler(itemRepo *repository.ItemRepository, service *services.Zam
return &ZammadHandler{itemRepo: itemRepo, service: service, permissionService: permissionService, auditor: auditor}
}
-func (h *ZammadHandler) SetOAuthBaseURL(baseURL string) { h.publicBaseURL = baseURL }
-
func (h *ZammadHandler) SetSyncAllTrigger(trigger func() bool) { h.syncAllTrigger = trigger }
-// 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 {
@@ -189,6 +126,25 @@ func (h *ZammadHandler) RetryUncertainTicketCreation(w http.ResponseWriter, r *h
respondJSONOK(w, link.Response())
}
+// DetachTicketLinkLocally is an administrator-only recovery action. The route
+// middleware enforces system-admin access; the handler records the deliberately
+// orphan-prone operation so it remains distinguishable from a normal unlink.
+func (h *ZammadHandler) DetachTicketLinkLocally(w http.ResponseWriter, r *http.Request) {
+ user, ok := RequireAuth(w, r)
+ if !ok {
+ return
+ }
+ link, err := h.service.DetachTicketLinkLocally(r.PathValue("linkId"))
+ if !h.respondServiceError(w, r, err) {
+ return
+ }
+ h.audit(r, user, logger.ActionZammadTicketDetachLocal, logger.ResourceZammadTicket, link.ID, link.TicketNumber, map[string]any{
+ "item_id": link.ItemID, "connection_id": link.ProviderID, "ticket_id": link.TicketID,
+ "remote_correlation_may_remain": true,
+ })
+ w.WriteHeader(http.StatusNoContent)
+}
+
func (h *ZammadHandler) ListWorkspaceConnections(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := requireIDParam(w, r, "workspaceId")
if !ok {
@@ -214,7 +170,7 @@ func (h *ZammadHandler) ListWorkspaceConnections(w http.ResponseWriter, r *http.
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,
+ AllowedGroups: connection.AllowedGroups,
})
}
respondJSONOK(w, responses)
@@ -226,9 +182,11 @@ func (h *ZammadHandler) ListWorkspaceConnections(w http.ResponseWriter, r *http.
// 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 strings.TrimSpace(connection.DefaultGroupName) != "" && len(connection.AllowedGroups) == 0
}
- return len(connection.AllowedGroupIDs) == 0 || slices.Contains(connection.AllowedGroupIDs, connection.DefaultGroupID)
+ return len(connection.AllowedGroups) == 0 || slices.ContainsFunc(connection.AllowedGroups, func(group models.ZammadGroupRef) bool {
+ return group.ID == connection.DefaultGroupID && strings.TrimSpace(group.Name) != ""
+ })
}
func (h *ZammadHandler) GetWorkspaceMetadata(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/handlers/zammad_test.go b/internal/handlers/zammad_test.go
index ac51337da..a2545d89b 100644
--- a/internal/handlers/zammad_test.go
+++ b/internal/handlers/zammad_test.go
@@ -361,10 +361,10 @@ func TestZammadConnectionReadyRequiresLocallyUsableDefaultGroup(t *testing.T) {
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: "numeric default inside allowlist", connection: models.ZammadConnection{DefaultGroupID: 7, AllowedGroups: []models.ZammadGroupRef{{ID: 7, Name: "Support"}, {ID: 8, Name: "Escalations"}}}, want: true},
+ {name: "numeric default outside allowlist", connection: models.ZammadConnection{DefaultGroupID: 7, AllowedGroups: []models.ZammadGroupRef{{ID: 8, Name: "Escalations"}}}, 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: "name default with allowlist is unresolved", connection: models.ZammadConnection{DefaultGroupName: "Support", AllowedGroups: []models.ZammadGroupRef{{ID: 8, Name: "Escalations"}}}, want: false},
{name: "missing default", connection: models.ZammadConnection{}, want: false},
}
@@ -378,19 +378,37 @@ func TestZammadConnectionReadyRequiresLocallyUsableDefaultGroup(t *testing.T) {
}
func TestZammadOAuthCallbackRedirectsToIntegrationsAdminTab(t *testing.T) {
- handler, _, _, _ := newZammadHandlerTest(t)
+ handler, db, _, _ := newZammadHandlerTest(t)
+ oauthHandler := NewIntegrationOAuthHandler(db, sso.NewSecretEncryption("synthetic-handler-test-secret"), "https://windshift.example.test")
+ oauthHandler.RegisterSystemOAuthFlow(models.IntegrationProviderZammad, handler.service, logger.NewAuditor(db))
recorder := httptest.NewRecorder()
- request := httptest.NewRequest(http.MethodGet, "/api/integrations/zammad/oauth/callback?error=access_denied&state=missing", nil)
- handler.OAuthCallback(recorder, request)
+ request := httptest.NewRequest(http.MethodGet, "/api/integrations/oauth/system/zammad/callback?error=access_denied&state=missing", nil)
+ request.SetPathValue("providerType", string(models.IntegrationProviderZammad))
+ request.SetPathValue("slug", "zammad")
+ oauthHandler.SystemOAuthCallback(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 TestUserOAuthSlugCannotCollideWithSystemCallback(t *testing.T) {
+ _, db, _, _ := newZammadHandlerTest(t)
+ oauthHandler := NewIntegrationOAuthHandler(db, sso.NewSecretEncryption("synthetic-handler-test-secret"), "https://windshift.example.test")
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/api/integrations/oauth/zammad/callback?error=access_denied", nil)
+ request.SetPathValue("slug", "zammad")
+ oauthHandler.OAuthCallback(recorder, request)
+ if recorder.Code != http.StatusFound || !strings.HasPrefix(recorder.Header().Get("Location"), "/profile?tab=connected-accounts&oauth=error") {
+ t.Fatalf("user callback was dispatched as a system callback: 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"))
+ encryption := sso.NewSecretEncryption("synthetic-handler-test-secret")
+ handler.service.SetOAuthEncryption(encryption)
+ oauthHandler := NewIntegrationOAuthHandler(db, encryption, "https://windshift.example.test")
+ oauthHandler.RegisterSystemOAuthFlow(models.IntegrationProviderZammad, handler.service, logger.NewAuditor(db))
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
@@ -400,7 +418,8 @@ 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",
- DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{workspaceID},
+ DefaultGroupID: 7, DefaultGroupName: "Support", AllowedGroups: []models.ZammadGroupRef{{ID: 7, Name: "Support"}},
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{workspaceID},
}, user.ID)
if err != nil {
t.Fatal(err)
@@ -416,9 +435,11 @@ func TestZammadOAuthCallbackAuditsInitiatorSuccessAndFailureWithoutSecrets(t *te
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)
+ request := httptest.NewRequest(http.MethodGet, "/api/integrations/oauth/system/zammad/callback?state="+url.QueryEscape(parsed.Query().Get("state"))+"&code="+url.QueryEscape(code), nil)
+ request.SetPathValue("providerType", string(models.IntegrationProviderZammad))
+ request.SetPathValue("slug", "zammad")
recorder := httptest.NewRecorder()
- handler.OAuthCallback(recorder, request)
+ oauthHandler.SystemOAuthCallback(recorder, request)
if recorder.Code != http.StatusFound {
t.Fatalf("callback status = %d", recorder.Code)
}
@@ -427,7 +448,7 @@ func TestZammadOAuthCallbackAuditsInitiatorSuccessAndFailureWithoutSecrets(t *te
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)
+ FROM audit_logs WHERE action_type = ? ORDER BY id`, logger.ActionIntegrationProviderOAuthCredentialSet)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/integrations/catalog.go b/internal/integrations/catalog.go
new file mode 100644
index 000000000..d08e2fd65
--- /dev/null
+++ b/internal/integrations/catalog.go
@@ -0,0 +1,90 @@
+package integrations
+
+import "windshift/internal/models"
+
+// CredentialOwner describes who owns the authorization established for an
+// integration provider. User-owned credentials are stored per user, while
+// system-owned credentials back an administrator-managed connection.
+type CredentialOwner string
+
+const (
+ CredentialOwnerUser CredentialOwner = "user"
+ CredentialOwnerSystem CredentialOwner = "system"
+)
+
+// ProviderCapabilities declares which parts of the shared integration core a
+// provider can safely use. Provider-specific synchronization remains outside
+// this catalog.
+type ProviderCapabilities struct {
+ Type models.IntegrationProviderType
+ CredentialOwner CredentialOwner
+ AdminProviderCRUD bool
+ OAuth bool
+ GenericItemLinks bool
+ GenericItemRefresh bool
+}
+
+var providerCatalog = map[models.IntegrationProviderType]ProviderCapabilities{
+ models.IntegrationProviderNotion: {
+ Type: models.IntegrationProviderNotion,
+ CredentialOwner: CredentialOwnerUser,
+ AdminProviderCRUD: true,
+ OAuth: true,
+ GenericItemLinks: true,
+ GenericItemRefresh: true,
+ },
+ models.IntegrationProviderTodoist: {
+ Type: models.IntegrationProviderTodoist,
+ CredentialOwner: CredentialOwnerUser,
+ AdminProviderCRUD: true,
+ OAuth: true,
+ GenericItemLinks: true,
+ },
+ models.IntegrationProviderZammad: {
+ Type: models.IntegrationProviderZammad,
+ CredentialOwner: CredentialOwnerSystem,
+ OAuth: true,
+ },
+}
+
+// Capabilities returns the declared capabilities for a known provider type.
+// Unknown types fail closed.
+func Capabilities(providerType models.IntegrationProviderType) (ProviderCapabilities, bool) {
+ capabilities, ok := providerCatalog[providerType]
+ return capabilities, ok
+}
+
+// IsKnown reports whether a provider type is registered with the integration
+// core.
+func IsKnown(providerType models.IntegrationProviderType) bool {
+ _, ok := Capabilities(providerType)
+ return ok
+}
+
+// SupportsAdminProviderCRUD reports whether the generic provider settings API
+// owns the provider row.
+func SupportsAdminProviderCRUD(providerType models.IntegrationProviderType) bool {
+ capabilities, ok := Capabilities(providerType)
+ return ok && capabilities.AdminProviderCRUD
+}
+
+// SupportsUserOAuth reports whether the shared user connection flow owns the
+// provider's OAuth token.
+func SupportsUserOAuth(providerType models.IntegrationProviderType) bool {
+ capabilities, ok := Capabilities(providerType)
+ return ok && capabilities.OAuth && capabilities.CredentialOwner == CredentialOwnerUser
+}
+
+// SupportsGenericItemLinks reports whether callers may create and delete this
+// provider's links through the generic item-link endpoints.
+func SupportsGenericItemLinks(providerType models.IntegrationProviderType) bool {
+ capabilities, ok := Capabilities(providerType)
+ return ok && capabilities.GenericItemLinks
+}
+
+// SupportsGenericItemRefresh reports whether the generic refresh endpoint has
+// an implementation for the provider.
+func SupportsGenericItemRefresh(providerType models.IntegrationProviderType) bool {
+ capabilities, ok := Capabilities(providerType)
+ return ok && capabilities.GenericItemRefresh
+}
diff --git a/internal/integrations/oauth.go b/internal/integrations/oauth.go
new file mode 100644
index 000000000..94fec4821
--- /dev/null
+++ b/internal/integrations/oauth.go
@@ -0,0 +1,25 @@
+package integrations
+
+import (
+ "context"
+
+ "windshift/internal/models"
+)
+
+// SystemOAuthCallbackResult is the provider-neutral audit context returned by
+// a system-owned OAuth flow.
+type SystemOAuthCallbackResult struct {
+ ProviderID string
+ ProviderName string
+ Initiator *models.User
+ Generation int64
+}
+
+// SystemOAuthFlow extends the shared OAuth handler for administrator-managed
+// connections. Provider-specific token exchange and concurrency guarantees
+// remain behind this interface.
+type SystemOAuthFlow interface {
+ StartOAuth(ctx context.Context, providerID string, actorID int, publicBaseURL string) (string, error)
+ CompleteOAuth(ctx context.Context, state, code, publicBaseURL string) (*SystemOAuthCallbackResult, error)
+ ConsumeFailedOAuthCallback(state string) (*SystemOAuthCallbackResult, error)
+}
diff --git a/internal/integrations/zammad/client.go b/internal/integrations/zammad/client.go
index ffb5e8f3b..6c7f98c9c 100644
--- a/internal/integrations/zammad/client.go
+++ b/internal/integrations/zammad/client.go
@@ -91,6 +91,16 @@ func (c *Client) Metadata(ctx context.Context) (*models.ZammadConnectionMetadata
if err != nil {
return nil, err
}
+ activeStates, err := c.States(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &models.ZammadConnectionMetadata{Groups: activeGroups, States: activeStates}, nil
+}
+
+// States returns the active ticket states. Unlike Groups, Zammad permits this
+// endpoint for ordinary ticket agents, so it is safe for runtime use.
+func (c *Client) States(ctx context.Context) ([]models.ZammadState, error) {
states := []models.ZammadState{}
if err := c.getJSON(ctx, "/api/v1/ticket_states", &states); err != nil {
return nil, err
@@ -101,7 +111,7 @@ func (c *Client) Metadata(ctx context.Context) (*models.ZammadConnectionMetadata
activeStates = append(activeStates, state)
}
}
- return &models.ZammadConnectionMetadata{Groups: activeGroups, States: activeStates}, nil
+ return activeStates, nil
}
func (c *Client) Groups(ctx context.Context) ([]models.ZammadGroup, error) {
diff --git a/internal/logger/audit.go b/internal/logger/audit.go
index e729052b9..4455f7f01 100644
--- a/internal/logger/audit.go
+++ b/internal/logger/audit.go
@@ -540,22 +540,23 @@ const (
ActionLLMConnectionDelete = "llm_connection.delete"
// Integration Providers
- ActionIntegrationProviderCreate = "integration_provider.create"
- ActionIntegrationProviderUpdate = "integration_provider.update"
- 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"
- ActionZammadTicketRefreshAll = "zammad_ticket.refresh_all"
+ ActionIntegrationProviderCreate = "integration_provider.create"
+ ActionIntegrationProviderUpdate = "integration_provider.update"
+ 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"
+ ActionIntegrationProviderOAuthCredentialSet = "integration_provider.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"
+ ActionZammadTicketDetachLocal = "zammad_ticket.detach_local"
+ ActionZammadTicketRefresh = "zammad_ticket.refresh"
+ ActionZammadTicketRefreshAll = "zammad_ticket.refresh_all"
// Issue sync configuration
ActionIssueSyncConfigCreate = "issue_sync_config.create"
diff --git a/internal/models/zammad.go b/internal/models/zammad.go
index 700aee094..59234e6ae 100644
--- a/internal/models/zammad.go
+++ b/internal/models/zammad.go
@@ -42,7 +42,7 @@ type ZammadConnection struct {
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"`
+ AllowedGroups []ZammadGroupRef `json:"allowed_groups"`
DefaultCustomer string `json:"default_customer"`
CorrelationField string `json:"correlation_field"`
ClosedStateIDs []int `json:"closed_state_ids"`
@@ -66,7 +66,7 @@ type ZammadWorkspaceConnection struct {
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"`
+ AllowedGroups []ZammadGroupRef `json:"allowed_groups"`
}
type CreateZammadConnectionRequest struct {
@@ -80,6 +80,7 @@ type CreateZammadConnectionRequest struct {
OAuthClientSecret string `json:"oauth_client_secret,omitempty"`
DefaultGroupID int `json:"default_group_id,omitempty"`
DefaultGroupName string `json:"default_group_name,omitempty"`
+ AllowedGroups []ZammadGroupRef `json:"allowed_groups,omitempty"`
AllowedGroupIDs []int `json:"allowed_group_ids,omitempty"`
DefaultCustomer string `json:"default_customer"`
CorrelationField string `json:"correlation_field,omitempty"`
@@ -100,6 +101,7 @@ type UpdateZammadConnectionRequest struct {
OAuthClientSecret *string `json:"oauth_client_secret,omitempty"`
DefaultGroupID *int `json:"default_group_id,omitempty"`
DefaultGroupName *string `json:"default_group_name,omitempty"`
+ AllowedGroups *[]ZammadGroupRef `json:"allowed_groups,omitempty"`
AllowedGroupIDs *[]int `json:"allowed_group_ids,omitempty"`
DefaultCustomer *string `json:"default_customer,omitempty"`
CorrelationField *string `json:"correlation_field,omitempty"`
@@ -116,6 +118,14 @@ type ZammadGroup struct {
Active bool `json:"active"`
}
+// ZammadGroupRef is the persisted group policy used at runtime. Keeping both
+// the stable ID and display name avoids requiring Zammad's admin-only groups
+// endpoint after connection setup.
+type ZammadGroupRef struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+}
+
type ZammadState struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -126,6 +136,7 @@ type ZammadState struct {
type ZammadConnectionMetadata struct {
Groups []ZammadGroup `json:"groups"`
States []ZammadState `json:"states"`
+ GroupCatalogVerified bool `json:"group_catalog_verified"`
CorrelationFieldVerified bool `json:"correlation_field_verified"`
}
diff --git a/internal/repository/integration_provider_repository.go b/internal/repository/integration_provider_repository.go
index bff7ee4d6..b3f23cd75 100644
--- a/internal/repository/integration_provider_repository.go
+++ b/internal/repository/integration_provider_repository.go
@@ -8,6 +8,7 @@ import (
"time"
"windshift/internal/database"
+ "windshift/internal/integrations"
"windshift/internal/models"
)
@@ -65,11 +66,10 @@ 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 WHERE " + genericIntegrationProviderFilter + " ORDER BY name")
+ rows, err := r.db.Query("SELECT " + integrationProviderColumns + " FROM integration_providers ORDER BY name")
if err != nil {
return nil, fmt.Errorf("list integration_providers: %w", err)
}
@@ -81,7 +81,9 @@ func (r *IntegrationProviderRepository) List() ([]IntegrationProvider, error) {
if scanErr != nil {
return nil, fmt.Errorf("scan integration_provider: %w", scanErr)
}
- providers = append(providers, p)
+ if integrations.SupportsAdminProviderCRUD(p.ProviderType) {
+ providers = append(providers, p)
+ }
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate integration_providers: %w", err)
@@ -91,7 +93,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 = ? AND "+genericIntegrationProviderFilter, id)
+ row := r.db.QueryRow("SELECT "+integrationProviderColumns+" FROM integration_providers WHERE id = ?", id)
p, err := scanIntegrationProvider(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -99,12 +101,18 @@ func (r *IntegrationProviderRepository) GetByID(id string) (*IntegrationProvider
if err != nil {
return nil, fmt.Errorf("get integration_provider %s: %w", id, err)
}
+ if !integrations.SupportsAdminProviderCRUD(p.ProviderType) {
+ return nil, ErrNotFound
+ }
return &p, nil
}
// Create inserts a new integration_provider. Returns ErrDuplicateEntry when
// the slug collides with an existing row.
func (r *IntegrationProviderRepository) Create(req IntegrationProviderInsert) error {
+ if !integrations.SupportsAdminProviderCRUD(models.IntegrationProviderType(req.ProviderType)) {
+ return ErrInvalidInput
+ }
_, err := r.db.ExecWrite(`
INSERT INTO integration_providers (
id, slug, name, provider_type, enabled,
@@ -128,6 +136,10 @@ func (r *IntegrationProviderRepository) Create(req IntegrationProviderInsert) er
// updated_at is always bumped when at least one field is set. Returns
// ErrNotFound when no row matches and ErrDuplicateEntry on slug collision.
func (r *IntegrationProviderRepository) Update(id string, req IntegrationProviderUpdate) error {
+ if _, err := r.GetByID(id); err != nil {
+ return err
+ }
+
var sets []string
var args []any
@@ -157,18 +169,13 @@ func (r *IntegrationProviderRepository) Update(id string, req IntegrationProvide
}
if len(sets) == 0 {
- // No fields to update — verify the row exists so callers still get
- // ErrNotFound semantics for a missing id.
- if _, err := r.GetByID(id); err != nil {
- return err
- }
return nil
}
sets = append(sets, "updated_at = CURRENT_TIMESTAMP")
args = append(args, id)
- query := "UPDATE integration_providers SET " + strings.Join(sets, ", ") + " WHERE id = ? AND " + genericIntegrationProviderFilter
+ query := "UPDATE integration_providers SET " + strings.Join(sets, ", ") + " WHERE id = ?"
result, err := r.db.ExecWrite(query, args...)
if err != nil {
if database.IsUniqueConstraintError(err) {
@@ -186,7 +193,10 @@ 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 = ? AND "+genericIntegrationProviderFilter, id)
+ if _, err := r.GetByID(id); err != nil {
+ return err
+ }
+ result, err := r.db.ExecWrite("DELETE FROM integration_providers WHERE id = ?", 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
index ceef1d377..876a79c91 100644
--- a/internal/repository/zammad_oauth_repository.go
+++ b/internal/repository/zammad_oauth_repository.go
@@ -195,7 +195,11 @@ func (r *ZammadRepository) GuardOAuthGenerationTx(tx database.Tx, providerID str
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)
+ WHERE provider_id = ? AND oauth_generation = ? AND oauth_attempt_id = ?
+ AND EXISTS (
+ SELECT 1 FROM integration_providers ip
+ WHERE ip.id = zammad_connections.provider_id AND ip.enabled = true
+ )`, providerID, generation, attemptID)
if err != nil {
return false, err
}
diff --git a/internal/repository/zammad_repository.go b/internal/repository/zammad_repository.go
index 59f64333d..3762002a9 100644
--- a/internal/repository/zammad_repository.go
+++ b/internal/repository/zammad_repository.go
@@ -22,7 +22,7 @@ type ZammadTicketLinkReservationSnapshot struct {
ConnectionEnabled bool
DefaultGroupID int
DefaultGroupName string
- AllowedGroupIDs []int
+ AllowedGroups []models.ZammadGroupRef
DefaultCustomer string
WorkspaceID int
WorkspaceItemNumber int
@@ -44,7 +44,7 @@ type ZammadConnectionMutationSnapshot struct {
CorrelationField string
DefaultGroupID int
DefaultGroupName string
- AllowedGroupIDs []int
+ AllowedGroups []models.ZammadGroupRef
ClosedStateIDs []int
CompletionStatusID *int
}
@@ -59,8 +59,8 @@ const zammadConnectionColumns = `
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.allowed_groups, zc.default_customer, zc.correlation_field, zc.closed_state_ids,
+ zc.completion_status_id, (SELECT applies_to_all_workspaces FROM action_credentials ac WHERE ac.id = zc.credential_id),
zc.last_tested_at, zc.last_test_error, zc.created_by,
zc.created_at, zc.updated_at`
@@ -81,9 +81,9 @@ func (r *ZammadRepository) ListConnectionsForWorkspace(workspaceID int) ([]*mode
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 = ?
+ EXISTS (SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = true) OR EXISTS (
+ SELECT 1 FROM action_credential_workspaces acw
+ WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ?
)
)
ORDER BY ip.name`, workspaceID)
@@ -119,9 +119,9 @@ func (r *ZammadRepository) IsConnectionAvailableToWorkspace(id string, workspace
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 = ?
+ EXISTS (SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = true) OR EXISTS (
+ SELECT 1 FROM action_credential_workspaces acw
+ WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ?
)
))`, id, workspaceID).Scan(&available)
return available, err
@@ -135,9 +135,9 @@ func (r *ZammadRepository) IsConnectionScopedToWorkspace(id string, workspaceID
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 = ?
+ EXISTS (SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = true) OR EXISTS (
+ SELECT 1 FROM action_credential_workspaces acw
+ WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ?
)
))`, id, workspaceID).Scan(&scoped)
return scoped, err
@@ -148,7 +148,7 @@ func (r *ZammadRepository) CreateConnection(connection *models.ZammadConnection)
if err != nil {
return err
}
- allowedGroupsJSON, err := json.Marshal(connection.AllowedGroupIDs)
+ allowedGroupsJSON, err := json.Marshal(connection.AllowedGroups)
if err != nil {
return err
}
@@ -165,17 +165,16 @@ func (r *ZammadRepository) CreateConnection(connection *models.ZammadConnection)
}
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,
+ default_group_name, allowed_groups, default_customer, correlation_field,
+ closed_state_ids, completion_status_id,
created_by)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, connection.ProviderID,
+ 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 {
+ string(closedJSON), connection.CompletionStatusID, connection.CreatedBy); err != nil {
return err
}
- return replaceZammadConnectionWorkspaces(tx, connection.ProviderID, connection.AppliesToAllWorkspaces, connection.WorkspaceIDs)
+ return nil
})
}
@@ -190,19 +189,18 @@ func (r *ZammadRepository) UpdateConnectionTx(tx database.Tx, connection *models
if err != nil {
return err
}
- allowedGroupsJSON, err := json.Marshal(connection.AllowedGroupIDs)
+ allowedGroupsJSON, err := json.Marshal(connection.AllowedGroups)
if err != nil {
return err
}
result, err := tx.Exec(`UPDATE zammad_connections SET
- credential_id = ?, auth_method = ?, base_url = ?, default_group_id = ?, default_group_name = ?, allowed_group_ids = ?,
+ credential_id = ?, auth_method = ?, base_url = ?, default_group_id = ?, default_group_name = ?, allowed_groups = ?,
default_customer = ?, correlation_field = ?, closed_state_ids = ?,
- completion_status_id = ?, applies_to_all_workspaces = ?, config_revision = config_revision + 1,
+ completion_status_id = ?, 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)
+ string(closedJSON), connection.CompletionStatusID, connection.ProviderID, connection.ConfigRevision)
if err != nil {
return err
}
@@ -227,7 +225,7 @@ func (r *ZammadRepository) UpdateConnectionTx(tx database.Tx, connection *models
if rows, _ := result.RowsAffected(); rows == 0 {
return ErrNotFound
}
- return replaceZammadConnectionWorkspaces(tx, connection.ProviderID, connection.AppliesToAllWorkspaces, connection.WorkspaceIDs)
+ return nil
}
func (r *ZammadRepository) DeleteConnectionTx(tx database.Tx, id string) error {
@@ -319,21 +317,21 @@ func (r *ZammadRepository) lockTicketLinkConnectionTx(tx database.Tx, linkID str
func (r *ZammadRepository) ConnectionMutationSnapshotTx(tx database.Tx, id string) (*ZammadConnectionMutationSnapshot, error) {
snapshot := &ZammadConnectionMutationSnapshot{}
- var allowedGroupIDsJSON, closedStateIDsJSON string
+ var allowedGroupsJSON, 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
+ default_group_name, allowed_groups, 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,
+ &snapshot.DefaultGroupName, &allowedGroupsJSON, &closedStateIDsJSON, &completionStatusID,
); 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)
+ if err := json.Unmarshal([]byte(allowedGroupsJSON), &snapshot.AllowedGroups); err != nil {
+ return nil, fmt.Errorf("decode Zammad allowed groups: %w", err)
}
if err := json.Unmarshal([]byte(closedStateIDsJSON), &snapshot.ClosedStateIDs); err != nil {
return nil, fmt.Errorf("decode Zammad closed state IDs: %w", err)
@@ -345,24 +343,24 @@ func (r *ZammadRepository) ConnectionMutationSnapshotTx(tx database.Tx, id strin
return snapshot, nil
}
-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 {
+func (r *ZammadRepository) ConnectionGroupPolicyTx(tx database.Tx, id string) (defaultGroupID int, defaultGroupName string, allowedGroups []models.ZammadGroupRef, queryErr error) {
+ var allowedGroupsJSON string
+ if err := tx.QueryRow(`SELECT COALESCE(default_group_id, 0), default_group_name, allowed_groups
+ FROM zammad_connections WHERE provider_id = ?`, id).Scan(&defaultGroupID, &defaultGroupName, &allowedGroupsJSON); 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)
+ if err := json.Unmarshal([]byte(allowedGroupsJSON), &allowedGroups); err != nil {
+ return 0, "", nil, fmt.Errorf("decode Zammad allowed groups: %w", err)
}
- return defaultGroupID, defaultGroupName, allowedGroupIDs, nil
+ return defaultGroupID, defaultGroupName, allowedGroups, 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
+ COALESCE(zc.default_group_id, 0), zc.default_group_name, zc.allowed_groups, zc.default_customer
FROM zammad_connections zc
JOIN integration_providers ip ON ip.id = zc.provider_id
WHERE zc.provider_id = ?`
@@ -370,18 +368,18 @@ func (r *ZammadRepository) LockTicketLinkReservationTx(tx database.Tx, providerI
connectionQuery += " FOR UPDATE OF zc"
}
snapshot := &ZammadTicketLinkReservationSnapshot{}
- var allowedGroupIDsJSON string
+ var allowedGroupsJSON string
if err := tx.QueryRow(connectionQuery, providerID).Scan(
&snapshot.BaseURL, &snapshot.CorrelationField, &snapshot.ConnectionEnabled,
- &snapshot.DefaultGroupID, &snapshot.DefaultGroupName, &allowedGroupIDsJSON, &snapshot.DefaultCustomer,
+ &snapshot.DefaultGroupID, &snapshot.DefaultGroupName, &allowedGroupsJSON, &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)
+ if err := json.Unmarshal([]byte(allowedGroupsJSON), &snapshot.AllowedGroups); err != nil {
+ return nil, fmt.Errorf("decode Zammad allowed groups: %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 = ?`
@@ -397,9 +395,9 @@ func (r *ZammadRepository) LockTicketLinkReservationTx(tx database.Tx, providerI
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 = ?
+ EXISTS (SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = true) OR EXISTS (
+ SELECT 1 FROM action_credential_workspaces acw
+ WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ?
)
))`, providerID, snapshot.WorkspaceID).Scan(&snapshot.WorkspaceAllowed); err != nil {
return nil, err
@@ -438,10 +436,12 @@ func (r *ZammadRepository) HasTicketLinkUnavailableInWorkspaceTx(tx database.Tx,
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
+ WHERE ztl.item_id = ? AND EXISTS (
+ SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.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 = ?
+ SELECT 1 FROM action_credential_workspaces acw
+ WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ?
))`, itemID, workspaceID).Scan(&unavailable)
return unavailable, err
}
@@ -525,16 +525,18 @@ func scanZammadConnection(scanner interface{ Scan(...any) error }) (*models.Zamm
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)
+ if err := json.Unmarshal([]byte(allowedGroupsJSON), &connection.AllowedGroups); err != nil {
+ return nil, fmt.Errorf("decode allowed_groups: %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)
+ rows, err := r.db.Query(`SELECT acw.workspace_id
+ FROM zammad_connections zc
+ JOIN action_credential_workspaces acw ON acw.credential_id = zc.credential_id
+ WHERE zc.provider_id = ? ORDER BY acw.workspace_id`, providerID)
if err != nil {
return nil, err
}
@@ -550,22 +552,6 @@ func (r *ZammadRepository) connectionWorkspaceIDs(providerID string) ([]int, err
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
@@ -573,9 +559,9 @@ func (r *ZammadRepository) GetTicketLinksForItem(itemID int) ([]*models.ZammadTi
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
+ EXISTS (SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = true) OR EXISTS (
+ SELECT 1 FROM action_credential_workspaces acw
+ WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = i.workspace_id
)
) ORDER BY ztl.created_at DESC`, itemID)
if err != nil {
@@ -1136,23 +1122,6 @@ func scanZammadTicketLink(scanner interface{ Scan(...any) error }) (*models.Zamm
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
- })
-}
-
// 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
diff --git a/internal/restapi/v1/handlers/items.go b/internal/restapi/v1/handlers/items.go
index 544b91b96..99133b059 100644
--- a/internal/restapi/v1/handlers/items.go
+++ b/internal/restapi/v1/handlers/items.go
@@ -885,7 +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 409 {object} handlers.ErrorResponse "Item or descendant still has a protected integration link"
// @Failure 500 {object} handlers.ErrorResponse
// @Router /items/{id} [delete]
func (h *ItemHandler) Delete(w http.ResponseWriter, r *http.Request) {
@@ -909,11 +909,11 @@ func (h *ItemHandler) Delete(w http.ResponseWriter, r *http.Request) {
h.RespondError(w, r, restapi.ErrItemNotFound)
return
}
- if errors.Is(err, services.ErrItemHasZammadTicketLinks) {
+ if errors.Is(err, services.ErrItemHasProtectedIntegrationLinks) {
h.RespondError(w, r, restapi.NewAPIError(
http.StatusConflict,
restapi.ErrCodeConflict,
- "Unlink all Zammad tickets from the affected items before deleting them.",
+ "Remove all protected integration links from the affected items before deleting them.",
))
return
}
diff --git a/internal/restapi/v1/handlers/workspaces.go b/internal/restapi/v1/handlers/workspaces.go
index a927bbafa..1192846d3 100644
--- a/internal/restapi/v1/handlers/workspaces.go
+++ b/internal/restapi/v1/handlers/workspaces.go
@@ -393,7 +393,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 409 {object} handlers.ErrorResponse "Workspace still has externally linked items"
// @Failure 500 {object} handlers.ErrorResponse
// @Router /workspaces/{id} [delete]
func (h *WorkspaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
@@ -421,11 +421,11 @@ func (h *WorkspaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
h.RespondError(w, r, restapi.ErrWorkspaceNotFound)
return
}
- if errors.Is(err, services.ErrWorkspaceHasZammadTicketLinks) {
+ if errors.Is(err, services.ErrWorkspaceHasProtectedIntegrationLinks) {
h.RespondError(w, r, restapi.NewAPIError(
http.StatusConflict,
restapi.ErrCodeConflict,
- "Unlink all Zammad tickets from this workspace before deleting it.",
+ "Remove all protected integration links from this workspace before deleting it.",
))
return
}
diff --git a/internal/routes/integrations.go b/internal/routes/integrations.go
index a84c05076..58585ac1b 100644
--- a/internal/routes/integrations.go
+++ b/internal/routes/integrations.go
@@ -27,10 +27,12 @@ func RegisterIntegrationRoutes(deps *Deps) {
api.HandleH("GET /admin/integration-providers/{id}", admin(http.HandlerFunc(deps.Integrations.Provider.GetProvider)))
api.HandleH("PUT /admin/integration-providers/{id}", admin(http.HandlerFunc(deps.Integrations.Provider.UpdateProvider)))
api.HandleH("DELETE /admin/integration-providers/{id}", admin(http.HandlerFunc(deps.Integrations.Provider.DeleteProvider)))
+ api.HandleH("POST /admin/integration-providers/{id}/oauth/start", admin(http.HandlerFunc(deps.Integrations.OAuth.StartSystemOAuth)))
// OAuth flow
api.HandleH("GET /integrations/oauth/{slug}/start", auth(http.HandlerFunc(deps.Integrations.OAuth.StartOAuth)))
api.Handle("GET /integrations/oauth/{slug}/callback", http.HandlerFunc(deps.Integrations.OAuth.OAuthCallback))
+ api.Handle("GET /integrations/oauth/system/{providerType}/callback", http.HandlerFunc(deps.Integrations.OAuth.SystemOAuthCallback))
// User connections
api.HandleH("GET /users/me/integration-connections", auth(http.HandlerFunc(deps.Integrations.OAuth.GetUserConnections)))
@@ -50,10 +52,9 @@ func RegisterIntegrationRoutes(deps *Deps) {
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.HandleH("POST /admin/zammad-ticket-links/refresh", admin(http.HandlerFunc(deps.Integrations.Zammad.RefreshAllTickets)))
- 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("POST /admin/zammad-ticket-links/{linkId}/detach-local", admin(http.HandlerFunc(deps.Integrations.Zammad.DetachTicketLinkLocally)))
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)))
diff --git a/internal/server/server.go b/internal/server/server.go
index 598156e60..0874ac0ff 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -1046,7 +1046,7 @@ func (s *Server) initialize() error {
zammadService.SetEventCoordinator(eventCoordinator)
zammadService.SetOAuthEncryption(scmProviderHandler.GetEncryption())
zammadHandler := handlers.NewZammadHandler(repository.NewItemRepository(s.db), zammadService, permService, logger.NewAuditor(s.db))
- zammadHandler.SetOAuthBaseURL(baseURL)
+ integrationOAuthHandler.RegisterSystemOAuthFlow(models.IntegrationProviderZammad, zammadService, logger.NewAuditor(s.db))
s.zammadSyncScheduler = scheduler.NewZammadSyncScheduler(zammadService)
s.zammadSyncScheduler.Start()
zammadHandler.SetSyncAllTrigger(s.zammadSyncScheduler.TriggerSyncAll)
diff --git a/internal/services/integration_link_guards.go b/internal/services/integration_link_guards.go
new file mode 100644
index 000000000..d8f93696a
--- /dev/null
+++ b/internal/services/integration_link_guards.go
@@ -0,0 +1,72 @@
+package services
+
+import (
+ "windshift/internal/database"
+ "windshift/internal/repository"
+)
+
+// IntegrationLinkGuard protects provider-managed links whose lifecycle needs
+// more than the generic item_integration_links cascade can provide.
+type IntegrationLinkGuard interface {
+ HasLinksForItemsTx(tx database.Tx, itemIDs []int) (bool, error)
+ HasLinksForWorkspaceTx(tx database.Tx, workspaceID int) (bool, error)
+ HasLinkUnavailableInWorkspaceTx(tx database.Tx, itemID, workspaceID int) (bool, error)
+}
+
+// IntegrationLinkGuards composes all provider lifecycle extensions. Core item
+// and workspace services depend only on this interface set, not on Zammad.
+type IntegrationLinkGuards struct {
+ guards []IntegrationLinkGuard
+}
+
+func NewIntegrationLinkGuards(db database.Database) *IntegrationLinkGuards {
+ return &IntegrationLinkGuards{guards: []IntegrationLinkGuard{
+ zammadIntegrationLinkGuard{repo: repository.NewZammadRepository(db)},
+ }}
+}
+
+func (g *IntegrationLinkGuards) HasLinksForItemsTx(tx database.Tx, itemIDs []int) (bool, error) {
+ for _, guard := range g.guards {
+ blocked, err := guard.HasLinksForItemsTx(tx, itemIDs)
+ if err != nil || blocked {
+ return blocked, err
+ }
+ }
+ return false, nil
+}
+
+func (g *IntegrationLinkGuards) HasLinksForWorkspaceTx(tx database.Tx, workspaceID int) (bool, error) {
+ for _, guard := range g.guards {
+ blocked, err := guard.HasLinksForWorkspaceTx(tx, workspaceID)
+ if err != nil || blocked {
+ return blocked, err
+ }
+ }
+ return false, nil
+}
+
+func (g *IntegrationLinkGuards) HasLinkUnavailableInWorkspaceTx(tx database.Tx, itemID, workspaceID int) (bool, error) {
+ for _, guard := range g.guards {
+ blocked, err := guard.HasLinkUnavailableInWorkspaceTx(tx, itemID, workspaceID)
+ if err != nil || blocked {
+ return blocked, err
+ }
+ }
+ return false, nil
+}
+
+type zammadIntegrationLinkGuard struct {
+ repo *repository.ZammadRepository
+}
+
+func (g zammadIntegrationLinkGuard) HasLinksForItemsTx(tx database.Tx, itemIDs []int) (bool, error) {
+ return g.repo.HasTicketLinksForItemsTx(tx, itemIDs)
+}
+
+func (g zammadIntegrationLinkGuard) HasLinksForWorkspaceTx(tx database.Tx, workspaceID int) (bool, error) {
+ return g.repo.HasTicketLinksForWorkspaceTx(tx, workspaceID)
+}
+
+func (g zammadIntegrationLinkGuard) HasLinkUnavailableInWorkspaceTx(tx database.Tx, itemID, workspaceID int) (bool, error) {
+ return g.repo.HasTicketLinkUnavailableInWorkspaceTx(tx, itemID, workspaceID)
+}
diff --git a/internal/services/item_crud_service.go b/internal/services/item_crud_service.go
index fe20a2593..f5ee94225 100644
--- a/internal/services/item_crud_service.go
+++ b/internal/services/item_crud_service.go
@@ -18,26 +18,26 @@ import (
)
var (
- ErrCollectionNotFound = errors.New("collection not found")
- ErrQLQuery = errors.New("QL query error")
- ErrItemHasZammadTicketLinks = errors.New("item has linked Zammad tickets")
+ ErrCollectionNotFound = errors.New("collection not found")
+ ErrQLQuery = errors.New("QL query error")
+ ErrItemHasProtectedIntegrationLinks = errors.New("item has provider-managed integration links")
)
// ItemCRUDService handles item CRUD operations
type ItemCRUDService struct {
- db database.Database
- repo *repository.ItemRepository
- workspaceRepo *repository.WorkspaceRepository
- zammadRepo *repository.ZammadRepository
+ db database.Database
+ repo *repository.ItemRepository
+ workspaceRepo *repository.WorkspaceRepository
+ integrationLinkGuards *IntegrationLinkGuards
}
// NewItemCRUDService creates a new item CRUD service
func NewItemCRUDService(db database.Database) *ItemCRUDService {
return &ItemCRUDService{
- db: db,
- repo: repository.NewItemRepository(db),
- workspaceRepo: repository.NewWorkspaceRepository(db),
- zammadRepo: repository.NewZammadRepository(db),
+ db: db,
+ repo: repository.NewItemRepository(db),
+ workspaceRepo: repository.NewWorkspaceRepository(db),
+ integrationLinkGuards: NewIntegrationLinkGuards(db),
}
}
@@ -98,12 +98,12 @@ func (s *ItemCRUDService) DeleteSingleWithMetadata(itemID int, metadata itemeven
if err != nil {
return err
}
- hasZammadLinks, err := s.zammadRepo.HasTicketLinksForItemsTx(tx, []int{itemID})
+ hasProtectedLinks, err := s.integrationLinkGuards.HasLinksForItemsTx(tx, []int{itemID})
if err != nil {
return err
}
- if hasZammadLinks {
- return ErrItemHasZammadTicketLinks
+ if hasProtectedLinks {
+ return ErrItemHasProtectedIntegrationLinks
}
if metadata.OccurredAt.IsZero() {
metadata.OccurredAt = time.Now()
@@ -167,12 +167,12 @@ func (s *ItemCRUDService) DeleteWithMetadata(itemID int, metadata itemevents.Met
if len(items) != len(allIDs) {
return repository.ErrNotFound
}
- hasZammadLinks, err := s.zammadRepo.HasTicketLinksForItemsTx(tx, allIDs)
+ hasProtectedLinks, err := s.integrationLinkGuards.HasLinksForItemsTx(tx, allIDs)
if err != nil {
return err
}
- if hasZammadLinks {
- return ErrItemHasZammadTicketLinks
+ if hasProtectedLinks {
+ return ErrItemHasProtectedIntegrationLinks
}
if metadata.OccurredAt.IsZero() {
metadata.OccurredAt = time.Now()
diff --git a/internal/services/item_workspace_move_service.go b/internal/services/item_workspace_move_service.go
index 83f8c983b..bf997d00d 100644
--- a/internal/services/item_workspace_move_service.go
+++ b/internal/services/item_workspace_move_service.go
@@ -580,14 +580,14 @@ 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)
+ integrationLinkUnavailable, err := NewIntegrationLinkGuards(s.db).HasLinkUnavailableInWorkspaceTx(tx, itemID, input.DestinationWorkspaceID)
if err != nil {
- return nil, fmt.Errorf("validate Zammad ticket links for workspace move: %w", err)
+ return nil, fmt.Errorf("validate integration links for workspace move: %w", err)
}
- if zammadLinkUnavailable {
+ if integrationLinkUnavailable {
return nil, &validation.ValidationError{
Field: "destination_workspace_id",
- Message: "Destination workspace is not allowed by every linked Zammad connection",
+ Message: "Destination workspace is not allowed by every provider-managed integration link",
}
}
diff --git a/internal/services/user_offboard.go b/internal/services/user_offboard.go
index 3528462c5..b96ff4284 100644
--- a/internal/services/user_offboard.go
+++ b/internal/services/user_offboard.go
@@ -17,9 +17,9 @@ 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")
+// ErrUserOffboardingHasProtectedIntegrationLinks prevents deleting a personal
+// workspace while provider-managed links still need explicit cleanup.
+var ErrUserOffboardingHasProtectedIntegrationLinks = errors.New("user has provider-managed integration links 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
@@ -39,7 +39,7 @@ func OffboardUser(db database.Database, userID int, notificationDeleter UserNoti
}
defer func() { _ = tx.Rollback() }()
- // Resolve the personal workspace and protect its Zammad links before any
+ // Resolve the personal workspace and protect its external links before any
// mutation in this transaction. Offboarding must not orphan linked tickets.
var personalWsID *int
var wsID int
@@ -58,13 +58,12 @@ func OffboardUser(db database.Database, userID int, notificationDeleter UserNoti
if err := itemRepo.LockWorkspaceItemsTx(tx, *personalWsID); err != nil {
return nil, err
}
- zammadRepo := repository.NewZammadRepository(db)
- hasLinks, err := zammadRepo.HasTicketLinksForWorkspaceTx(tx, *personalWsID)
+ hasLinks, err := NewIntegrationLinkGuards(db).HasLinksForWorkspaceTx(tx, *personalWsID)
if err != nil {
- return nil, fmt.Errorf("failed to check personal workspace Zammad links: %w", err)
+ return nil, fmt.Errorf("failed to check personal workspace integration links: %w", err)
}
if hasLinks {
- return nil, ErrUserOffboardingHasZammadTicketLinks
+ return nil, ErrUserOffboardingHasProtectedIntegrationLinks
}
}
diff --git a/internal/services/workspace_service.go b/internal/services/workspace_service.go
index 0fec81ec6..211ce56ec 100644
--- a/internal/services/workspace_service.go
+++ b/internal/services/workspace_service.go
@@ -13,27 +13,27 @@ import (
"windshift/internal/repository"
)
-var ErrWorkspaceHasZammadTicketLinks = errors.New("workspace has linked Zammad tickets")
+var ErrWorkspaceHasProtectedIntegrationLinks = errors.New("workspace has provider-managed integration links")
// WorkspaceService encapsulates workspace business logic used by both HTTP handlers
// and other services.
type WorkspaceService struct {
- db database.Database
- repo *repository.WorkspaceRepository
- itemRepo *repository.ItemRepository
- templates *repository.WorkspaceTemplateRepository
- zammadRepo *repository.ZammadRepository
- access WorkspaceSourceAccess
+ db database.Database
+ repo *repository.WorkspaceRepository
+ itemRepo *repository.ItemRepository
+ templates *repository.WorkspaceTemplateRepository
+ integrationLinkGuards *IntegrationLinkGuards
+ access WorkspaceSourceAccess
}
// NewWorkspaceService creates a new WorkspaceService.
func NewWorkspaceService(db database.Database) *WorkspaceService {
return &WorkspaceService{
- db: db,
- repo: repository.NewWorkspaceRepository(db),
- itemRepo: repository.NewItemRepository(db),
- templates: repository.NewWorkspaceTemplateRepository(db),
- zammadRepo: repository.NewZammadRepository(db),
+ db: db,
+ repo: repository.NewWorkspaceRepository(db),
+ itemRepo: repository.NewItemRepository(db),
+ templates: repository.NewWorkspaceTemplateRepository(db),
+ integrationLinkGuards: NewIntegrationLinkGuards(db),
}
}
@@ -360,12 +360,12 @@ func (s *WorkspaceService) Delete(id int) error {
return err
}
- hasZammadLinks, err := s.zammadRepo.HasTicketLinksForWorkspaceTx(tx, id)
+ hasProtectedLinks, err := s.integrationLinkGuards.HasLinksForWorkspaceTx(tx, id)
if err != nil {
return err
}
- if hasZammadLinks {
- return ErrWorkspaceHasZammadTicketLinks
+ if hasProtectedLinks {
+ return ErrWorkspaceHasProtectedIntegrationLinks
}
if err := s.repo.DeleteTx(tx, id); err != nil {
diff --git a/internal/services/zammad_service.go b/internal/services/zammad_service.go
index e61600ad9..e226dae72 100644
--- a/internal/services/zammad_service.go
+++ b/internal/services/zammad_service.go
@@ -14,6 +14,7 @@ import (
"time"
"windshift/internal/database"
+ coreintegrations "windshift/internal/integrations"
"windshift/internal/integrations/zammad"
"windshift/internal/itemevents"
"windshift/internal/models"
@@ -38,12 +39,7 @@ const zammadOAuthRefreshWaitDuration = 5 * time.Second
const zammadOAuthRefreshPollInterval = 50 * time.Millisecond
const zammadSyncLeaseDuration = 5 * time.Minute
-type ZammadOAuthCallbackResult struct {
- ProviderID string
- ProviderName string
- Initiator *models.User
- OAuthGeneration int64
-}
+type ZammadOAuthCallbackResult = coreintegrations.SystemOAuthCallbackResult
type ZammadSyncSummary struct {
Selected int `json:"selected"`
@@ -264,12 +260,19 @@ func (s *ZammadService) UpdateConnection(id string, req models.UpdateZammadConne
if req.DefaultGroupName != nil {
connection.DefaultGroupName = strings.TrimSpace(*req.DefaultGroupName)
}
- if req.AllowedGroupIDs != nil {
+ if req.AllowedGroups != nil {
+ normalizedGroups := normalizeZammadGroupRefs(*req.AllowedGroups)
+ if err := validateZammadGroupRefs(*req.AllowedGroups); err != nil && !slices.Equal(normalizedGroups, connection.AllowedGroups) {
+ return nil, err
+ }
+ connection.AllowedGroups = normalizedGroups
+ } else if req.AllowedGroupIDs != nil {
if hasNonPositiveIDs(*req.AllowedGroupIDs) {
return nil, zammadValidationError("allowed_group_ids must contain positive IDs")
}
- connection.AllowedGroupIDs = normalizePositiveIDs(*req.AllowedGroupIDs)
+ connection.AllowedGroups = legacyZammadGroupRefs(*req.AllowedGroupIDs, connection.DefaultGroupID, connection.DefaultGroupName)
}
+ syncZammadDefaultGroupName(connection)
if req.DefaultCustomer != nil {
connection.DefaultCustomer = strings.TrimSpace(*req.DefaultCustomer)
}
@@ -400,7 +403,7 @@ func (s *ZammadService) validateZammadConnectionLinksTx(tx database.Tx, connecti
}
groupPolicyChanged := connection.DefaultGroupID != current.DefaultGroupID ||
connection.DefaultGroupName != current.DefaultGroupName ||
- !slices.Equal(connection.AllowedGroupIDs, current.AllowedGroupIDs)
+ !slices.Equal(effectiveZammadGroupRefs(connection), effectiveZammadSnapshotGroupRefs(current))
completionPolicyChanged := !slices.Equal(connection.ClosedStateIDs, current.ClosedStateIDs) ||
!optionalIntEqual(connection.CompletionStatusID, current.CompletionStatusID)
groupPolicyNarrows := groupPolicyChanged && zammadGroupPolicyNarrows(current, connection)
@@ -430,8 +433,10 @@ func zammadConnectionAllowsWorkspace(connection *models.ZammadConnection, worksp
}
func zammadConnectionAllowsGroupSnapshot(connection *models.ZammadConnection, groupID int, groupName string) bool {
- if len(connection.AllowedGroupIDs) > 0 {
- return slices.Contains(connection.AllowedGroupIDs, groupID)
+ allowedGroups := effectiveZammadGroupRefs(connection)
+ if len(allowedGroups) > 0 {
+ _, ok := zammadGroupRefByID(allowedGroups, groupID)
+ return ok
}
if connection.DefaultGroupID > 0 {
return connection.DefaultGroupID == groupID
@@ -444,9 +449,10 @@ func zammadConnectionAllowsGroupSnapshot(connection *models.ZammadConnection, gr
// 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, "") {
+ currentGroups := effectiveZammadSnapshotGroupRefs(current)
+ if len(currentGroups) > 0 {
+ for _, group := range currentGroups {
+ if !zammadConnectionAllowsGroupSnapshot(proposed, group.ID, group.Name) {
return true
}
}
@@ -459,7 +465,7 @@ func zammadGroupPolicyNarrows(current *repository.ZammadConnectionMutationSnapsh
if current.DefaultGroupID > 0 {
return !zammadConnectionAllowsGroupSnapshot(proposed, current.DefaultGroupID, current.DefaultGroupName)
}
- return len(proposed.AllowedGroupIDs) > 0 || proposed.DefaultGroupID > 0 || proposed.DefaultGroupName != current.DefaultGroupName
+ return len(effectiveZammadGroupRefs(proposed)) > 0 || proposed.DefaultGroupID > 0 || proposed.DefaultGroupName != current.DefaultGroupName
}
func (s *ZammadService) reserveZammadTicketLink(connection *models.ZammadConnection, item *models.Item, link *models.ZammadTicketLink, existing bool) error {
@@ -475,7 +481,7 @@ func (s *ZammadService) reserveZammadTicketLink(connection *models.ZammadConnect
snapshot.WorkspaceKey != item.WorkspaceKey ||
!zammadConnectionAllowsGroupSnapshot(&models.ZammadConnection{
DefaultGroupID: snapshot.DefaultGroupID, DefaultGroupName: snapshot.DefaultGroupName,
- AllowedGroupIDs: snapshot.AllowedGroupIDs,
+ AllowedGroups: snapshot.AllowedGroups,
}, link.GroupID, link.GroupName) {
return ErrZammadLinkReservationConflict
}
@@ -493,6 +499,9 @@ func (s *ZammadService) StartOAuth(ctx context.Context, id string, actorID int,
if err != nil {
return "", err
}
+ if !connection.Enabled {
+ return "", zammadValidationError("connection is disabled")
+ }
if connection.AuthMethod != models.ZammadAuthMethodOAuth {
return "", zammadValidationError("connection does not use OAuth")
}
@@ -556,6 +565,9 @@ func (s *ZammadService) CompleteOAuth(ctx context.Context, state, code, publicBa
if connection.OAuthGeneration != consumed.OAuthGeneration {
return result, ErrZammadOAuthSuperseded
}
+ if !connection.Enabled {
+ return result, zammadValidationError("connection is disabled")
+ }
if connection.AuthMethod != models.ZammadAuthMethodOAuth {
return result, zammadValidationError("connection OAuth configuration changed")
}
@@ -602,7 +614,7 @@ func (s *ZammadService) CompleteOAuth(ctx context.Context, state, code, publicBa
}
func (s *ZammadService) oauthCallbackResult(consumed *repository.ZammadOAuthState) *ZammadOAuthCallbackResult {
- result := &ZammadOAuthCallbackResult{ProviderID: consumed.ProviderID, OAuthGeneration: consumed.OAuthGeneration}
+ result := &ZammadOAuthCallbackResult{ProviderID: consumed.ProviderID, Generation: consumed.OAuthGeneration}
initiator, err := repository.NewUserRepository(s.db).GetByID(consumed.InitiatedBy)
if err != nil {
initiator = &models.User{ID: consumed.InitiatedBy, Username: "unknown"}
@@ -840,15 +852,16 @@ func (s *ZammadService) TestConnection(ctx context.Context, id string) (*models.
if err != nil {
return nil, err
}
- metadata, err := client.Metadata(ctx)
+ metadata, err := zammadRuntimeMetadata(ctx, connection, client)
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.
+ // neither admin.object nor admin.group. The configured group catalog and
+ // custom field therefore remain explicitly unverified.
+ metadata.GroupCatalogVerified = false
metadata.CorrelationFieldVerified = false
}
if err == nil {
- err = validateZammadMetadata(connection, metadata)
+ err = validateZammadStates(connection, metadata)
}
safeError := ""
if err != nil {
@@ -863,12 +876,7 @@ func (s *ZammadService) MetadataForWorkspace(ctx context.Context, id string, wor
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
+ return zammadRuntimeMetadata(ctx, connection, client)
}
func (s *ZammadService) OwnersForWorkspace(ctx context.Context, id string, workspaceID, groupID int) ([]models.ZammadOwner, error) {
@@ -879,11 +887,7 @@ func (s *ZammadService) OwnersForWorkspace(ctx context.Context, id string, works
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 {
+ if _, err := allowedZammadGroup(connection, groupID); err != nil {
return nil, err
}
owners, err := client.Owners(ctx, groupID)
@@ -955,11 +959,11 @@ func (s *ZammadService) LinkExistingTicket(ctx context.Context, itemID, actorID
if ticket.Number != req.TicketNumber {
return nil, zammadValidationError("Zammad ticket was not found")
}
- metadata, err := client.Metadata(ctx)
+ metadata, err := zammadRuntimeMetadata(ctx, connection, client)
if err != nil {
return nil, err
}
- group, err := allowedZammadGroup(connection, metadata.Groups, ticket.GroupID)
+ group, err := allowedZammadGroup(connection, ticket.GroupID)
if err != nil {
return nil, err
}
@@ -1040,11 +1044,11 @@ func (s *ZammadService) LinkExistingTicket(ctx context.Context, itemID, actorID
if err != nil {
return nil, err
}
- metadata, err = client.Metadata(ctx)
+ metadata, err = zammadRuntimeMetadata(ctx, connection, client)
if err != nil {
return nil, err
}
- group, err = allowedZammadGroup(connection, metadata.Groups, ticket.GroupID)
+ group, err = allowedZammadGroup(connection, ticket.GroupID)
if err != nil {
return nil, err
}
@@ -1099,27 +1103,13 @@ func (s *ZammadService) CreateTicket(ctx context.Context, itemID, actorID int, r
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)
+ group, err := allowedZammadGroup(connection, groupID)
+ if err != nil {
+ return nil, err
}
- if !allowed {
- return nil, zammadValidationError("selected Zammad group is not allowed for this connection")
+ groupID, groupName = group.ID, group.Name
+ if strings.TrimSpace(groupName) == "" {
+ return nil, zammadValidationError("selected Zammad group needs a stored name before tickets can be created")
}
correlation := fmt.Sprintf("windshift:%s:%s-%d", connection.ProviderID, item.WorkspaceKey, item.WorkspaceItemNumber)
link, err := s.repo.GetTicketLinkForItem(itemID, connection.ProviderID)
@@ -1170,11 +1160,7 @@ func (s *ZammadService) CreateTicket(ctx context.Context, itemID, actorID int, r
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 {
+ if _, err := allowedZammadGroup(connection, groupID); err != nil {
return nil, s.failZammadTicketCreationBeforePost(link.ID, syncOwner, wasUncertain, err)
}
@@ -1222,7 +1208,7 @@ func (s *ZammadService) CreateTicket(ctx context.Context, itemID, actorID int, r
}
}
ticketGroupID := ticket.GroupID
- allowedGroup, groupErr := s.requireAllowedTicketGroup(ctx, connection, client, ticket, groups)
+ allowedGroup, groupErr := s.requireAllowedTicketGroup(ctx, connection, client, ticket, nil)
if groupErr != nil {
if err := s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, RedactString(groupErr.Error())); err != nil {
return nil, s.mapZammadSyncClaimError(err)
@@ -1253,35 +1239,7 @@ func (s *ZammadService) CreateTicket(ctx context.Context, itemID, actorID int, r
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
- }
- 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 != "" {
- for groupID, name := range activeGroups {
- if name == connection.DefaultGroupName {
- defaultGroupID = groupID
- break
- }
- }
- 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))
- }
- }
+func validateZammadStates(connection *models.ZammadConnection, metadata *models.ZammadConnectionMetadata) error {
activeStates := make(map[int]struct{}, len(metadata.States))
for _, state := range metadata.States {
activeStates[state.ID] = struct{}{}
@@ -1338,7 +1296,7 @@ func (s *ZammadService) UpdateTicketLink(ctx context.Context, linkID string, req
if err != nil {
return nil, err
}
- metadata, err := client.Metadata(ctx)
+ metadata, err := zammadRuntimeMetadata(ctx, connection, client)
if err != nil {
return nil, err
}
@@ -1347,7 +1305,7 @@ func (s *ZammadService) UpdateTicketLink(ctx context.Context, linkID string, req
if req.GroupID != nil {
effectiveGroupID = *req.GroupID
}
- if _, err := allowedZammadGroup(connection, metadata.Groups, effectiveGroupID); err != nil {
+ if _, err := allowedZammadGroup(connection, effectiveGroupID); err != nil {
return nil, err
}
if req.StateID != nil && !zammadStateExists(metadata, *req.StateID) {
@@ -1470,6 +1428,42 @@ func (s *ZammadService) UnlinkTicket(ctx context.Context, linkID string) (*model
return link, nil
}
+// DetachTicketLinkLocally is an explicit administrator recovery path for
+// upstream outages or permanently invalid credentials. It removes only the
+// local typed and generic links. The remote correlation value may remain in
+// Zammad and must be cleared there separately when the upstream system is
+// available again.
+func (s *ZammadService) DetachTicketLinkLocally(linkID string) (*models.ZammadTicketLink, error) {
+ link, err := s.repo.GetTicketLink(linkID)
+ if err != nil {
+ return nil, err
+ }
+ 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
+ }
+ deleted := false
+ defer func() {
+ if !deleted {
+ _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner)
+ }
+ }()
+
+ link, err = s.repo.GetTicketLink(link.ID)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.repo.DeleteTicketLinkClaimed(link.ID, syncOwner); err != nil {
+ return nil, s.mapZammadSyncClaimError(err)
+ }
+ deleted = true
+ return link, nil
+}
+
func (s *ZammadService) SyncTicketLink(ctx context.Context, linkID string) (*models.ZammadTicketLink, error) {
link, err := s.repo.GetTicketLink(linkID)
if err != nil {
@@ -1557,7 +1551,7 @@ func (s *ZammadService) syncClaimedTicketLink(ctx context.Context, link *models.
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)
+ metadata, err = zammadRuntimeMetadata(ctx, connection, client)
if err != nil {
s.recordZammadSyncError(link, syncOwner, err)
return nil, err
@@ -1740,12 +1734,12 @@ func (s *ZammadService) completeExistingTicketLinkWithCurrentGroupPolicy(link *m
}
func (s *ZammadService) requireCurrentZammadGroupTx(tx database.Tx, providerID string, groupID int, groupName string) error {
- defaultGroupID, defaultGroupName, allowedGroupIDs, err := s.repo.ConnectionGroupPolicyTx(tx, providerID)
+ defaultGroupID, defaultGroupName, allowedGroups, err := s.repo.ConnectionGroupPolicyTx(tx, providerID)
if err != nil {
return err
}
if !zammadConnectionAllowsGroupSnapshot(&models.ZammadConnection{
- DefaultGroupID: defaultGroupID, DefaultGroupName: defaultGroupName, AllowedGroupIDs: allowedGroupIDs,
+ DefaultGroupID: defaultGroupID, DefaultGroupName: defaultGroupName, AllowedGroups: allowedGroups,
}, groupID, groupName) {
return ErrZammadTicketGroupPolicyChanged
}
@@ -1774,26 +1768,20 @@ func resolveZammadOwnerName(ctx context.Context, client *zammad.Client, ticket *
return ""
}
-func allowedZammadGroups(connection *models.ZammadConnection, groups []models.ZammadGroup) []models.ZammadGroup {
- allowed := make([]models.ZammadGroup, 0, len(groups))
- for _, group := range groups {
- 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)
- }
+func allowedZammadGroups(connection *models.ZammadConnection) []models.ZammadGroup {
+ refs := effectiveZammadGroupRefs(connection)
+ if len(refs) == 0 && connection.DefaultGroupID > 0 {
+ refs = []models.ZammadGroupRef{{ID: connection.DefaultGroupID, Name: connection.DefaultGroupName}}
+ }
+ allowed := make([]models.ZammadGroup, 0, len(refs))
+ for _, group := range refs {
+ allowed = append(allowed, models.ZammadGroup{ID: group.ID, Name: group.Name, Active: true})
}
return allowed
}
-func allowedZammadGroup(connection *models.ZammadConnection, groups []models.ZammadGroup, groupID int) (models.ZammadGroup, error) {
- for _, group := range allowedZammadGroups(connection, groups) {
+func allowedZammadGroup(connection *models.ZammadConnection, groupID int) (models.ZammadGroup, error) {
+ for _, group := range allowedZammadGroups(connection) {
if group.ID == groupID {
return group, nil
}
@@ -1801,18 +1789,19 @@ func allowedZammadGroup(connection *models.ZammadConnection, groups []models.Zam
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) {
+func (s *ZammadService) requireAllowedTicketGroup(_ context.Context, connection *models.ZammadConnection, _ *zammad.Client, ticket *zammad.Ticket, _ []models.ZammadGroup) (models.ZammadGroup, error) {
if ticket == nil || ticket.GroupID <= 0 {
return models.ZammadGroup{}, zammadValidationError("Zammad ticket group is missing or invalid")
}
- if knownGroups == nil {
- var err error
- knownGroups, err = client.Groups(ctx)
- if err != nil {
- return models.ZammadGroup{}, err
- }
+ return allowedZammadGroup(connection, ticket.GroupID)
+}
+
+func zammadRuntimeMetadata(ctx context.Context, connection *models.ZammadConnection, client *zammad.Client) (*models.ZammadConnectionMetadata, error) {
+ states, err := client.States(ctx)
+ if err != nil {
+ return nil, err
}
- return allowedZammadGroup(connection, knownGroups, ticket.GroupID)
+ return &models.ZammadConnectionMetadata{Groups: allowedZammadGroups(connection), States: states}, nil
}
func zammadCreationOutcomeUncertain(err error) bool {
@@ -2038,11 +2027,11 @@ func (s *ZammadService) clientForConnection(ctx context.Context, connection *mod
}
func (s *ZammadService) resolveStateName(ctx context.Context, client *zammad.Client, stateID int) string {
- metadata, err := client.Metadata(ctx)
+ states, err := client.States(ctx)
if err != nil {
return ""
}
- for _, state := range metadata.States {
+ for _, state := range states {
if state.ID == stateID {
return state.Name
}
@@ -2054,6 +2043,9 @@ func validateNewZammadConnection(req models.CreateZammadConnectionRequest, actor
if hasNonPositiveIDs(req.ClosedStateIDs) {
return nil, zammadValidationError("closed_state_ids must contain positive IDs")
}
+ if err := validateZammadGroupRefs(req.AllowedGroups); err != nil {
+ return nil, err
+ }
if hasNonPositiveIDs(req.AllowedGroupIDs) {
return nil, zammadValidationError("allowed_group_ids must contain positive IDs")
}
@@ -2080,12 +2072,16 @@ func validateNewZammadConnection(req models.CreateZammadConnectionRequest, actor
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),
+ AllowedGroups: normalizeZammadGroupRefs(req.AllowedGroups),
DefaultCustomer: strings.TrimSpace(req.DefaultCustomer), CorrelationField: correlationField,
ClosedStateIDs: normalizePositiveIDs(req.ClosedStateIDs), CompletionStatusID: req.CompletionStatusID,
AppliesToAllWorkspaces: appliesAll, WorkspaceIDs: normalizePositiveIDs(req.WorkspaceIDs),
CreatedBy: &actorID,
}
+ if len(connection.AllowedGroups) == 0 && len(req.AllowedGroupIDs) > 0 {
+ connection.AllowedGroups = legacyZammadGroupRefs(req.AllowedGroupIDs, connection.DefaultGroupID, connection.DefaultGroupName)
+ }
+ syncZammadDefaultGroupName(connection)
connection.AuthMethod = req.AuthMethod
if connection.AuthMethod == "" {
connection.AuthMethod = models.ZammadAuthMethodAPIToken
@@ -2125,8 +2121,15 @@ func validateZammadConnection(connection *models.ZammadConnection) error {
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")
+ allowedGroups := effectiveZammadGroupRefs(connection)
+ if connection.DefaultGroupID > 0 && len(allowedGroups) > 0 {
+ defaultGroup, ok := zammadGroupRefByID(allowedGroups, connection.DefaultGroupID)
+ if !ok {
+ return zammadValidationError("default Zammad group must be included in allowed_groups")
+ }
+ if connection.DefaultGroupName != "" && defaultGroup.Name != "" && connection.DefaultGroupName != defaultGroup.Name {
+ return zammadValidationError("default Zammad group name must match allowed_groups")
+ }
}
if connection.CompletionStatusID != nil && *connection.CompletionStatusID <= 0 {
return zammadValidationError("completion_status_id must be positive")
@@ -2171,7 +2174,7 @@ func zammadOAuthRedirectURI(publicBaseURL string) (string, error) {
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.Path = strings.TrimRight(parsed.Path, "/") + "/api/integrations/oauth/system/zammad/callback"
parsed.RawPath = ""
return parsed.String(), nil
}
@@ -2192,6 +2195,83 @@ func normalizePositiveIDs(ids []int) []int {
return out
}
+func validateZammadGroupRefs(groups []models.ZammadGroupRef) error {
+ seen := make(map[int]struct{}, len(groups))
+ for _, group := range groups {
+ if group.ID <= 0 {
+ return zammadValidationError("allowed_groups must contain positive IDs")
+ }
+ if strings.TrimSpace(group.Name) == "" {
+ return zammadValidationError("allowed_groups must contain a name for every group")
+ }
+ if _, exists := seen[group.ID]; exists {
+ return zammadValidationError("allowed_groups must not contain duplicate IDs")
+ }
+ seen[group.ID] = struct{}{}
+ }
+ return nil
+}
+
+func normalizeZammadGroupRefs(groups []models.ZammadGroupRef) []models.ZammadGroupRef {
+ seen := make(map[int]struct{}, len(groups))
+ out := make([]models.ZammadGroupRef, 0, len(groups))
+ for _, group := range groups {
+ if group.ID <= 0 {
+ continue
+ }
+ if _, exists := seen[group.ID]; exists {
+ continue
+ }
+ seen[group.ID] = struct{}{}
+ out = append(out, models.ZammadGroupRef{ID: group.ID, Name: strings.TrimSpace(group.Name)})
+ }
+ return out
+}
+
+func legacyZammadGroupRefs(groupIDs []int, defaultGroupID int, defaultGroupName string) []models.ZammadGroupRef {
+ groups := make([]models.ZammadGroupRef, 0, len(groupIDs))
+ for _, groupID := range normalizePositiveIDs(groupIDs) {
+ name := ""
+ if groupID == defaultGroupID {
+ name = strings.TrimSpace(defaultGroupName)
+ }
+ groups = append(groups, models.ZammadGroupRef{ID: groupID, Name: name})
+ }
+ return groups
+}
+
+func effectiveZammadGroupRefs(connection *models.ZammadConnection) []models.ZammadGroupRef {
+ if connection == nil {
+ return nil
+ }
+ return connection.AllowedGroups
+}
+
+func effectiveZammadSnapshotGroupRefs(snapshot *repository.ZammadConnectionMutationSnapshot) []models.ZammadGroupRef {
+ if snapshot == nil {
+ return nil
+ }
+ return snapshot.AllowedGroups
+}
+
+func syncZammadDefaultGroupName(connection *models.ZammadConnection) {
+ if connection == nil || connection.DefaultGroupID <= 0 {
+ return
+ }
+ if group, ok := zammadGroupRefByID(effectiveZammadGroupRefs(connection), connection.DefaultGroupID); ok && strings.TrimSpace(group.Name) != "" {
+ connection.DefaultGroupName = strings.TrimSpace(group.Name)
+ }
+}
+
+func zammadGroupRefByID(groups []models.ZammadGroupRef, groupID int) (models.ZammadGroupRef, bool) {
+ for _, group := range groups {
+ if group.ID == groupID {
+ return group, true
+ }
+ }
+ return models.ZammadGroupRef{}, false
+}
+
func hasNonPositiveIDs(ids []int) bool {
for _, id := range ids {
if id <= 0 {
diff --git a/internal/services/zammad_service_test.go b/internal/services/zammad_service_test.go
index 37ed3c2db..0b4b161d1 100644
--- a/internal/services/zammad_service_test.go
+++ b/internal/services/zammad_service_test.go
@@ -44,6 +44,18 @@ type fakeZammadTransport struct {
putRerouteGroupID int
}
+func zammadTestGroupRefs(ids ...int) []models.ZammadGroupRef {
+ groups := make([]models.ZammadGroupRef, 0, len(ids))
+ for _, id := range ids {
+ name := "Support"
+ if id != 7 {
+ name = "Escalations"
+ }
+ groups = append(groups, models.ZammadGroupRef{ID: id, Name: name})
+ }
+ return groups
+}
+
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()
@@ -465,8 +477,8 @@ func TestZammadUpdateTicketLinkValidatesOwnerForEffectiveGroupAndPersistsSnapsho
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 {
+ allowedGroups := zammadTestGroupRefs(7, 8)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &allowedGroups}); err != nil {
t.Fatal(err)
}
stateID, groupID, ownerID := 4, 8, 99
@@ -493,8 +505,8 @@ func TestZammadTicketSnapshotBlocksConcurrentGroupPolicyNarrowing(t *testing.T)
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 {
+ initialAllowedGroups := zammadTestGroupRefs(7, 8)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &initialAllowedGroups}); err != nil {
t.Fatal(err)
}
@@ -513,8 +525,8 @@ func TestZammadTicketSnapshotBlocksConcurrentGroupPolicyNarrowing(t *testing.T)
updateErr <- err
}()
<-arrived
- narrowedAllowedGroups := []int{7}
- if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroupIDs: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) {
+ narrowedAllowedGroups := zammadTestGroupRefs(7)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) {
t.Fatalf("group policy changed while remote update was pending: %v", err)
}
close(release)
@@ -541,8 +553,8 @@ func TestZammadExpiredUpdateLeaseReloadsGroupPolicyBeforeRemoteWrite(t *testing.
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 {
+ initialAllowed := zammadTestGroupRefs(7, 8)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &initialAllowed}); err != nil {
t.Fatal(err)
}
arrived := make(chan struct{}, 1)
@@ -563,8 +575,8 @@ func TestZammadExpiredUpdateLeaseReloadsGroupPolicyBeforeRemoteWrite(t *testing.
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 {
+ narrowed := zammadTestGroupRefs(7)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &narrowed}); err != nil {
t.Fatalf("group policy could not change after synthetic lease expiry: %v", err)
}
puts := f.transport.putCount()
@@ -581,8 +593,8 @@ func TestZammadTicketCreationBlocksGroupNarrowingAcrossRemoteReroute(t *testing.
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 {
+ initialAllowedGroups := zammadTestGroupRefs(7, 8)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &initialAllowedGroups}); err != nil {
t.Fatal(err)
}
arrived := make(chan struct{}, 1)
@@ -603,8 +615,8 @@ func TestZammadTicketCreationBlocksGroupNarrowingAcrossRemoteReroute(t *testing.
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) {
+ narrowedAllowedGroups := zammadTestGroupRefs(7)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) {
t.Fatalf("group policy changed while rerouted ticket creation was incomplete: %v", err)
}
close(release)
@@ -618,8 +630,8 @@ func TestZammadIncompleteReservationAllowsPolicyExpansionAndCorrelationRecovery(
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 {
+ initialAllowedGroups := zammadTestGroupRefs(7)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &initialAllowedGroups}); err != nil {
t.Fatal(err)
}
@@ -633,12 +645,12 @@ func TestZammadIncompleteReservationAllowsPolicyExpansionAndCorrelationRecovery(
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 {
+ expandedAllowedGroups := zammadTestGroupRefs(7, 8)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &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) {
+ narrowedAllowedGroups := zammadTestGroupRefs(7)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) {
t.Fatalf("group policy narrowing was allowed while reservation was incomplete: %v", err)
}
@@ -656,7 +668,7 @@ func TestZammadIncompleteReservationAllowsPolicyExpansionAndCorrelationRecovery(
}
func TestZammadGroupPolicyNarrows(t *testing.T) {
- currentNumeric := &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7, AllowedGroupIDs: []int{7, 8}}
+ currentNumeric := &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7, AllowedGroups: zammadTestGroupRefs(7, 8)}
currentNameOnly := &repository.ZammadConnectionMutationSnapshot{DefaultGroupName: "Support"}
tests := []struct {
name string
@@ -666,26 +678,26 @@ func TestZammadGroupPolicyNarrows(t *testing.T) {
}{
{
name: "numeric expansion", current: currentNumeric,
- proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroupIDs: []int{7, 8, 9}},
+ proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroups: zammadTestGroupRefs(7, 8, 9)},
},
{
name: "numeric expansion preserves name-only default through allowlist",
current: &repository.ZammadConnectionMutationSnapshot{
- DefaultGroupName: "Support", AllowedGroupIDs: []int{7, 8},
+ DefaultGroupName: "Support", AllowedGroups: zammadTestGroupRefs(7, 8),
},
- proposed: &models.ZammadConnection{DefaultGroupName: "Support", AllowedGroupIDs: []int{7, 8, 9}},
+ proposed: &models.ZammadConnection{DefaultGroupName: "Support", AllowedGroups: zammadTestGroupRefs(7, 8, 9)},
},
{
name: "numeric narrowing", current: currentNumeric,
- proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroupIDs: []int{7}}, narrows: true,
+ proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroups: zammadTestGroupRefs(7)}, narrows: true,
},
{
name: "old default remains allowed", current: &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7},
- proposed: &models.ZammadConnection{DefaultGroupID: 8, AllowedGroupIDs: []int{7, 8}},
+ proposed: &models.ZammadConnection{DefaultGroupID: 8, AllowedGroups: zammadTestGroupRefs(7, 8)},
},
{
name: "old default excluded", current: &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7},
- proposed: &models.ZammadConnection{DefaultGroupID: 8, AllowedGroupIDs: []int{8}}, narrows: true,
+ proposed: &models.ZammadConnection{DefaultGroupID: 8, AllowedGroups: zammadTestGroupRefs(8)}, narrows: true,
},
{
name: "same name-only policy", current: currentNameOnly,
@@ -693,7 +705,7 @@ func TestZammadGroupPolicyNarrows(t *testing.T) {
},
{
name: "name-only changed to IDs", current: currentNameOnly,
- proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroupIDs: []int{7}}, narrows: true,
+ proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroups: zammadTestGroupRefs(7)}, narrows: true,
},
}
for _, tt := range tests {
@@ -710,8 +722,8 @@ func TestZammadExistingTicketLinkBlocksGroupNarrowingAcrossRemoteReroute(t *test
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 {
+ initialAllowedGroups := zammadTestGroupRefs(7, 8)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &initialAllowedGroups}); err != nil {
t.Fatal(err)
}
arrived := make(chan struct{}, 1)
@@ -732,8 +744,8 @@ func TestZammadExistingTicketLinkBlocksGroupNarrowingAcrossRemoteReroute(t *test
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) {
+ narrowedAllowedGroups := zammadTestGroupRefs(7)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) {
t.Fatalf("group policy changed while rerouted existing-ticket link was incomplete: %v", err)
}
close(release)
@@ -760,6 +772,42 @@ func TestZammadUnlinkClearsOnlyExactRemoteCorrelationWithoutDeletingTicket(t *te
}
}
+func TestZammadLocalDetachRemovesLinksWithoutContactingUnavailableUpstream(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.resetRequests()
+ f.transport.getStatus = http.StatusServiceUnavailable
+ f.transport.putError = errors.New("synthetic upstream outage")
+
+ detached, err := f.service.DetachTicketLinkLocally(link.ID)
+ if err != nil {
+ t.Fatalf("local detach failed while upstream was unavailable: %v", err)
+ }
+ if detached.ID != link.ID {
+ t.Fatalf("local detach returned the wrong link: got %q want %q", detached.ID, link.ID)
+ }
+ requests, _ := f.transport.counts()
+ if requests != 0 {
+ t.Fatalf("local detach contacted Zammad %d times", requests)
+ }
+ if f.transport.ticket["windshift_item_key"] != link.CorrelationKey {
+ t.Fatalf("local detach unexpectedly changed remote correlation: %#v", f.transport.ticket)
+ }
+ if _, err := f.service.GetTicketLink(link.ID); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("local detach retained typed 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 != 0 {
+ t.Fatalf("local detach retained generic link: count=%d err=%v", genericLinkCount, err)
+ }
+ if _, err := NewItemCRUDService(f.db).Delete(f.item1); err != nil {
+ t.Fatalf("item remained protected after local detach: %v", err)
+ }
+}
+
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})
@@ -790,7 +838,7 @@ func TestZammadUnlinkStillRequiresWorkspaceScope(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if _, err := f.db.ExecWrite("DELETE FROM zammad_connection_workspaces WHERE provider_id = ?", f.connection.ProviderID); err != nil {
+ if _, err := f.db.ExecWrite("DELETE FROM action_credential_workspaces WHERE credential_id = ?", f.connection.CredentialID); err != nil {
t.Fatal(err)
}
if _, err := f.service.UnlinkTicket(context.Background(), link.ID); !errors.Is(err, ErrCredentialScopeMismatch) {
@@ -835,7 +883,7 @@ func TestZammadLinkedItemDeletionRequiresExplicitUnlink(t *testing.T) {
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) {
+ if _, err := crud.Delete(f.item1); !errors.Is(err, ErrItemHasProtectedIntegrationLinks) {
t.Fatalf("linked item deletion returned %v", err)
}
if _, err := repository.NewItemRepository(f.db).FindByID(f.item1); err != nil {
@@ -870,7 +918,7 @@ func TestZammadCascadeDeletionIsBlockedByLinkedDescendant(t *testing.T) {
t.Fatal(err)
}
- if _, err := NewItemCRUDService(f.db).Delete(f.item1); !errors.Is(err, ErrItemHasZammadTicketLinks) {
+ if _, err := NewItemCRUDService(f.db).Delete(f.item1); !errors.Is(err, ErrItemHasProtectedIntegrationLinks) {
t.Fatalf("cascade with linked descendant returned %v", err)
}
itemRepo := repository.NewItemRepository(f.db)
@@ -890,7 +938,7 @@ func TestZammadLinkedItemsBlockWorkspaceDeletion(t *testing.T) {
}
workspaceService := NewWorkspaceService(f.db)
- if err := workspaceService.Delete(f.workspace1); !errors.Is(err, ErrWorkspaceHasZammadTicketLinks) {
+ if err := workspaceService.Delete(f.workspace1); !errors.Is(err, ErrWorkspaceHasProtectedIntegrationLinks) {
t.Fatalf("workspace deletion with linked item returned %v", err)
}
if exists, err := repository.NewWorkspaceRepository(f.db).Exists(f.workspace1); err != nil || !exists {
@@ -929,7 +977,7 @@ func TestZammadLinkedPersonalWorkspaceBlocksUserOffboardingBeforeMutation(t *tes
t.Fatal(err)
}
- if _, err := OffboardUser(f.db, f.actorID, nil); !errors.Is(err, ErrUserOffboardingHasZammadTicketLinks) {
+ if _, err := OffboardUser(f.db, f.actorID, nil); !errors.Is(err, ErrUserOffboardingHasProtectedIntegrationLinks) {
t.Fatalf("offboarding with linked personal item returned %v", err)
}
var afterEmail, afterUsername string
@@ -1133,8 +1181,8 @@ func TestZammadSyncPersistsRemoteGroupAndOwner(t *testing.T) {
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 {
+ allowedGroups := zammadTestGroupRefs(7, 8)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &allowedGroups}); err != nil {
t.Fatal(err)
}
synced, err := f.service.SyncTicketLink(context.Background(), link.ID)
@@ -1154,10 +1202,10 @@ func TestZammadSyncRejectsTicketMovedToDisallowedGroupBeforeSnapshotOrCompletion
if err != nil {
t.Fatal(err)
}
- allowedGroups := []int{7}
+ allowedGroups := zammadTestGroupRefs(7)
closedStates := []int{4}
if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
- AllowedGroupIDs: &allowedGroups,
+ AllowedGroups: &allowedGroups,
ClosedStateIDs: &closedStates,
CompletionStatusID: &f.doneStatus,
}); err != nil {
@@ -1468,9 +1516,9 @@ func TestZammadConnectionRoutingAndGroupsCannotStrandExistingTicketLinks(t *test
}
newDefaultGroupID := 8
newDefaultGroupName := "Escalations"
- newAllowedGroups := []int{8}
+ newAllowedGroups := zammadTestGroupRefs(8)
if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
- DefaultGroupID: &newDefaultGroupID, DefaultGroupName: &newDefaultGroupName, AllowedGroupIDs: &newAllowedGroups,
+ DefaultGroupID: &newDefaultGroupID, DefaultGroupName: &newDefaultGroupName, AllowedGroups: &newAllowedGroups,
}); err == nil {
t.Fatal("group scope excluded an existing ticket link")
}
@@ -1507,9 +1555,9 @@ func TestZammadReservationRejectsStaleConnectionAndItemSnapshots(t *testing.T) {
staleConnection := *f.connection
newDefaultGroupID := 8
newDefaultGroupName := "Escalations"
- newAllowedGroups := []int{8}
+ newAllowedGroups := zammadTestGroupRefs(8)
if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
- DefaultGroupID: &newDefaultGroupID, DefaultGroupName: &newDefaultGroupName, AllowedGroupIDs: &newAllowedGroups,
+ DefaultGroupID: &newDefaultGroupID, DefaultGroupName: &newDefaultGroupName, AllowedGroups: &newAllowedGroups,
}); err != nil {
t.Fatal(err)
}
@@ -1752,6 +1800,9 @@ func TestZammadDisabledConnectionCanStillBeTestedByAdmin(t *testing.T) {
if len(metadata.Groups) != 1 || metadata.Groups[0].Name != "Support" {
t.Fatalf("unexpected connection test metadata: %#v", metadata)
}
+ if metadata.GroupCatalogVerified || metadata.CorrelationFieldVerified {
+ t.Fatalf("least-privilege checks must report admin-only metadata as unverified: %#v", metadata)
+ }
}
func TestZammadConnectionValidationRequiresUsableDefaultGroup(t *testing.T) {
@@ -1767,7 +1818,7 @@ func TestZammadConnectionValidationRequiresUsableDefaultGroup(t *testing.T) {
_, 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},
+ APIToken: "token", DefaultGroupID: 7, AllowedGroups: zammadTestGroupRefs(8),
DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
}, f.actorID)
if !errors.As(err, &validationErr) {
@@ -1775,15 +1826,40 @@ func TestZammadConnectionValidationRequiresUsableDefaultGroup(t *testing.T) {
}
}
-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},
- }}
+func TestZammadLegacyGroupCatalogCanBeSavedUnchanged(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{
+ Slug: "legacy-groups", Name: "Legacy groups", BaseURL: "https://legacy-groups.example.test",
+ APIToken: "token", DefaultGroupID: 7, DefaultGroupName: "Support", AllowedGroupIDs: []int{7, 8},
+ DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ }, f.actorID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(connection.AllowedGroups) != 2 || connection.AllowedGroups[1].ID != 8 || connection.AllowedGroups[1].Name != "" {
+ t.Fatalf("unexpected legacy catalog: %#v", connection.AllowedGroups)
+ }
+ name := "Legacy groups renamed"
+ updated, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{
+ Name: &name, AllowedGroups: &connection.AllowedGroups,
+ })
+ if err != nil {
+ t.Fatalf("unchanged legacy group catalog blocked an unrelated update: %v", err)
+ }
+ if updated.Name != name || len(updated.AllowedGroups) != 2 || updated.AllowedGroups[1].Name != "" {
+ t.Fatalf("legacy group catalog was not preserved: %#v", updated)
+ }
+}
+
+func TestZammadMetadataValidatesOnlyRuntimeTicketStates(t *testing.T) {
+ connection := &models.ZammadConnection{DefaultGroupName: "Support", AllowedGroups: zammadTestGroupRefs(8), ClosedStateIDs: []int{4}}
+ metadata := &models.ZammadConnectionMetadata{
+ Groups: []models.ZammadGroup{{ID: 99, Name: "Different", Active: true}},
+ States: []models.ZammadState{{ID: 2, Name: "open", 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)
+ if err := validateZammadStates(connection, metadata); !errors.As(err, &validationErr) {
+ t.Fatalf("missing configured closed state was accepted: %v", err)
}
}
@@ -2182,7 +2258,7 @@ func TestZammadManualRefreshReportsBusyWhenAnotherWorkerOwnsLease(t *testing.T)
}
}
-func TestZammadSyncRejectsConfiguredGroupAfterItBecomesInactive(t *testing.T) {
+func TestZammadSyncUsesPersistedGroupPolicyWithoutAdminLookup(t *testing.T) {
workflow := &fakeZammadWorkflow{}
f := newZammadServiceFixture(t, workflow)
workflow.db = f.db
@@ -2197,13 +2273,12 @@ func TestZammadSyncRejectsConfiguredGroupAfterItBecomesInactive(t *testing.T) {
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)
+ if err != nil {
+ t.Fatalf("persisted group policy required an admin-only lookup: %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)
+ if getErr != nil || workflow.writes != 1 || !stored.CompletionApplied {
+ t.Fatalf("persisted group policy did not complete normal sync: link=%#v writes=%d err=%v", stored, workflow.writes, getErr)
}
}
@@ -2242,8 +2317,8 @@ func TestZammadDueSyncUsesAgeThreshold(t *testing.T) {
t.Fatal(err)
}
requests, _ := f.transport.counts()
- if requests != 2 {
- t.Fatalf("expected ticket and active-group refresh, got %d transport requests", requests)
+ if requests != 1 {
+ t.Fatalf("expected only the ticket refresh, got %d transport requests", requests)
}
}
@@ -2257,7 +2332,7 @@ func TestZammadDueSyncPollsFreshLinkOnNextSchedulerTick(t *testing.T) {
t.Fatal(err)
}
requests, _ := f.transport.counts()
- if requests != 2 {
+ if requests != 1 {
t.Fatalf("fresh link missed the next scheduler tick: requests=%d", requests)
}
}
@@ -2280,7 +2355,7 @@ func TestZammadSyncAllTicketLinksOverridesRetryDelay(t *testing.T) {
t.Fatalf("unexpected system refresh summary: %#v", summary)
}
requests, _ := f.transport.counts()
- if requests != 2 {
+ if requests != 1 {
t.Fatalf("system refresh did not override the retry delay: requests=%d", requests)
}
}
@@ -2396,7 +2471,7 @@ func TestZammadOAuthConnectionStoresConnectionTokensAndConsumesStateOnce(t *test
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" {
+ if parsed.Path != "/oauth/authorize" || parsed.Query().Get("scope") != "full" || parsed.Query().Get("redirect_uri") != "https://windshift.example.test/api/integrations/oauth/system/zammad/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 {
@@ -2594,6 +2669,54 @@ func TestZammadOAuthCallbackCannotCommitAfterConfigurationReset(t *testing.T) {
}
}
+func TestZammadOAuthCallbackCannotCommitAfterConnectionIsDisabled(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-disable", Name: "OAuth callback disable", BaseURL: "https://callback-disable.example.test",
+ AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret",
+ DefaultGroupName: "Support", 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
+ }))
+ done := make(chan error, 1)
+ go func() {
+ _, callbackErr := f.service.CompleteOAuth(context.Background(), parsed.Query().Get("state"), "code", "https://windshift.example.test")
+ done <- callbackErr
+ }()
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("OAuth callback did not reach token exchange")
+ }
+ disabled := false
+ if _, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil {
+ t.Fatal(err)
+ }
+ close(release)
+ if err := <-done; !errors.Is(err, ErrZammadOAuthSuperseded) {
+ t.Fatalf("disabled connection accepted OAuth credentials: %v", err)
+ }
+ if _, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test"); err == nil {
+ t.Fatal("disabled connection allowed a new OAuth attempt")
+ }
+}
+
func TestZammadOAuthCallbackPreservesConcurrentNameAndScopeUpdate(t *testing.T) {
f := newZammadServiceFixture(t, nil)
f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests"))
@@ -3083,7 +3206,8 @@ 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", DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
+ OAuthClientID: "client", OAuthClientSecret: "secret", DefaultGroupID: 7, DefaultGroupName: "Support",
+ AllowedGroups: zammadTestGroupRefs(7), DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1},
}, f.actorID)
if err != nil {
t.Fatal(err)
From ed63482a72f76ce79fdae97e7a917334796341d0 Mon Sep 17 00:00:00 2001
From: Optic00
Date: Tue, 1 Sep 2026 07:39:41 +0200
Subject: [PATCH 08/10] fix migrated Zammad group labels
---
.../lib/features/items/ZammadItemPanel.svelte | 5 ++-
.../lib/features/items/zammadPanelContext.js | 4 ++
.../features/items/zammadPanelContext.spec.js | 9 ++++
frontend/src/lib/locales/ar/zammad.js | 2 +-
frontend/src/lib/locales/de/zammad.js | 3 +-
frontend/src/lib/locales/en/zammad.js | 3 +-
frontend/src/lib/locales/es/zammad.js | 2 +-
frontend/src/lib/locales/pt-BR/zammad.js | 2 +-
frontend/src/lib/locales/ru/zammad.js | 3 +-
frontend/src/lib/locales/zh-CN/zammad.js | 2 +-
.../settings/ZammadConnectionManager.spec.js | 43 +++++++++++++++++++
.../settings/ZammadConnectionManager.svelte | 28 ++++++++----
12 files changed, 89 insertions(+), 17 deletions(-)
diff --git a/frontend/src/lib/features/items/ZammadItemPanel.svelte b/frontend/src/lib/features/items/ZammadItemPanel.svelte
index 99c846ad2..ad6418644 100644
--- a/frontend/src/lib/features/items/ZammadItemPanel.svelte
+++ b/frontend/src/lib/features/items/ZammadItemPanel.svelte
@@ -25,6 +25,7 @@
import {
isCurrentZammadMetadataRequest,
isCurrentZammadPanelContext,
+ isUsableZammadGroup,
} from './zammadPanelContext.js';
let { itemId, workspaceId, canEdit = false } = $props();
@@ -65,7 +66,7 @@
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 editGroups = $derived(editMetadata.groups.filter(isUsableZammadGroup));
let editStates = $derived(editMetadata.states.filter((state) => state.active !== false));
let editOwnerOptions = $derived([
{ value: '1', label: t('zammad.unassignedOwner') },
@@ -143,7 +144,7 @@
function usableGroups(connection, loadedMetadata) {
const allowedIds = (connection?.allowed_groups || []).map((group) => group.id);
return loadedMetadata.groups.filter((group) => {
- if (group.active === false) return false;
+ if (!isUsableZammadGroup(group)) return false;
if (allowedIds.length > 0) return allowedIds.includes(group.id);
return group.id === connection?.default_group_id || group.name === connection?.default_group_name;
});
diff --git a/frontend/src/lib/features/items/zammadPanelContext.js b/frontend/src/lib/features/items/zammadPanelContext.js
index 90aacbc38..b838c09b8 100644
--- a/frontend/src/lib/features/items/zammadPanelContext.js
+++ b/frontend/src/lib/features/items/zammadPanelContext.js
@@ -28,3 +28,7 @@ export function isCurrentZammadMetadataRequest(
dialogMode === 'create'
);
}
+
+export function isUsableZammadGroup(group) {
+ return group?.active !== false && Boolean(group?.name?.trim());
+}
diff --git a/frontend/src/lib/features/items/zammadPanelContext.spec.js b/frontend/src/lib/features/items/zammadPanelContext.spec.js
index e1f1675f9..53a614344 100644
--- a/frontend/src/lib/features/items/zammadPanelContext.spec.js
+++ b/frontend/src/lib/features/items/zammadPanelContext.spec.js
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
isCurrentZammadMetadataRequest,
isCurrentZammadPanelContext,
+ isUsableZammadGroup,
} from './zammadPanelContext.js';
describe('Zammad item panel context guards', () => {
@@ -41,3 +42,11 @@ describe('Zammad metadata request guards', () => {
);
});
});
+
+describe('Zammad group choices', () => {
+ it('offers only active groups with a verified persisted name', () => {
+ expect(isUsableZammadGroup({ id: 2, name: 'Windshift', active: true })).toBe(true);
+ expect(isUsableZammadGroup({ id: 3, name: ' ', active: true })).toBe(false);
+ expect(isUsableZammadGroup({ id: 4, name: 'Legacy', active: false })).toBe(false);
+ });
+});
diff --git a/frontend/src/lib/locales/ar/zammad.js b/frontend/src/lib/locales/ar/zammad.js
index 2a505b89d..7f3f1c0c6 100644
--- a/frontend/src/lib/locales/ar/zammad.js
+++ b/frontend/src/lib/locales/ar/zammad.js
@@ -5,7 +5,7 @@ export default { zammad: {
connectionDeleted: 'تم حذف اتصال Zammad', deleteConnection: 'حذف اتصال Zammad', deleteConnectionConfirm: 'هل تريد حذف «{name}»؟ يجب أولًا إلغاء ربط التذاكر الحالية.',
deleteConnectionFailed: 'تعذر حذف اتصال Zammad', testConnection: 'اختبار الاتصال', connectionTestSucceeded: 'نجح اتصال Zammad', connectionTestFailed: 'فشل اتصال Zammad',
refreshAllTickets: 'مزامنة جميع التذاكر', refreshAllTicketsStarted: 'بدأت مزامنة Zammad على مستوى النظام', refreshAllTicketsAlreadyRunning: 'مزامنة Zammad على مستوى النظام قيد التشغيل بالفعل', refreshAllTicketsFailed: 'تعذرت مزامنة تذاكر Zammad على مستوى النظام',
- metadataSummary: 'تتوفر {groups} مجموعات و{states} حالات تذاكر', slug: 'المعرّف المختصر', baseUrl: 'الرابط الأساسي', apiToken: 'رمز API',
+ 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 مطلوبة',
diff --git a/frontend/src/lib/locales/de/zammad.js b/frontend/src/lib/locales/de/zammad.js
index 77ebf02cc..09c20f44d 100644
--- a/frontend/src/lib/locales/de/zammad.js
+++ b/frontend/src/lib/locales/de/zammad.js
@@ -20,7 +20,7 @@ export default { zammad: {
refreshAllTicketsStarted: 'Die systemweite Zammad-Synchronisierung wurde gestartet',
refreshAllTicketsAlreadyRunning: 'Die systemweite Zammad-Synchronisierung läuft bereits',
refreshAllTicketsFailed: 'Die Zammad-Tickets konnten nicht systemweit synchronisiert werden',
- metadataSummary: '{groups} Gruppen und {states} Ticketstatus verfügbar',
+ metadataSummary: '{groups} konfigurierte Gruppen und {states} verfügbare Ticketstatus',
slug: 'Kurzname',
baseUrl: 'Basis-URL',
apiToken: 'API-Token',
@@ -46,6 +46,7 @@ export default { zammad: {
defaultCustomer: 'Standardkunde für Tickets',
defaultGroup: 'Standardgruppe',
allowedGroups: 'Erlaubte Ticketgruppen',
+ unverifiedGroup: 'Nicht verifizierte Gruppe (ID {id})',
allowedGroupIds: 'Erlaubte Gruppen (ID:Name)',
correlationField: 'Korrelationsfeld',
correlationFieldHint: 'Dieses Textfeld in Zammad-Tickets anlegen, damit Wiederholungen ein bereits erstelltes Ticket finden.',
diff --git a/frontend/src/lib/locales/en/zammad.js b/frontend/src/lib/locales/en/zammad.js
index d617612eb..1fe537001 100644
--- a/frontend/src/lib/locales/en/zammad.js
+++ b/frontend/src/lib/locales/en/zammad.js
@@ -20,7 +20,7 @@ export default { zammad: {
refreshAllTicketsStarted: 'System-wide Zammad synchronization started',
refreshAllTicketsAlreadyRunning: 'System-wide Zammad synchronization is already running',
refreshAllTicketsFailed: 'Failed to sync Zammad tickets system-wide',
- metadataSummary: '{groups} groups and {states} ticket states available',
+ metadataSummary: '{groups} configured groups and {states} ticket states available',
slug: 'Slug',
baseUrl: 'Base URL',
apiToken: 'API token',
@@ -46,6 +46,7 @@ export default { zammad: {
defaultCustomer: 'Default ticket customer',
defaultGroup: 'Default group',
allowedGroups: 'Allowed ticket groups',
+ unverifiedGroup: 'Unverified group (ID {id})',
allowedGroupIds: 'Allowed groups (ID:name)',
correlationField: 'Correlation field',
correlationFieldHint: 'Create this text field on Zammad tickets so retries can find an already-created ticket.',
diff --git a/frontend/src/lib/locales/es/zammad.js b/frontend/src/lib/locales/es/zammad.js
index c4cd9775e..f41383811 100644
--- a/frontend/src/lib/locales/es/zammad.js
+++ b/frontend/src/lib/locales/es/zammad.js
@@ -8,7 +8,7 @@ export default { 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ó',
refreshAllTickets: 'Sincronizar todos los tickets', refreshAllTicketsStarted: 'Se inició la sincronización de Zammad en todo el sistema', refreshAllTicketsAlreadyRunning: 'La sincronización de Zammad en todo el sistema ya está en curso', refreshAllTicketsFailed: 'No se pudieron sincronizar los tickets de Zammad en todo el sistema',
- metadataSummary: '{groups} grupos y {states} estados de ticket disponibles', slug: 'Identificador', baseUrl: 'URL base', apiToken: 'Token de API',
+ metadataSummary: '{groups} grupos configurados 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',
diff --git a/frontend/src/lib/locales/pt-BR/zammad.js b/frontend/src/lib/locales/pt-BR/zammad.js
index 126d31daa..c6ccab747 100644
--- a/frontend/src/lib/locales/pt-BR/zammad.js
+++ b/frontend/src/lib/locales/pt-BR/zammad.js
@@ -8,7 +8,7 @@ export default { 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',
refreshAllTickets: 'Sincronizar todos os tickets', refreshAllTicketsStarted: 'A sincronização do Zammad em todo o sistema foi iniciada', refreshAllTicketsAlreadyRunning: 'A sincronização do Zammad em todo o sistema já está em execução', refreshAllTicketsFailed: 'Não foi possível sincronizar os tickets do Zammad em todo o sistema',
- metadataSummary: '{groups} grupos e {states} estados de ticket disponíveis', slug: 'Identificador', baseUrl: 'URL base', apiToken: 'Token da API',
+ metadataSummary: '{groups} grupos configurados 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',
diff --git a/frontend/src/lib/locales/ru/zammad.js b/frontend/src/lib/locales/ru/zammad.js
index b527ba166..eaa4e2939 100644
--- a/frontend/src/lib/locales/ru/zammad.js
+++ b/frontend/src/lib/locales/ru/zammad.js
@@ -20,7 +20,7 @@ export default { zammad: {
refreshAllTicketsStarted: 'Запущена общесистемная синхронизация Zammad',
refreshAllTicketsAlreadyRunning: 'Общесистемная синхронизация Zammad уже выполняется',
refreshAllTicketsFailed: 'Не удалось синхронизировать заявки Zammad во всей системе',
- metadataSummary: 'Доступно групп: {groups}, состояний заявок: {states}',
+ metadataSummary: 'Настроено групп: {groups}, доступно состояний заявок: {states}',
slug: 'Краткое имя',
baseUrl: 'Базовый URL',
apiToken: 'Токен API',
@@ -46,6 +46,7 @@ export default { zammad: {
defaultCustomer: 'Клиент заявки по умолчанию',
defaultGroup: 'Группа по умолчанию',
allowedGroups: 'Разрешённые группы заявок',
+ unverifiedGroup: 'Непроверенная группа (ID {id})',
allowedGroupIds: 'ID разрешённых групп Zammad',
correlationField: 'Поле корреляции',
correlationFieldHint: 'Создайте это текстовое поле в заявках Zammad, чтобы при повторных попытках находить уже созданную заявку.',
diff --git a/frontend/src/lib/locales/zh-CN/zammad.js b/frontend/src/lib/locales/zh-CN/zammad.js
index 4b999eca8..74c513851 100644
--- a/frontend/src/lib/locales/zh-CN/zammad.js
+++ b/frontend/src/lib/locales/zh-CN/zammad.js
@@ -5,7 +5,7 @@ export default { zammad: {
connectionDeleted: '已删除 Zammad 连接', deleteConnection: '删除 Zammad 连接', deleteConnectionConfirm: '删除“{name}”?必须先取消所有现有工单关联。',
deleteConnectionFailed: '无法删除 Zammad 连接', testConnection: '测试连接', connectionTestSucceeded: 'Zammad 连接测试成功', connectionTestFailed: 'Zammad 连接失败',
refreshAllTickets: '同步所有工单', refreshAllTicketsStarted: '已启动系统范围的 Zammad 同步', refreshAllTicketsAlreadyRunning: '系统范围的 Zammad 同步已在运行', refreshAllTicketsFailed: '无法在系统范围内同步 Zammad 工单',
- metadataSummary: '可用群组 {groups} 个,工单状态 {states} 个', slug: '短标识', baseUrl: '基础网址', apiToken: 'API 令牌',
+ 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',
diff --git a/frontend/src/lib/settings/ZammadConnectionManager.spec.js b/frontend/src/lib/settings/ZammadConnectionManager.spec.js
index c468d29b7..69fe4917a 100644
--- a/frontend/src/lib/settings/ZammadConnectionManager.spec.js
+++ b/frontend/src/lib/settings/ZammadConnectionManager.spec.js
@@ -118,4 +118,47 @@ describe('ZammadConnectionManager', () => {
)
);
});
+
+ it('labels migrated groups without names and excludes them from the default-group choices', async () => {
+ mocks.getConnections.mockResolvedValue([
+ {
+ ...connection,
+ default_group_id: 2,
+ allowed_groups: [
+ { id: 2, name: 'Windshift' },
+ { id: 99, name: '' },
+ ],
+ },
+ ]);
+ mocks.testConnection.mockResolvedValue({
+ metadata: {
+ groups: [
+ { id: 2, name: 'Windshift' },
+ { id: 99, name: '' },
+ ],
+ states: [],
+ correlation_field_verified: false,
+ group_catalog_verified: false,
+ },
+ });
+
+ 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');
+ expect(within(dialog).getByText('zammad.unverifiedGroup')).toBeInTheDocument();
+ const defaultGroupSelect = /** @type {HTMLSelectElement} */ (
+ within(dialog).getAllByRole('combobox')[0]
+ );
+ const defaultGroupOptions = Array.from(defaultGroupSelect.options).map(
+ (option) => option.textContent
+ );
+ expect(defaultGroupOptions).toEqual(['Windshift']);
+ });
});
diff --git a/frontend/src/lib/settings/ZammadConnectionManager.svelte b/frontend/src/lib/settings/ZammadConnectionManager.svelte
index 06fec558d..2d1e0cc2f 100644
--- a/frontend/src/lib/settings/ZammadConnectionManager.svelte
+++ b/frontend/src/lib/settings/ZammadConnectionManager.svelte
@@ -170,9 +170,9 @@
const result = await api.zammadConnections.test(connection.id);
metadataByConnection = { ...metadataByConnection, [connection.id]: result.metadata };
successToast(t('zammad.connectionTestSucceeded'));
- if (result.metadata?.group_catalog_verified === false) {
- warningToast(t('zammad.groupCatalogNotVerified'));
- }
+ if (result.metadata?.group_catalog_verified === false) {
+ warningToast(t('zammad.groupCatalogNotVerified'));
+ }
if (result.metadata?.correlation_field_verified === false) {
warningToast(t('zammad.correlationFieldNotVerified'));
}
@@ -277,6 +277,16 @@
form.default_group_name = group?.name || '';
}
+ function hasKnownGroupName(group) {
+ return Boolean(group?.name?.trim());
+ }
+
+ function groupLabel(group) {
+ return hasKnownGroupName(group)
+ ? group.name.trim()
+ : t('zammad.unverifiedGroup', { id: group.id });
+ }
+
function serializeAllowedGroups(groups) {
return groups.map((group) => `${group.id}:${group.name}`).join(', ');
}
@@ -355,9 +365,9 @@
{#if metadataByConnection[connection.id].correlation_field_verified === false}
{t('zammad.correlationFieldNotVerified')}
{/if}
- {#if metadataByConnection[connection.id].group_catalog_verified === false}
- {t('zammad.groupCatalogNotVerified')}
- {/if}
+ {#if metadataByConnection[connection.id].group_catalog_verified === false}
+ {t('zammad.groupCatalogNotVerified')}
+ {/if}
{/if}
{/each}
@@ -429,7 +439,9 @@
({ value: String(group.id), label: group.name }))}
+ options={metadataByConnection[editing.id].groups
+ .filter(hasKnownGroupName)
+ .map((group) => ({ value: String(group.id), label: groupLabel(group) }))}
/>
@@ -439,7 +451,7 @@
id={`zammad-group-${group.id}`}
checked={form.allowed_groups.some((entry) => entry.id === group.id)}
onchange={(checked) => toggleAllowedGroup(group.id, checked)}
- label={group.name}
+ label={groupLabel(group)}
size="small"
/>
{/each}
From 0d7a68b148c647f7a92e966992511f447e4208bd Mon Sep 17 00:00:00 2001
From: Optic00
Date: Tue, 1 Sep 2026 16:37:28 +0200
Subject: [PATCH 09/10] remove legacy Zammad group discovery
---
.../settings/ZammadConnectionManager.spec.js | 26 +++++++++++---
.../settings/ZammadConnectionManager.svelte | 34 ++++++++++++++-----
internal/integrations/zammad/client.go | 30 ++--------------
internal/integrations/zammad/client_test.go | 16 ++++-----
internal/services/zammad_service_test.go | 19 +++--------
5 files changed, 61 insertions(+), 64 deletions(-)
diff --git a/frontend/src/lib/settings/ZammadConnectionManager.spec.js b/frontend/src/lib/settings/ZammadConnectionManager.spec.js
index 69fe4917a..afd7e5624 100644
--- a/frontend/src/lib/settings/ZammadConnectionManager.spec.js
+++ b/frontend/src/lib/settings/ZammadConnectionManager.spec.js
@@ -119,11 +119,12 @@ describe('ZammadConnectionManager', () => {
);
});
- it('labels migrated groups without names and excludes them from the default-group choices', async () => {
+ it('labels and lets administrators repair migrated groups without names', async () => {
mocks.getConnections.mockResolvedValue([
{
...connection,
- default_group_id: 2,
+ default_group_id: 99,
+ default_group_name: '',
allowed_groups: [
{ id: 2, name: 'Windshift' },
{ id: 99, name: '' },
@@ -152,13 +153,30 @@ describe('ZammadConnectionManager', () => {
await fireEvent.click(editButton);
const dialog = screen.getByRole('dialog');
- expect(within(dialog).getByText('zammad.unverifiedGroup')).toBeInTheDocument();
+ expect(within(dialog).getAllByText('zammad.unverifiedGroup')).toHaveLength(2);
const defaultGroupSelect = /** @type {HTMLSelectElement} */ (
within(dialog).getAllByRole('combobox')[0]
);
const defaultGroupOptions = Array.from(defaultGroupSelect.options).map(
(option) => option.textContent
);
- expect(defaultGroupOptions).toEqual(['Windshift']);
+ expect(defaultGroupOptions).toEqual(['Windshift', 'zammad.unverifiedGroup']);
+ expect(defaultGroupSelect.value).toBe('99');
+
+ await fireEvent.input(within(dialog).getByLabelText('settings.groups.groupName'), {
+ target: { value: 'Legacy Support' },
+ });
+ await fireEvent.click(within(dialog).getByRole('button', { name: 'common.update' }));
+
+ await waitFor(() =>
+ expect(mocks.updateConnection).toHaveBeenCalledWith(
+ 'zammad-dev',
+ expect.objectContaining({
+ default_group_id: 99,
+ default_group_name: 'Legacy Support',
+ allowed_groups: expect.arrayContaining([{ id: 99, name: 'Legacy Support' }]),
+ })
+ )
+ );
});
});
diff --git a/frontend/src/lib/settings/ZammadConnectionManager.svelte b/frontend/src/lib/settings/ZammadConnectionManager.svelte
index 2d1e0cc2f..2e5a3ae1d 100644
--- a/frontend/src/lib/settings/ZammadConnectionManager.svelte
+++ b/frontend/src/lib/settings/ZammadConnectionManager.svelte
@@ -269,6 +269,13 @@
: form.allowed_groups.filter((entry) => entry.id !== id);
}
+ function setAllowedGroupName(id, name) {
+ form.allowed_groups = form.allowed_groups.map((entry) =>
+ entry.id === id ? { ...entry, name } : entry,
+ );
+ if (Number(form.default_group_id) === id) form.default_group_name = name;
+ }
+
function selectDefaultGroup(value) {
form.default_group_id = value;
const group = metadataByConnection[editing?.id]?.groups?.find(
@@ -440,20 +447,31 @@
value={String(form.default_group_id)}
onchange={selectDefaultGroup}
options={metadataByConnection[editing.id].groups
- .filter(hasKnownGroupName)
+ .filter((group) => hasKnownGroupName(group) || group.id === Number(form.default_group_id))
.map((group) => ({ value: String(group.id), label: groupLabel(group) }))}
/>
diff --git a/internal/integrations/zammad/client.go b/internal/integrations/zammad/client.go
index 6c7f98c9c..2a13aa6e6 100644
--- a/internal/integrations/zammad/client.go
+++ b/internal/integrations/zammad/client.go
@@ -86,20 +86,8 @@ type objectAttribute struct {
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
- }
- activeStates, err := c.States(ctx)
- if err != nil {
- return nil, err
- }
- return &models.ZammadConnectionMetadata{Groups: activeGroups, States: activeStates}, nil
-}
-
-// States returns the active ticket states. Unlike Groups, Zammad permits this
-// endpoint for ordinary ticket agents, so it is safe for runtime use.
+// States returns the active ticket states. Zammad permits this endpoint for
+// ordinary ticket agents, so it is safe for runtime use.
func (c *Client) States(ctx context.Context) ([]models.ZammadState, error) {
states := []models.ZammadState{}
if err := c.getJSON(ctx, "/api/v1/ticket_states", &states); err != nil {
@@ -114,20 +102,6 @@ func (c *Client) States(ctx context.Context) ([]models.ZammadState, error) {
return 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 {
diff --git a/internal/integrations/zammad/client_test.go b/internal/integrations/zammad/client_test.go
index 9cab5ff2d..c3103a7bb 100644
--- a/internal/integrations/zammad/client_test.go
+++ b/internal/integrations/zammad/client_test.go
@@ -35,7 +35,7 @@ func (t httpTestTransport) Do(ctx context.Context, method, targetURL string, bod
return &Response{StatusCode: resp.StatusCode, Body: responseBody}, nil
}
-func TestClientMetadataCorrelationAndTicketCreation(t *testing.T) {
+func TestClientStatesCorrelationAndTicketCreation(t *testing.T) {
t.Parallel()
const token = "synthetic-secret-token"
postCount := 0
@@ -45,10 +45,8 @@ func TestClientMetadataCorrelationAndTicketCreation(t *testing.T) {
}
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}]`))
+ _, _ = w.Write([]byte(`[{"id":4,"name":"closed","state_type_id":5,"active":true},{"id":5,"name":"inactive","active":false}]`))
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":
@@ -68,12 +66,12 @@ func TestClientMetadataCorrelationAndTicketCreation(t *testing.T) {
defer server.Close()
client := NewClient(server.URL, token, httpTestTransport{client: server.Client()})
- metadata, err := client.Metadata(context.Background())
+ states, err := client.States(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 len(states) != 1 || states[0].Name != "closed" {
+ t.Fatalf("unexpected states: %#v", states)
}
if err := client.ValidateCorrelationField(context.Background(), "windshift_item_key"); err != nil {
t.Fatal(err)
@@ -98,7 +96,7 @@ func TestClientRedactsAPIErrorBodyAndToken(t *testing.T) {
defer server.Close()
client := NewClient(server.URL, token, httpTestTransport{client: server.Client()})
- _, err := client.Metadata(context.Background())
+ _, err := client.States(context.Background())
if err == nil {
t.Fatal("expected authentication error")
}
@@ -118,7 +116,7 @@ func TestClientHonorsContextTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
client := NewClient(server.URL, "token", httpTestTransport{client: server.Client()})
- _, err := client.Metadata(ctx)
+ _, err := client.States(ctx)
if err == nil {
t.Fatal("expected timeout")
}
diff --git a/internal/services/zammad_service_test.go b/internal/services/zammad_service_test.go
index 0b4b161d1..0ec8f32a4 100644
--- a/internal/services/zammad_service_test.go
+++ b/internal/services/zammad_service_test.go
@@ -134,11 +134,6 @@ func (f *fakeZammadTransport) Do(_ context.Context, method, targetURL string, bo
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
@@ -3228,9 +3223,6 @@ func TestZammadOAuthTestSkipsAdminOnlyCorrelationFieldCheck(t *testing.T) {
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)
@@ -3247,9 +3239,6 @@ func TestZammadAPITokenTestSkipsAdminOnlyCorrelationFieldCheck(t *testing.T) {
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)
@@ -3260,7 +3249,7 @@ func TestZammadAPITokenTestSkipsAdminOnlyCorrelationFieldCheck(t *testing.T) {
func TestZammadSafeTransportHonorsAllowLocalConnections(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/v1/groups" {
+ if r.URL.Path != "/api/v1/ticket_states" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`[]`))
@@ -3270,11 +3259,11 @@ func TestZammadSafeTransportHonorsAllowLocalConnections(t *testing.T) {
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 {
+ if _, err := transport.Do(context.Background(), http.MethodGet, server.URL+"/api/v1/ticket_states", 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) {
+ if _, err := transport.Do(context.Background(), http.MethodGet, server.URL+"/api/v1/ticket_states", nil, nil); !errors.Is(err, utils.ErrBlockedSSRFAddr) {
t.Fatalf("ALLOW_LOCAL_CONNECTIONS=false did not block local Zammad target: %v", err)
}
}
@@ -3298,7 +3287,7 @@ func TestZammadClientsUseExactlyOneExpectedAuthorizationScheme(t *testing.T) {
}))},
} {
t.Run(testCase.name, func(t *testing.T) {
- if _, err := testCase.client.Groups(context.Background()); err != nil {
+ if _, err := testCase.client.States(context.Background()); err != nil {
t.Fatal(err)
}
})
From e0c85bb1771fe0bc1b326471c2c770b85fade82e Mon Sep 17 00:00:00 2001
From: Optic00
Date: Tue, 1 Sep 2026 17:37:34 +0200
Subject: [PATCH 10/10] fix: defer Zammad client creation
---
internal/services/zammad_service.go | 25 +++++++++++++++++--------
1 file changed, 17 insertions(+), 8 deletions(-)
diff --git a/internal/services/zammad_service.go b/internal/services/zammad_service.go
index e226dae72..ef4e8ea5c 100644
--- a/internal/services/zammad_service.go
+++ b/internal/services/zammad_service.go
@@ -1095,10 +1095,11 @@ func (s *ZammadService) CreateTicket(ctx context.Context, itemID, actorID int, r
if err != nil {
return nil, err
}
- connection, client, err := s.client(ctx, req.ConnectionID, item.WorkspaceID)
+ connection, err := s.connection(req.ConnectionID, item.WorkspaceID)
if err != nil {
return nil, err
}
+ var client *zammad.Client
groupID, groupName := req.GroupID, ""
if groupID == 0 {
groupID = connection.DefaultGroupID
@@ -1965,22 +1966,30 @@ func (s *ZammadService) completeWindshiftItem(ctx context.Context, link *models.
}
func (s *ZammadService) client(ctx context.Context, id string, workspaceID int) (*models.ZammadConnection, *zammad.Client, error) {
- connection, err := s.repo.GetConnection(id)
+ connection, err := s.connection(id, workspaceID)
if err != nil {
return nil, nil, err
}
- available, err := s.repo.IsConnectionAvailableToWorkspace(id, workspaceID)
+ client, err := s.clientForConnection(ctx, connection, workspaceID)
if err != nil {
return nil, nil, err
}
- if !available {
- return nil, nil, ErrCredentialScopeMismatch
+ return connection, client, nil
+}
+
+func (s *ZammadService) connection(id string, workspaceID int) (*models.ZammadConnection, error) {
+ connection, err := s.repo.GetConnection(id)
+ if err != nil {
+ return nil, err
}
- client, err := s.clientForConnection(ctx, connection, workspaceID)
+ available, err := s.repo.IsConnectionAvailableToWorkspace(id, workspaceID)
if err != nil {
- return nil, nil, err
+ return nil, err
}
- return connection, client, nil
+ if !available {
+ return nil, ErrCredentialScopeMismatch
+ }
+ return connection, nil
}
// clientForUnlink permits cleanup through a disabled connection while keeping