diff --git a/.gitignore b/.gitignore
index 40932ff86..cb03e2fc3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,10 @@ 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
+!frontend/src/lib/features/items/ZammadLinkResolver.spec.js
+!internal/repository/zammad_repository_test.go
# 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..6108fdea6
--- /dev/null
+++ b/docs/zammad-integration.md
@@ -0,0 +1,121 @@
+# 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}`, 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 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.
+
+Workspace and item routes are `GET /api/workspaces/{workspaceId}/zammad-connections`, `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/metadata`, and `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/owners?group_id={groupId}`.
+Ticket routes are `GET /api/items/{id}/zammad-links`, `GET /api/zammad-ticket-links/resolve/{correlationKey}`, `POST /api/items/{id}/zammad-tickets`, `POST /api/items/{id}/zammad-ticket-links`, `PUT /api/zammad-ticket-links/{linkId}`, `DELETE /api/zammad-ticket-links/{linkId}`, and `POST /api/zammad-ticket-links/{linkId}/refresh`.
+
+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/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.
+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_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.
+The field is the idempotency boundary for create retries and must be readable by the service agent.
+
+For a permission-checked return link from Zammad to the current Windshift item, configure this link template on the same attribute:
+
+```text
+https://windshift.example/zammad/#{ticket.windshift_item_key}
+```
+
+Replace the origin and optional path prefix with the browser-visible Windshift base URL.
+If the connection uses a different correlation field, replace `windshift_item_key` in the placeholder too.
+The resolver accepts the correlation value whether Zammad leaves it literal or percent-encodes it in the generated URL.
+The target keeps working after an item moves because Windshift resolves the stored correlation key to the current workspace and item.
+Opening the link requires a Windshift session and `item.view` permission; missing and unauthorized targets are both reported as unavailable.
+
+## 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 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 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.
+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.
+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.
+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..d03f9ee68 100644
--- a/frontend/src/lib/api/integrations.js
+++ b/frontend/src/lib/api/integrations.js
@@ -49,3 +49,51 @@ 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' }),
+ refreshAllTickets: () => fetchAPI('/admin/zammad-ticket-links/refresh', { 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`),
+ owners: (workspaceId, id, groupId) =>
+ fetchAPI(
+ `/workspaces/${workspaceId}/zammad-connections/${id}/owners?group_id=${encodeURIComponent(groupId)}`
+ ),
+};
+
+export const zammadTickets = {
+ resolve: (correlationKey) =>
+ fetchAPI(`/zammad-ticket-links/resolve/${encodeURIComponent(correlationKey)}`),
+ 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..4004432bd
--- /dev/null
+++ b/frontend/src/lib/api/integrations.spec.js
@@ -0,0 +1,157 @@
+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/integration-providers/connection-id/oauth/start');
+ expect(options.method).toBe('POST');
+ expect(options.body).toBeUndefined();
+ });
+});
+
+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();
+ });
+
+ it('resolves a Zammad correlation key to its current item', async () => {
+ const destination = { workspace_id: 7, item_id: 42 };
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(destination), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(zammadTickets.resolve('windshift:provider:TST-42')).resolves.toEqual(destination);
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ expect(fetchMock.mock.calls[0][0]).toBe(
+ '/api/zammad-ticket-links/resolve/windshift%3Aprovider%3ATST-42'
+ );
+ });
+
+ 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/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/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 { useEventListener } from 'runed';
+ 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';
+ import {
+ isCurrentZammadMetadataRequest,
+ isCurrentZammadPanelContext,
+ isUsableZammadGroup,
+ } from './zammadPanelContext.js';
+
+ 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 loadVersion = 0;
+ let contextVersion = 0;
+ let metadataVersion = 0;
+ let editVersion = 0;
+ let ownersVersion = 0;
+
+ let usableConnections = $derived(connections.filter(isConnectionUsable));
+ let unavailableConnections = $derived(connections.filter((connection) => !isConnectionUsable(connection)));
+ 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') },
+ ...editOwners
+ .filter((owner) => Number(owner.id) !== 1)
+ .map((owner) => ({ value: String(owner.id), label: owner.name })),
+ ]);
+ let editPayloadAvailable = $derived(Object.keys(editPayload()).length > 0);
+
+ $effect(() => {
+ const currentItemId = itemId;
+ const currentWorkspaceId = workspaceId;
+ const currentVersion = ++contextVersion;
+ resetContext();
+ void load(currentItemId, currentWorkspaceId, currentVersion);
+ });
+
+ useEventListener(() => window, 'item-zammad-links-changed', (/** @type {CustomEvent<{itemId?: number|string}>} */ event) => {
+ const id = event?.detail?.itemId;
+ if (id == null || String(id) !== String(itemId)) return;
+ void load(itemId, workspaceId, contextVersion);
+ });
+
+ function isCurrentContext(version, currentItemId = itemId, currentWorkspaceId = workspaceId) {
+ return isCurrentZammadPanelContext(
+ version,
+ contextVersion,
+ currentItemId,
+ itemId,
+ currentWorkspaceId,
+ workspaceId,
+ );
+ }
+
+ function resetContext() {
+ loadVersion += 1;
+ metadataVersion += 1;
+ editVersion += 1;
+ ownersVersion += 1;
+ connections = [];
+ links = [];
+ metadata = { groups: [], states: [] };
+ editMetadata = { groups: [], states: [] };
+ editOwners = [];
+ loading = Boolean(itemId && workspaceId);
+ loadingMetadata = false;
+ loadingEditMetadata = false;
+ loadingEditOwners = false;
+ creating = false;
+ linking = false;
+ savingEdit = false;
+ refreshingId = null;
+ removingId = null;
+ showCreate = false;
+ showEdit = false;
+ dialogMode = 'create';
+ selectedConnectionId = '';
+ selectedGroupId = '';
+ ticketNumber = '';
+ editingLink = null;
+ selectedEditStateId = '';
+ selectedEditGroupId = '';
+ selectedEditOwnerId = '1';
+ initialEdit = { stateId: '', groupId: '', ownerId: '1' };
+ error = '';
+ formError = '';
+ editError = '';
+ }
+
+ 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_groups || []).map((group) => group.id);
+ return loadedMetadata.groups.filter((group) => {
+ 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;
+ });
+ }
+
+ 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(currentItemId = itemId, currentWorkspaceId = workspaceId, version = contextVersion) {
+ const currentVersion = ++loadVersion;
+ if (!currentItemId || !currentWorkspaceId) {
+ connections = [];
+ links = [];
+ loading = false;
+ error = '';
+ return;
+ }
+
+ loading = true;
+ error = '';
+ try {
+ const [loadedConnections, loadedLinks] = await Promise.all([
+ api.zammadConnections.forWorkspace(currentWorkspaceId),
+ api.zammadTickets.forItem(currentItemId),
+ ]);
+ if (currentVersion !== loadVersion || !isCurrentContext(version, currentItemId, currentWorkspaceId)) return;
+ connections = loadedConnections;
+ links = loadedLinks;
+ } catch (err) {
+ if (currentVersion !== loadVersion || !isCurrentContext(version, currentItemId, currentWorkspaceId)) return;
+ console.error('Failed to load Zammad links:', err);
+ error = t('zammad.loadLinksFailed');
+ } finally {
+ if (currentVersion === loadVersion && isCurrentContext(version, currentItemId, currentWorkspaceId)) loading = false;
+ }
+ }
+
+ async function openCreateDialog() {
+ dialogMode = 'create';
+ formError = '';
+ ticketNumber = '';
+ selectedConnectionId = usableConnections[0]?.id || '';
+ selectedGroupId = '';
+ showCreate = true;
+ await loadMetadata(contextVersion);
+ }
+
+ function openLinkDialog() {
+ metadataVersion += 1;
+ loadingMetadata = false;
+ metadata = { groups: [], states: [] };
+ dialogMode = 'link';
+ formError = '';
+ ticketNumber = '';
+ selectedConnectionId = usableConnections[0]?.id || '';
+ selectedGroupId = '';
+ showCreate = true;
+ }
+
+ function closeCreateDialog() {
+ if (creating || linking) return;
+ metadataVersion += 1;
+ loadingMetadata = false;
+ showCreate = false;
+ formError = '';
+ }
+
+ async function handleCreateConnectionChange() {
+ formError = '';
+ if (dialogMode === 'create') await loadMetadata(contextVersion);
+ }
+
+ async function loadMetadata(version = contextVersion) {
+ const requestVersion = ++metadataVersion;
+ const connectionId = selectedConnectionId;
+ const connection = selectedConnection();
+ if (!connection) return;
+ const isCurrentRequest = () =>
+ isCurrentContext(version) &&
+ isCurrentZammadMetadataRequest(
+ requestVersion,
+ metadataVersion,
+ connectionId,
+ selectedConnectionId,
+ showCreate,
+ dialogMode,
+ );
+ loadingMetadata = true;
+ try {
+ const loadedMetadata = await api.zammadConnections.metadata(workspaceId, connection.id);
+ if (!isCurrentRequest()) return;
+ 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) {
+ if (!isCurrentRequest()) return;
+ console.error('Failed to load Zammad metadata:', err);
+ formError = t('zammad.loadMetadataFailed');
+ metadata = { groups: [], states: [] };
+ selectedGroupId = '';
+ } finally {
+ if (isCurrentRequest()) {
+ loadingMetadata = false;
+ }
+ }
+ }
+
+ async function createTicket() {
+ const version = contextVersion;
+ const currentItemId = itemId;
+ const group = metadata.groups.find((entry) => entry.id === Number(selectedGroupId));
+ if (!selectedConnectionId || !group) return;
+ creating = true;
+ formError = '';
+ try {
+ const link = await api.zammadTickets.create(currentItemId, {
+ connection_id: selectedConnectionId,
+ group_id: group.id,
+ });
+ if (!isCurrentContext(version, currentItemId)) return;
+ replaceLink(link);
+ showCreate = false;
+ successToast(link.sync_state === 'linked' ? t('zammad.ticketCreated') : t('zammad.ticketCreationStarted'));
+ } catch (err) {
+ if (!isCurrentContext(version, currentItemId)) return;
+ console.error('Failed to create Zammad ticket:', err);
+ formError = err.message || t('zammad.ticketCreateFailed');
+ errorToast(t('zammad.ticketCreateFailed'));
+ await load(currentItemId, workspaceId, version);
+ } finally {
+ if (isCurrentContext(version, currentItemId)) creating = false;
+ }
+ }
+
+ async function linkExistingTicket() {
+ const version = contextVersion;
+ const currentItemId = itemId;
+ const trimmedTicketNumber = ticketNumber.trim();
+ if (!selectedConnectionId || !trimmedTicketNumber) return;
+ linking = true;
+ formError = '';
+ try {
+ const link = await api.zammadTickets.link(currentItemId, {
+ connection_id: selectedConnectionId,
+ ticket_number: trimmedTicketNumber,
+ });
+ if (!isCurrentContext(version, currentItemId)) return;
+ replaceLink(link);
+ showCreate = false;
+ successToast(t('zammad.ticketLinked'));
+ } catch (err) {
+ if (!isCurrentContext(version, currentItemId)) return;
+ console.error('Failed to link existing Zammad ticket:', err);
+ formError = err.message || t('zammad.ticketLinkFailed');
+ errorToast(t('zammad.ticketLinkFailed'));
+ } finally {
+ if (isCurrentContext(version, currentItemId)) linking = false;
+ }
+ }
+
+ async function refresh(link) {
+ if (!link.ticket_id) return;
+ const version = contextVersion;
+ const currentItemId = itemId;
+ refreshingId = link.id;
+ try {
+ const updated = await api.zammadTickets.refresh(link.id);
+ if (!isCurrentContext(version, currentItemId)) return;
+ replaceLink(updated);
+ successToast(t('zammad.ticketRefreshed'));
+ } catch (err) {
+ if (!isCurrentContext(version, currentItemId)) return;
+ console.error('Failed to refresh Zammad ticket:', err);
+ errorToast(t('zammad.ticketRefreshFailed'));
+ await load(currentItemId, workspaceId, version);
+ } finally {
+ if (isCurrentContext(version, currentItemId)) refreshingId = null;
+ }
+ }
+
+ async function openEditDialog(link) {
+ const version = ++editVersion;
+ const currentItemId = itemId;
+ const currentWorkspaceId = workspaceId;
+ 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(currentWorkspaceId, connection.id);
+ if (version !== editVersion || !isCurrentContext(contextVersion, currentItemId, currentWorkspaceId) || !showEdit || editingLink?.id !== link.id) return;
+ editMetadata = { ...loadedMetadata, groups: usableGroups(connection, loadedMetadata) };
+ if (!editGroups.some((group) => String(group.id) === selectedEditGroupId)) {
+ selectedEditGroupId = String(editGroups[0]?.id || '');
+ }
+ await loadEditOwners();
+ if (version !== editVersion || !isCurrentContext(contextVersion, currentItemId, currentWorkspaceId) || !showEdit || editingLink?.id !== link.id) return;
+ initialEdit = {
+ stateId: selectedEditStateId,
+ groupId: selectedEditGroupId,
+ ownerId: selectedEditOwnerId,
+ };
+ } catch (err) {
+ if (version !== editVersion || !isCurrentContext(contextVersion, currentItemId, currentWorkspaceId)) return;
+ console.error('Failed to load Zammad ticket metadata:', err);
+ editError = t('zammad.loadMetadataFailed');
+ } finally {
+ if (version === editVersion && isCurrentContext(contextVersion, currentItemId, currentWorkspaceId)) loadingEditMetadata = false;
+ }
+ }
+
+ function closeEditDialog() {
+ if (savingEdit) return;
+ showEdit = false;
+ editError = '';
+ editingLink = null;
+ }
+
+ async function loadEditOwners() {
+ const version = editVersion;
+ const requestVersion = ++ownersVersion;
+ const currentItemId = itemId;
+ const currentWorkspaceId = workspaceId;
+ const connection = selectedEditConnection();
+ if (!connection || !selectedEditGroupId) return;
+ loadingEditOwners = true;
+ try {
+ const loadedOwners = await api.zammadConnections.owners(
+ currentWorkspaceId,
+ connection.id,
+ Number(selectedEditGroupId),
+ );
+ if (requestVersion !== ownersVersion || version !== editVersion || !isCurrentContext(contextVersion, currentItemId, currentWorkspaceId) || !showEdit) return;
+ editOwners = loadedOwners;
+ if (!editOwners.some((owner) => String(owner.id) === selectedEditOwnerId)) {
+ selectedEditOwnerId = '1';
+ }
+ } catch (err) {
+ if (requestVersion !== ownersVersion || version !== editVersion || !isCurrentContext(contextVersion, currentItemId, currentWorkspaceId)) return;
+ console.error('Failed to load Zammad owners:', err);
+ editError = t('zammad.loadOwnersFailed');
+ editOwners = [];
+ selectedEditOwnerId = '1';
+ } finally {
+ if (requestVersion === ownersVersion && version === editVersion && isCurrentContext(contextVersion, currentItemId, currentWorkspaceId)) 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 context = contextVersion;
+ const version = editVersion;
+ const currentItemId = itemId;
+ const payload = editPayload();
+ if (!editingLink || Object.keys(payload).length === 0) return;
+ savingEdit = true;
+ editError = '';
+ try {
+ const updated = await api.zammadTickets.update(editingLink.id, payload);
+ if (version !== editVersion || !isCurrentContext(context, currentItemId)) return;
+ replaceLink(updated);
+ showEdit = false;
+ successToast(t('zammad.ticketUpdated'));
+ } catch (err) {
+ if (version !== editVersion || !isCurrentContext(context, currentItemId)) return;
+ console.error('Failed to update Zammad ticket:', err);
+ editError = err.message || t('zammad.ticketUpdateFailed');
+ errorToast(t('zammad.ticketUpdateFailed'));
+ } finally {
+ if (version === editVersion && isCurrentContext(context, currentItemId)) savingEdit = false;
+ }
+ }
+
+ async function removeLink(link) {
+ const version = contextVersion;
+ const currentItemId = itemId;
+ const currentWorkspaceId = workspaceId;
+ 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;
+ if (!isCurrentContext(version, currentItemId)) return;
+
+ removingId = link.id;
+ try {
+ await api.zammadTickets.delete(link.id);
+ if (!isCurrentContext(version, currentItemId, currentWorkspaceId)) return;
+ successToast(t('zammad.ticketLinkRemoved'));
+ await load(currentItemId, currentWorkspaceId, version);
+ } catch (err) {
+ if (!isCurrentContext(version, currentItemId, currentWorkspaceId)) return;
+ console.error('Failed to remove Zammad ticket link:', err);
+ errorToast(t('zammad.ticketLinkRemoveFailed'));
+ await load(currentItemId, currentWorkspaceId, version);
+ } finally {
+ if (isCurrentContext(version, currentItemId, currentWorkspaceId)) removingId = null;
+ }
+ }
+
+
+{#if loading || connections.length > 0 || links.length > 0}
+
+
+
+
+
+ {t('zammad.tickets')}
+
+ {#if usableConnections.length > 0 && canEdit}
+
+
+
+
+
+ {/if}
+
+
+ {#if loading}
+
+ {:else if error}
+
{error}
+ {:else}
+ {#if unavailableConnections.length > 0}
+
{t('zammad.connectionUnavailable')}
+ {/if}
+ {#if links.length === 0}
+
{t('zammad.noTickets')}
+ {:else}
+
+ {#each links as link}
+ {@const linkConnectionUsable = usableConnections.some((connection) => connection.id === link.connection_id)}
+
+
+
+ {#if link.ticket_url}
+
+ {t('zammad.ticketNumber', { number: link.ticket_number })}
+
+ {:else if link.ticket_number}
+
{t('zammad.ticketNumber', { number: link.ticket_number })}
+ {:else}
+
{t(`zammad.syncState.${link.sync_state}`)}
+ {/if}
+
+
{link.connection_name}
+
{t('zammad.status')}: {link.last_status_name || t('zammad.unknown')}
+
{t('zammad.group')}: {link.group_name || t('zammad.unknown')}
+
{t('zammad.owner')}: {link.owner_name || t('zammad.unassignedOwner')}
+
{link.last_synced_at ? t('zammad.lastSynced', { time: new Date(link.last_synced_at).toLocaleString() }) : t('zammad.notSynced')}
+
+
+ {#if canEdit}
+
+ {#if linkConnectionUsable && link.ticket_id && link.sync_state !== 'creating'}
+
+
+ {/if}
+
+
+ {/if}
+
+ {#if !link.ticket_id && link.sync_state !== 'linked'}
+
{syncActionHint(link)}
+ {/if}
+ {#if link.last_error}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+{/if}
+
+
+
+
+
+ {dialogMode === 'create' ? t('zammad.createTicketConfirm') : t('zammad.linkExistingTicketConfirm')}
+
+ {#if formError}
+
{formError}
+ {/if}
+
+ ({ value: connection.id, label: connection.name }))}
+ onchange={handleCreateConnectionChange}
+ />
+
+ {#if dialogMode === 'create'}
+
+ {#if loadingMetadata}
+
+ {:else}
+ ({ value: String(group.id), label: group.name }))}
+ disabled={metadata.groups.length === 0}
+ />
+ {/if}
+
+ {:else}
+
+
+
+ {/if}
+
+
+ {#if dialogMode === 'create'}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+ {#if editError}
+
{editError}
+ {/if}
+ {#if loadingEditMetadata}
+
+ {:else}
+
+ ({ value: String(state.id), label: state.name }))}
+ placeholder={t('zammad.leaveUnchanged')}
+ disabled={savingEdit || editStates.length === 0}
+ />
+
+
+ ({ value: String(group.id), label: group.name }))}
+ disabled={savingEdit || editGroups.length === 0}
+ onchange={changeEditGroup}
+ />
+
+
+ {#if loadingEditOwners}
+
+ {:else}
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+
diff --git a/frontend/src/lib/features/items/ZammadLinkResolver.spec.js b/frontend/src/lib/features/items/ZammadLinkResolver.spec.js
new file mode 100644
index 000000000..1b1487cac
--- /dev/null
+++ b/frontend/src/lib/features/items/ZammadLinkResolver.spec.js
@@ -0,0 +1,40 @@
+/** @vitest-environment jsdom */
+
+import '@testing-library/jest-dom/vitest';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import ZammadLinkResolver from './ZammadLinkResolver.svelte';
+
+const mocks = vi.hoisted(() => ({ resolve: vi.fn(), navigate: vi.fn() }));
+
+vi.mock('../../api.js', () => ({ api: { zammadTickets: { resolve: mocks.resolve } } }));
+vi.mock('../../router.js', () => ({ navigate: mocks.navigate }));
+vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key }));
+
+describe('ZammadLinkResolver', () => {
+ beforeEach(() => vi.clearAllMocks());
+ afterEach(cleanup);
+
+ it('replaces the resolver route with the current item destination', async () => {
+ mocks.resolve.mockResolvedValue({ workspace_id: 7, item_id: 42 });
+
+ render(ZammadLinkResolver, { correlationKey: 'windshift%3Aprovider%3ATST-42' });
+
+ await waitFor(() =>
+ expect(mocks.navigate).toHaveBeenCalledWith('/workspaces/7/items/42', { replace: true })
+ );
+ expect(mocks.resolve).toHaveBeenCalledWith('windshift:provider:TST-42');
+ });
+
+ it('keeps a retry action when the link cannot be resolved', async () => {
+ mocks.resolve.mockRejectedValueOnce(new Error('not found'));
+ mocks.resolve.mockResolvedValueOnce({ workspace_id: 7, item_id: 42 });
+
+ render(ZammadLinkResolver, { correlationKey: 'windshift:provider:TST-42' });
+
+ const retry = await screen.findByRole('button', { name: 'common.retry' });
+ expect(screen.getByText('zammad.returnLinkFailed')).toBeInTheDocument();
+ await fireEvent.click(retry);
+ await waitFor(() => expect(mocks.navigate).toHaveBeenCalledOnce());
+ });
+});
diff --git a/frontend/src/lib/features/items/ZammadLinkResolver.svelte b/frontend/src/lib/features/items/ZammadLinkResolver.svelte
new file mode 100644
index 000000000..d2e3bcb66
--- /dev/null
+++ b/frontend/src/lib/features/items/ZammadLinkResolver.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+ {#if failed}
+
{t('zammad.returnLinkFailed')}
+
+ {:else}
+
+
{t('zammad.returnLinkResolving')}
+ {/if}
+
+
diff --git a/frontend/src/lib/features/items/zammadPanelContext.js b/frontend/src/lib/features/items/zammadPanelContext.js
new file mode 100644
index 000000000..b838c09b8
--- /dev/null
+++ b/frontend/src/lib/features/items/zammadPanelContext.js
@@ -0,0 +1,34 @@
+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'
+ );
+}
+
+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
new file mode 100644
index 000000000..53a614344
--- /dev/null
+++ b/frontend/src/lib/features/items/zammadPanelContext.spec.js
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+import {
+ isCurrentZammadMetadataRequest,
+ isCurrentZammadPanelContext,
+ isUsableZammadGroup,
+} 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
+ );
+ });
+});
+
+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/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..7f3f1c0c6
--- /dev/null
+++ b/frontend/src/lib/locales/ar/zammad.js
@@ -0,0 +1,24 @@
+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',
+ refreshAllTickets: 'مزامنة جميع التذاكر', refreshAllTicketsStarted: 'بدأت مزامنة Zammad على مستوى النظام', refreshAllTicketsAlreadyRunning: 'مزامنة Zammad على مستوى النظام قيد التشغيل بالفعل', refreshAllTicketsFailed: 'تعذرت مزامنة تذاكر 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: 'تمنع أذونات حساب الخدمة المقيّدة التحقق التلقائي من حقل الربط. تأكد من وجوده في 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}', returnLinkResolving: 'جار فتح عنصر Windshift المرتبط...', returnLinkFailed: 'تعذر فتح عنصر Windshift المرتبط. ربما لم يعد موجودًا أو ليست لديك صلاحية الوصول إليه.',
+ 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..09c20f44d
--- /dev/null
+++ b/frontend/src/lib/locales/de/zammad.js
@@ -0,0 +1,109 @@
+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',
+ refreshAllTickets: 'Alle Tickets synchronisieren',
+ 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} konfigurierte Gruppen und {states} verfügbare Ticketstatus',
+ 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 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',
+ 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.',
+ 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: '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',
+ 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}',
+ 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',
+ 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..1fe537001
--- /dev/null
+++ b/frontend/src/lib/locales/en/zammad.js
@@ -0,0 +1,109 @@
+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',
+ refreshAllTickets: 'Sync all tickets',
+ refreshAllTicketsStarted: 'System-wide Zammad synchronization started',
+ refreshAllTicketsAlreadyRunning: 'System-wide Zammad synchronization is already running',
+ refreshAllTicketsFailed: 'Failed to sync Zammad tickets system-wide',
+ metadataSummary: '{groups} configured 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 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',
+ 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.',
+ 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 configured 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 itself remains in place.',
+ ticketLinkRemoved: 'Zammad ticket assignment removed',
+ 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',
+ 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..f41383811
--- /dev/null
+++ b/frontend/src/lib/locales/es/zammad.js
@@ -0,0 +1,30 @@
+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ó',
+ 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 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',
+ 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 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',
+ 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 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}', 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/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..c6ccab747
--- /dev/null
+++ b/frontend/src/lib/locales/pt-BR/zammad.js
@@ -0,0 +1,30 @@
+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',
+ 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 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',
+ 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 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',
+ 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 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}', 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/index.js b/frontend/src/lib/locales/ru/index.js
index 899aec36a..b896e299d 100644
--- a/frontend/src/lib/locales/ru/index.js
+++ b/frontend/src/lib/locales/ru/index.js
@@ -22,6 +22,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,
@@ -43,4 +44,5 @@ export default createLocale({
adminOperations,
agents,
dashboard,
+ zammad,
});
diff --git a/frontend/src/lib/locales/ru/zammad.js b/frontend/src/lib/locales/ru/zammad.js
new file mode 100644
index 000000000..eaa4e2939
--- /dev/null
+++ b/frontend/src/lib/locales/ru/zammad.js
@@ -0,0 +1,109 @@
+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 завершилась ошибкой',
+ refreshAllTickets: 'Синхронизировать все заявки',
+ refreshAllTicketsStarted: 'Запущена общесистемная синхронизация Zammad',
+ refreshAllTicketsAlreadyRunning: 'Общесистемная синхронизация Zammad уже выполняется',
+ refreshAllTicketsFailed: 'Не удалось синхронизировать заявки Zammad во всей системе',
+ metadataSummary: 'Настроено групп: {groups}, доступно состояний заявок: {states}',
+ slug: 'Краткое имя',
+ baseUrl: 'Базовый URL',
+ apiToken: 'Токен API',
+ apiTokenDescription: 'Использовать токен API Zammad для этого системного подключения.',
+ apiTokenAvailable: 'Токен API сохранён',
+ apiTokenMissing: 'Токен API отсутствует',
+ authMethod: 'Способ аутентификации',
+ oauth: 'OAuth',
+ oauthDescription: 'Авторизуйте это подключение через OAuth Zammad.',
+ oauthClientId: 'ID клиента OAuth',
+ oauthClientSecret: 'Секрет клиента OAuth',
+ secretStored: 'Надёжно сохранён - оставьте поле пустым, чтобы не изменять',
+ oauthPending: 'Ожидается авторизация OAuth',
+ oauthConnectedStatus: 'OAuth подключён',
+ oauthReauthorizationRequired: 'Требуется повторная авторизация OAuth',
+ connectOAuth: 'Подключить Zammad',
+ reauthorizeOAuth: 'Авторизовать повторно',
+ oauthConnected: 'OAuth Zammad подключён',
+ oauthConnectionFailed: 'Не удалось авторизоваться через OAuth Zammad',
+ oauthAuthorizationFailed: 'Не удалось запустить авторизацию OAuth Zammad',
+ correlationFieldNotVerified: 'Ограниченные права сервисной учётной записи не позволяют автоматически проверить поле корреляции. Перед созданием заявок убедитесь, что оно существует в Zammad.',
+ groupCatalogNotVerified: 'Ограниченные права сервисной учётной записи не позволяют автоматически проверить настроенные группы. Перед изменением политики групп проверьте в Zammad переименованные или удалённые группы.',
+ defaultCustomer: 'Клиент заявки по умолчанию',
+ defaultGroup: 'Группа по умолчанию',
+ allowedGroups: 'Разрешённые группы заявок',
+ unverifiedGroup: 'Непроверенная группа (ID {id})',
+ allowedGroupIds: 'ID разрешённых групп Zammad',
+ correlationField: 'Поле корреляции',
+ correlationFieldHint: 'Создайте это текстовое поле в заявках Zammad, чтобы при повторных попытках находить уже созданную заявку.',
+ completionStatus: 'Статус завершения в Windshift',
+ noAutomaticCompletion: 'Не завершать автоматически',
+ closedStates: 'Закрытые состояния Zammad',
+ closedStateIds: 'ID закрытых состояний 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}',
+ returnLinkResolving: 'Открывается связанный элемент Windshift...',
+ returnLinkFailed: 'Не удалось открыть связанный элемент Windshift. Возможно, он больше не существует или у вас нет доступа.',
+ syncState: {
+ pending: 'Ожидается создание заявки',
+ creating: 'Заявка создаётся',
+ linked: 'Заявка связана',
+ sync_failed: 'Ошибка синхронизации',
+ creation_uncertain: 'Результат создания неизвестен; повторные попытки только ищут существующую заявку',
+ },
+} };
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..74c513851
--- /dev/null
+++ b/frontend/src/lib/locales/zh-CN/zammad.js
@@ -0,0 +1,24 @@
+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 连接失败',
+ refreshAllTickets: '同步所有工单', refreshAllTicketsStarted: '已启动系统范围的 Zammad 同步', refreshAllTicketsAlreadyRunning: '系统范围的 Zammad 同步已在运行', refreshAllTicketsFailed: '无法在系统范围内同步 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: '受限的服务账号权限无法自动验证关联字段。请在创建工单前确认该字段已存在于 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}', 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/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.spec.js b/frontend/src/lib/settings/ZammadConnectionManager.spec.js
new file mode 100644
index 000000000..afd7e5624
--- /dev/null
+++ b/frontend/src/lib/settings/ZammadConnectionManager.spec.js
@@ -0,0 +1,182 @@
+/** @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_groups: [
+ { id: 2, name: 'Support' },
+ { id: 3, name: 'Escalations' },
+ ],
+ 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' })
+ )
+ );
+ });
+
+ it('labels and lets administrators repair migrated groups without names', async () => {
+ mocks.getConnections.mockResolvedValue([
+ {
+ ...connection,
+ default_group_id: 99,
+ default_group_name: '',
+ 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).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', '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
new file mode 100644
index 000000000..2e5a3ae1d
--- /dev/null
+++ b/frontend/src/lib/settings/ZammadConnectionManager.svelte
@@ -0,0 +1,554 @@
+
+
+
+
+ {#snippet actions()}
+
+
+
+
+
+ {/snippet}
+
+
+ {#if error}
{/if}
+
+ {#if loading}
+
+ {:else if connections.length === 0}
+
+ {:else}
+
+ {#each connections as connection}
+ {@const connectionAuthStatus = authStatus(connection)}
+
+
+
+
+
{connection.name}
+
+ {connection.enabled ? t('common.enabled') : t('common.disabled')}
+
+ {connectionAuthStatus.label}
+
+
{connection.base_url}
+ {#if connection.last_test_error}
+
{connection.last_test_error}
+ {/if}
+
+
+
+ {#if connection.auth_method === 'oauth'}
+
+ {/if}
+
+
+
+
+ {#if metadataByConnection[connection.id]}
+
+ {t('zammad.metadataSummary', {
+ groups: metadataByConnection[connection.id].groups.length,
+ states: metadataByConnection[connection.id].states.length,
+ })}
+
+ {#if metadataByConnection[connection.id].correlation_field_verified === false}
+
{t('zammad.correlationFieldNotVerified')}
+ {/if}
+ {#if metadataByConnection[connection.id].group_catalog_verified === false}
+
{t('zammad.groupCatalogNotVerified')}
+ {/if}
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+
+ (showModal = false)} />
+
+
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
diff --git a/internal/aitools/items.go b/internal/aitools/items.go
index 1e9371c69..e5754dd01 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.ErrItemHasProtectedIntegrationLinks) {
+ return map[string]string{"error": "remove all protected integration links 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 761e52c17..d58a5b170 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,307 @@ 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;
+ `,
+ },
+ {
+ 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;
+ `,
+ },
+ {
+ 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;
+ `,
+ },
+ {
+ 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 bae1f5d71..edd96ac67 100644
--- a/internal/database/schema/integrations.sql
+++ b/internal/database/schema/integrations.sql
@@ -135,4 +135,102 @@ 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,
+ config_revision INTEGER NOT NULL DEFAULT 1,
+ oauth_attempt_id TEXT,
+ base_url TEXT NOT NULL,
+ default_group_id INTEGER,
+ default_group_name TEXT 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,
+ 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_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,
+ 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)
+);
+
+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..3132f3b5c 100644
--- a/internal/database/schema/integrations_postgres.sql
+++ b/internal/database/schema/integrations_postgres.sql
@@ -119,3 +119,86 @@ 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,
+ config_revision BIGINT NOT NULL DEFAULT 1,
+ oauth_attempt_id TEXT,
+ base_url TEXT NOT NULL,
+ default_group_id INTEGER,
+ default_group_name TEXT 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,
+ 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_ticket_links (
+ id TEXT PRIMARY KEY,
+ 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,
+ 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,
+ sync_lock_owner TEXT,
+ 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..552a8cad0
--- /dev/null
+++ b/internal/database/zammad_schema_test.go
@@ -0,0 +1,332 @@
+package database
+
+import (
+ "encoding/json"
+ "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_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", "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)
+ }
+ 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, configRevision, 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 == "config_revision" {
+ configRevision = notNull == 1
+ }
+ if name == "oauth_attempt_id" {
+ oauthAttempt = true
+ }
+ }
+ 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
+ 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 IN (
+ '20260830_zammad_ticket_link_metadata',
+ '20260831_zammad_ticket_sync_lock_owner',
+ '20260831_zammad_ticket_link_item_restrict'
+ );
+ `)
+ 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", "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)
+ }
+ if count != 1 {
+ 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) {
+ 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")
+ }
+}
+
+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")
+ }
+}
+
+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.
+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 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, 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)
+ }
+ }
+ 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_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)
+ }
+ 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 configRevision int64
+ var authMethod string
+ 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_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 3c97fbee2..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"
@@ -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)", 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,6 +237,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 !integrations.SupportsGenericItemLinks(models.IntegrationProviderType(link.ProviderType)) {
+ respondNotFound(w, r, "integration_link")
+ return
+ }
if !CheckItemPermission(w, r, repository.NewItemRepository(h.db), h.permissionService, itemID, models.PermissionItemEdit) {
return
@@ -245,7 +252,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 +282,10 @@ func (h *IntegrationItemLinksHandler) RefreshItemLink(w http.ResponseWriter, r *
}
return
}
+ if !integrations.SupportsGenericItemRefresh(providerType) {
+ 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..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
@@ -64,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")
@@ -158,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")
@@ -190,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 {
@@ -386,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 39e135c4f..4c77e3098 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.ErrItemHasProtectedIntegrationLinks) {
+ respondError(w, r, restapi.NewAPIError(
+ http.StatusConflict,
+ restapi.ErrCodeConflict,
+ "Remove all protected integration links 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..df2700eda 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.ErrUserOffboardingHasProtectedIntegrationLinks) {
+ respondConflict(w, r, "Cannot offboard user while their personal workspace contains protected integration links")
+ 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..d334a627f 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.ErrWorkspaceHasProtectedIntegrationLinks) {
+ respondConflict(w, r, "Remove all protected integration links from this workspace before deleting it.")
+ return
+ }
respondInternalError(w, r, err)
return
}
diff --git a/internal/handlers/zammad.go b/internal/handlers/zammad.go
new file mode 100644
index 000000000..c69545b58
--- /dev/null
+++ b/internal/handlers/zammad.go
@@ -0,0 +1,442 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "slices"
+ "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
+ syncAllTrigger func() bool
+}
+
+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) SetSyncAllTrigger(trigger func() bool) { h.syncAllTrigger = trigger }
+
+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) RefreshAllTickets(w http.ResponseWriter, r *http.Request) {
+ if h.syncAllTrigger == nil {
+ respondServiceUnavailable(w, r, "Zammad synchronization is not available")
+ return
+ }
+ started := h.syncAllTrigger()
+ h.audit(r, currentUser(r), logger.ActionZammadTicketRefreshAll, logger.ResourceZammadTicket, "all", "", map[string]any{
+ "started": started,
+ })
+ respondJSON(w, http.StatusAccepted, map[string]bool{"started": started})
+}
+
+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())
+}
+
+// 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 {
+ 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
+ }
+ 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,
+ DefaultGroupID: connection.DefaultGroupID, DefaultGroupName: connection.DefaultGroupName,
+ AllowedGroups: connection.AllowedGroups,
+ })
+ }
+ 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.AllowedGroups) == 0
+ }
+ 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) {
+ 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.PermissionItemEdit, 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)
+}
+
+// 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 {
+ 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")
+ 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
+ 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
+ }
+ 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())
+ 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..a2545d89b
--- /dev/null
+++ b/internal/handlers/zammad_test.go
@@ -0,0 +1,480 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "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 TestZammadHandlerRefreshAllTicketsQueuesBackgroundRun(t *testing.T) {
+ handler, _, user, _ := newZammadHandlerTest(t)
+ handler.SetSyncAllTrigger(func() bool { return true })
+ recorder := httptest.NewRecorder()
+ request := authenticatedZammadRequest(http.MethodPost, "/api/admin/zammad-ticket-links/refresh", nil, user)
+ handler.RefreshAllTickets(recorder, request)
+ if recorder.Code != http.StatusAccepted {
+ t.Fatalf("unexpected status: %d %s", recorder.Code, recorder.Body.String())
+ }
+ var result map[string]bool
+ if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
+ t.Fatal(err)
+ }
+ if !result["started"] {
+ t.Fatalf("unexpected refresh result: %#v", result)
+ }
+}
+
+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 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()
+ 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 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, 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", AllowedGroups: []models.ZammadGroupRef{{ID: 8, Name: "Escalations"}}}, 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, 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/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)
+ 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
+ }
+ 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",
+ 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)
+ }
+
+ 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/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()
+ oauthHandler.SystemOAuthCallback(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.ActionIntegrationProviderOAuthCredentialSet)
+ 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/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
new file mode 100644
index 000000000..2a13aa6e6
--- /dev/null
+++ b/internal/integrations/zammad/client.go
@@ -0,0 +1,348 @@
+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"`
+}
+
+// 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 {
+ return nil, err
+ }
+ activeStates := states[:0]
+ for _, state := range states {
+ if state.Active {
+ activeStates = append(activeStates, state)
+ }
+ }
+ return activeStates, 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) {
+ 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
+ }
+ 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})
+ }
+ if len(rawUsers) < pageSize {
+ break
+ }
+ }
+ 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..c3103a7bb
--- /dev/null
+++ b/internal/integrations/zammad/client_test.go
@@ -0,0 +1,246 @@
+package zammad
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "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 TestClientStatesCorrelationAndTicketCreation(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/ticket_states":
+ _, _ = 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":
+ _, _ = 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()})
+ states, err := client.States(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ 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)
+ }
+ 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.States(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.States(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("page") != "1" || 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)
+ }
+}
+
+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/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..4455f7f01 100644
--- a/internal/logger/audit.go
+++ b/internal/logger/audit.go
@@ -540,11 +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"
+ 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"
@@ -825,6 +837,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..59234e6ae
--- /dev/null
+++ b/internal/models/zammad.go
@@ -0,0 +1,221 @@
+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:"-"`
+ ConfigRevision 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"`
+ AllowedGroups []ZammadGroupRef `json:"allowed_groups"`
+ 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"`
+ AllowedGroups []ZammadGroupRef `json:"allowed_groups"`
+}
+
+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"`
+ AllowedGroups []ZammadGroupRef `json:"allowed_groups,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"`
+ 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"`
+ 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"`
+}
+
+// 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"`
+ StateTypeID int `json:"state_type_id"`
+ Active bool `json:"active"`
+}
+
+type ZammadConnectionMetadata struct {
+ Groups []ZammadGroup `json:"groups"`
+ States []ZammadState `json:"states"`
+ GroupCatalogVerified bool `json:"group_catalog_verified"`
+ 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/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/integration_provider_repository.go b/internal/repository/integration_provider_repository.go
index d4b180eef..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"
)
@@ -80,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)
@@ -98,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,
@@ -127,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
@@ -156,11 +169,6 @@ 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
}
@@ -185,6 +193,9 @@ 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 {
+ 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/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_oauth_repository.go b/internal/repository/zammad_oauth_repository.go
new file mode 100644
index 000000000..876a79c91
--- /dev/null
+++ b/internal/repository/zammad_oauth_repository.go
@@ -0,0 +1,264 @@
+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 = ?
+ 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
+ }
+ 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..3762002a9
--- /dev/null
+++ b/internal/repository/zammad_repository.go
@@ -0,0 +1,1170 @@
+package repository
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "windshift/internal/database"
+ "windshift/internal/models"
+)
+
+type ZammadRepository struct {
+ db database.Database
+}
+
+type ZammadTicketLinkReservationSnapshot struct {
+ BaseURL string
+ CorrelationField string
+ ConnectionEnabled bool
+ DefaultGroupID int
+ DefaultGroupName string
+ AllowedGroups []models.ZammadGroupRef
+ 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
+ AllowedGroups []models.ZammadGroupRef
+ 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, 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,
+ 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`
+
+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 (
+ 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)
+ 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 (
+ 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
+}
+
+// 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 (
+ 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
+}
+
+func (r *ZammadRepository) CreateConnection(connection *models.ZammadConnection) error {
+ closedJSON, err := json.Marshal(connection.ClosedStateIDs)
+ if err != nil {
+ return err
+ }
+ allowedGroupsJSON, err := json.Marshal(connection.AllowedGroups)
+ 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_groups, default_customer, correlation_field,
+ closed_state_ids, completion_status_id,
+ 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.CreatedBy); err != nil {
+ return err
+ }
+ return nil
+ })
+}
+
+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.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_groups = ?,
+ default_customer = ?, correlation_field = ?, closed_state_ids = ?,
+ 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.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)
+ if err != nil {
+ if database.IsUniqueConstraintError(err) {
+ return ErrDuplicateEntry
+ }
+ return err
+ }
+ if rows, _ := result.RowsAffected(); rows == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+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
+ }
+ 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) 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
+}
+
+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.
+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
+ }
+ 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
+ }
+ 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 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_groups, closed_state_ids, completion_status_id
+ FROM zammad_connections WHERE provider_id = ?`, id).Scan(
+ &snapshot.BaseURL, &snapshot.CorrelationField, &snapshot.DefaultGroupID,
+ &snapshot.DefaultGroupName, &allowedGroupsJSON, &closedStateIDsJSON, &completionStatusID,
+ ); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, 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)
+ }
+ if completionStatusID.Valid {
+ value := int(completionStatusID.Int64)
+ snapshot.CompletionStatusID = &value
+ }
+ return snapshot, 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(allowedGroupsJSON), &allowedGroups); err != nil {
+ return 0, "", nil, fmt.Errorf("decode Zammad allowed groups: %w", err)
+ }
+ 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_groups, 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 allowedGroupsJSON string
+ if err := tx.QueryRow(connectionQuery, providerID).Scan(
+ &snapshot.BaseURL, &snapshot.CorrelationField, &snapshot.ConnectionEnabled,
+ &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(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 = ?`
+ 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 (
+ 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
+ }
+ 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 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 action_credential_workspaces acw
+ WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ?
+ ))`, itemID, workspaceID).Scan(&unavailable)
+ return unavailable, 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.ConfigRevision, &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.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 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
+ }
+ 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 (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
+ 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 (
+ 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 {
+ 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
+}
+
+// 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,
+ 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
+}
+
+// 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ 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
+}
+
+// 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
+ }
+ 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)
+ })
+}
+
+// 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,
+ })
+ 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 = ? 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, 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
+ }
+ 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, 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)
+ })
+}
+
+// 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})
+ 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 = ? 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, 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, 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, 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) {
+ 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, 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)
+ }
+ 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, sync_lock_owner = NULL,
+ completion_applied = CASE WHEN ? THEN ? ELSE completion_applied END,
+ 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, 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) {
+ 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 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
+ ))
+ 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()
+}
+
+// ListSyncableTicketLinks returns one stable snapshot of every complete ticket
+// link covered by an enabled, authorized connection. Unlike the scheduler's
+// due list, an explicit refresh ignores age, retry-delay, and claim gates so
+// concurrent work can be reported as skipped instead of disappearing.
+func (r *ZammadRepository) ListSyncableTicketLinks() ([]*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 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
+ ))
+ ORDER BY COALESCE(ztl.last_attempt_at, ztl.last_synced_at, ztl.created_at) ASC`)
+ 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, syncOwner string, until time.Time) (bool, error) {
+ if syncOwner == "" {
+ return false, ErrInvalidInput
+ }
+ 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) {
+ 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
+}
+
+// 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
+ }
+ return value
+}
diff --git a/internal/repository/zammad_repository_test.go b/internal/repository/zammad_repository_test.go
new file mode 100644
index 000000000..d9f919393
--- /dev/null
+++ b/internal/repository/zammad_repository_test.go
@@ -0,0 +1,252 @@
+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 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)
+ 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/restapi/v1/handlers/items.go b/internal/restapi/v1/handlers/items.go
index e3f6c8f79..99133b059 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 protected integration link"
// @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.ErrItemHasProtectedIntegrationLinks) {
+ h.RespondError(w, r, restapi.NewAPIError(
+ http.StatusConflict,
+ restapi.ErrCodeConflict,
+ "Remove all protected integration links 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..1192846d3 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 externally linked items"
// @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.ErrWorkspaceHasProtectedIntegrationLinks) {
+ h.RespondError(w, r, restapi.NewAPIError(
+ http.StatusConflict,
+ restapi.ErrCodeConflict,
+ "Remove all protected integration links from this workspace before deleting it.",
+ ))
+ return
+ }
h.RespondInternalError(w, r)
return
}
diff --git a/internal/routes/integrations.go b/internal/routes/integrations.go
index d5ed871ce..58585ac1b 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.
@@ -26,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)))
@@ -42,6 +45,27 @@ 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-ticket-links/refresh", admin(http.HandlerFunc(deps.Integrations.Zammad.RefreshAllTickets)))
+ 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)))
+ 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)))
+ 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..1721c6f0d
--- /dev/null
+++ b/internal/scheduler/zammad_sync_scheduler.go
@@ -0,0 +1,123 @@
+package scheduler
+
+import (
+ "context"
+ "log/slog"
+ "sync"
+ "time"
+
+ "windshift/internal/services"
+)
+
+type ZammadSyncScheduler struct {
+ service zammadSyncService
+ interval time.Duration
+ timer *time.Timer
+ triggerSyncAll chan struct{}
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+ mu sync.Mutex
+ running bool
+ syncAllPending bool
+}
+
+type zammadSyncService interface {
+ SyncDue(context.Context, int) error
+ SyncAllTicketLinks(context.Context) (services.ZammadSyncSummary, error)
+}
+
+func NewZammadSyncScheduler(service *services.ZammadService) *ZammadSyncScheduler {
+ return &ZammadSyncScheduler{service: service, interval: 2 * time.Minute, triggerSyncAll: make(chan struct{}, 1)}
+}
+
+func (s *ZammadSyncScheduler) Start() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.running {
+ return
+ }
+ // A shutdown can win the select race after a system-wide refresh was
+ // queued. Drop that stale request before starting a fresh loop so future
+ // refreshes are not permanently coalesced with work from the previous run.
+ s.syncAllPending = false
+ for {
+ select {
+ case <-s.triggerSyncAll:
+ continue
+ default:
+ goto triggerDrained
+ }
+ }
+
+triggerDrained:
+ s.timer = time.NewTimer(s.interval)
+ ctx, cancel := context.WithCancel(context.Background())
+ s.cancel = cancel
+ s.running = true
+ s.wg.Add(1)
+ go s.loop(ctx, s.timer)
+}
+
+// TriggerSyncAll queues one immediate system-wide refresh. A second request is
+// coalesced while the first is queued or running.
+func (s *ZammadSyncScheduler) TriggerSyncAll() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !s.running || s.syncAllPending {
+ return false
+ }
+ s.syncAllPending = true
+ s.triggerSyncAll <- struct{}{}
+ return true
+}
+
+func (s *ZammadSyncScheduler) Stop() {
+ s.mu.Lock()
+ if !s.running {
+ s.mu.Unlock()
+ return
+ }
+ s.running = false
+ s.timer.Stop()
+ s.cancel()
+ s.mu.Unlock()
+ s.wg.Wait()
+}
+
+func (s *ZammadSyncScheduler) loop(ctx context.Context, timer *time.Timer) {
+ defer s.wg.Done()
+ defer timer.Stop()
+ for {
+ select {
+ case <-timer.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))
+ }
+ timer.Reset(s.interval)
+ case <-s.triggerSyncAll:
+ if ctx.Err() != nil {
+ return
+ }
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ summary, err := s.service.SyncAllTicketLinks(ctx)
+ if err != nil && ctx.Err() == nil {
+ slog.Warn("System-wide Zammad ticket synchronization failed", slog.String("component", "zammad-sync"), slog.Any("error", err))
+ } else if ctx.Err() == nil {
+ slog.Info("System-wide Zammad ticket synchronization completed", slog.String("component", "zammad-sync"),
+ slog.Int("selected", summary.Selected), slog.Int("succeeded", summary.Succeeded),
+ slog.Int("failed", summary.Failed), slog.Int("skipped", summary.Skipped))
+ }
+ s.mu.Lock()
+ s.syncAllPending = false
+ s.mu.Unlock()
+ timer.Reset(s.interval)
+ case <-ctx.Done():
+ return
+ }
+ }
+}
diff --git a/internal/scheduler/zammad_sync_scheduler_test.go b/internal/scheduler/zammad_sync_scheduler_test.go
new file mode 100644
index 000000000..d08546ad6
--- /dev/null
+++ b/internal/scheduler/zammad_sync_scheduler_test.go
@@ -0,0 +1,140 @@
+package scheduler
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "windshift/internal/services"
+)
+
+type fakeZammadSyncService struct {
+ mu sync.Mutex
+ dueCalls int
+ dueStarted chan int
+ releaseDue chan struct{}
+ dueDone chan struct{}
+ allStarted chan struct{}
+ releaseAll chan struct{}
+ allDone chan struct{}
+ allDoneOnce sync.Once
+}
+
+func (f *fakeZammadSyncService) SyncDue(context.Context, int) error {
+ f.mu.Lock()
+ f.dueCalls++
+ call := f.dueCalls
+ f.mu.Unlock()
+ f.dueStarted <- call
+ if call == 1 && f.releaseDue != nil {
+ <-f.releaseDue
+ close(f.dueDone)
+ }
+ return nil
+}
+
+func (f *fakeZammadSyncService) SyncAllTicketLinks(context.Context) (services.ZammadSyncSummary, error) {
+ select {
+ case f.allStarted <- struct{}{}:
+ default:
+ }
+ if f.releaseAll != nil {
+ <-f.releaseAll
+ }
+ f.allDoneOnce.Do(func() { close(f.allDone) })
+ return services.ZammadSyncSummary{Selected: 1, Succeeded: 1}, nil
+}
+
+func TestZammadSchedulerWaitsFullIntervalAfterSlowRun(t *testing.T) {
+ fake := &fakeZammadSyncService{
+ dueStarted: make(chan int, 4), releaseDue: make(chan struct{}), dueDone: make(chan struct{}),
+ allStarted: make(chan struct{}, 1), allDone: make(chan struct{}),
+ }
+ s := &ZammadSyncScheduler{service: fake, interval: 20 * time.Millisecond, triggerSyncAll: make(chan struct{}, 1)}
+ s.Start()
+ t.Cleanup(s.Stop)
+
+ select {
+ case <-fake.dueStarted:
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("first scheduled synchronization did not start")
+ }
+ time.Sleep(50 * time.Millisecond)
+ close(fake.releaseDue)
+ select {
+ case <-fake.dueDone:
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("first scheduled synchronization did not finish")
+ }
+ select {
+ case <-fake.dueStarted:
+ t.Fatal("ticker backlog started another synchronization without waiting")
+ case <-time.After(10 * time.Millisecond):
+ }
+ select {
+ case <-fake.dueStarted:
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("next synchronization did not start after the configured interval")
+ }
+}
+
+func TestZammadSchedulerCoalescesSystemRefreshRequests(t *testing.T) {
+ fake := &fakeZammadSyncService{
+ dueStarted: make(chan int, 1), dueDone: make(chan struct{}),
+ allStarted: make(chan struct{}, 2), releaseAll: make(chan struct{}), allDone: make(chan struct{}),
+ }
+ s := &ZammadSyncScheduler{service: fake, interval: time.Hour, triggerSyncAll: make(chan struct{}, 1)}
+ s.Start()
+ t.Cleanup(s.Stop)
+
+ if !s.TriggerSyncAll() || s.TriggerSyncAll() {
+ t.Fatal("queued system refresh was not coalesced")
+ }
+ select {
+ case <-fake.allStarted:
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("system refresh did not start")
+ }
+ if s.TriggerSyncAll() {
+ t.Fatal("running system refresh accepted a duplicate trigger")
+ }
+ close(fake.releaseAll)
+ select {
+ case <-fake.allDone:
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("system refresh did not finish")
+ }
+ deadline := time.Now().Add(200 * time.Millisecond)
+ for !s.TriggerSyncAll() {
+ if time.Now().After(deadline) {
+ t.Fatal("system refresh trigger remained stuck after completion")
+ }
+ time.Sleep(time.Millisecond)
+ }
+}
+
+func TestZammadSchedulerStartDropsStaleSystemRefresh(t *testing.T) {
+ fake := &fakeZammadSyncService{
+ dueStarted: make(chan int, 1), dueDone: make(chan struct{}),
+ allStarted: make(chan struct{}, 1), releaseAll: make(chan struct{}), allDone: make(chan struct{}),
+ }
+ trigger := make(chan struct{}, 1)
+ trigger <- struct{}{}
+ s := &ZammadSyncScheduler{
+ service: fake, interval: time.Hour, triggerSyncAll: trigger,
+ syncAllPending: true,
+ }
+ s.Start()
+ t.Cleanup(s.Stop)
+
+ if !s.TriggerSyncAll() {
+ t.Fatal("fresh system refresh was coalesced with a stale request")
+ }
+ select {
+ case <-fake.allStarted:
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("fresh system refresh did not start")
+ }
+ close(fake.releaseAll)
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index a8abf3f72..0874ac0ff 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,23 @@ 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))
+ integrationOAuthHandler.RegisterSystemOAuthFlow(models.IntegrationProviderZammad, zammadService, logger.NewAuditor(s.db))
+ s.zammadSyncScheduler = scheduler.NewZammadSyncScheduler(zammadService)
+ s.zammadSyncScheduler.Start()
+ zammadHandler.SetSyncAllTrigger(s.zammadSyncScheduler.TriggerSyncAll)
+
// 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 +1630,7 @@ func (s *Server) initialize() error {
OAuth: integrationOAuthHandler,
ItemLinks: integrationItemLinksHandler,
TodoistSync: todoistSyncHandler,
+ Zammad: zammadHandler,
},
Pages: routes.PageHandlers{
Page: pageHandler,
@@ -1987,6 +2002,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 +2140,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/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_change_publisher.go b/internal/services/item_change_publisher.go
index 69cbca8dd..bb5af1a0f 100644
--- a/internal/services/item_change_publisher.go
+++ b/internal/services/item_change_publisher.go
@@ -16,6 +16,7 @@ const (
ItemChangeDeleted ItemChangeKind = "deleted"
ItemChangeComment ItemChangeKind = "comment"
ItemChangeLink ItemChangeKind = "link"
+ ItemChangeZammad ItemChangeKind = "zammad"
)
// ItemChangePublisher receives item-change notifications after a mutation has
diff --git a/internal/services/item_crud_service.go b/internal/services/item_crud_service.go
index fbd2c0d2b..f5ee94225 100644
--- a/internal/services/item_crud_service.go
+++ b/internal/services/item_crud_service.go
@@ -18,23 +18,26 @@ 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")
+ 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
+ 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),
+ db: db,
+ repo: repository.NewItemRepository(db),
+ workspaceRepo: repository.NewWorkspaceRepository(db),
+ integrationLinkGuards: NewIntegrationLinkGuards(db),
}
}
@@ -95,6 +98,13 @@ func (s *ItemCRUDService) DeleteSingleWithMetadata(itemID int, metadata itemeven
if err != nil {
return err
}
+ hasProtectedLinks, err := s.integrationLinkGuards.HasLinksForItemsTx(tx, []int{itemID})
+ if err != nil {
+ return err
+ }
+ if hasProtectedLinks {
+ return ErrItemHasProtectedIntegrationLinks
+ }
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
}
+ hasProtectedLinks, err := s.integrationLinkGuards.HasLinksForItemsTx(tx, allIDs)
+ if err != nil {
+ return err
+ }
+ 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 61a273934..bf997d00d 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
}
+ integrationLinkUnavailable, err := NewIntegrationLinkGuards(s.db).HasLinkUnavailableInWorkspaceTx(tx, itemID, input.DestinationWorkspaceID)
+ if err != nil {
+ return nil, fmt.Errorf("validate integration links for workspace move: %w", err)
+ }
+ if integrationLinkUnavailable {
+ return nil, &validation.ValidationError{
+ Field: "destination_workspace_id",
+ Message: "Destination workspace is not allowed by every provider-managed integration link",
+ }
+ }
newNumber, err := itemRepo.GetNextWorkspaceItemNumber(tx, input.DestinationWorkspaceID)
if err != nil {
diff --git a/internal/services/user_offboard.go b/internal/services/user_offboard.go
index 5bf9819ea..b96ff4284 100644
--- a/internal/services/user_offboard.go
+++ b/internal/services/user_offboard.go
@@ -17,6 +17,10 @@ type UserNotificationDeleter interface {
DeleteUserNotifications(userID int) error
}
+// 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
// item_history, comments, time_worklogs, etc. remain valid.
@@ -35,6 +39,34 @@ func OffboardUser(db database.Database, userID int, notificationDeleter UserNoti
}
defer func() { _ = tx.Rollback() }()
+ // 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
+ 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
+ }
+ hasLinks, err := NewIntegrationLinkGuards(db).HasLinksForWorkspaceTx(tx, *personalWsID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to check personal workspace integration links: %w", err)
+ }
+ if hasLinks {
+ return nil, ErrUserOffboardingHasProtectedIntegrationLinks
+ }
+ }
+
// a) Anonymize user record
if _, err := tx.Exec(`
UPDATE users SET
@@ -58,15 +90,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..211ce56ec 100644
--- a/internal/services/workspace_service.go
+++ b/internal/services/workspace_service.go
@@ -13,21 +13,27 @@ import (
"windshift/internal/repository"
)
+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
- templates *repository.WorkspaceTemplateRepository
- 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),
- templates: repository.NewWorkspaceTemplateRepository(db),
+ db: db,
+ repo: repository.NewWorkspaceRepository(db),
+ itemRepo: repository.NewItemRepository(db),
+ templates: repository.NewWorkspaceTemplateRepository(db),
+ integrationLinkGuards: NewIntegrationLinkGuards(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)
+ hasProtectedLinks, err := s.integrationLinkGuards.HasLinksForWorkspaceTx(tx, id)
+ if err != nil {
+ return err
+ }
+ if hasProtectedLinks {
+ return ErrWorkspaceHasProtectedIntegrationLinks
+ }
+
+ 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
new file mode 100644
index 000000000..ef4e8ea5c
--- /dev/null
+++ b/internal/services/zammad_service.go
@@ -0,0 +1,2301 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "windshift/internal/database"
+ coreintegrations "windshift/internal/integrations"
+ "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")
+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 = coreintegrations.SystemOAuthCallbackResult
+
+type ZammadSyncSummary struct {
+ Selected int `json:"selected"`
+ Succeeded int `json:"succeeded"`
+ Failed int `json:"failed"`
+ Skipped int `json:"skipped"`
+}
+
+// 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()
+ 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 {
+ 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) 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()
+}
+
+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
+ 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.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.AllowedGroups = legacyZammadGroupRefs(*req.AllowedGroupIDs, connection.DefaultGroupID, connection.DefaultGroupName)
+ }
+ syncZammadDefaultGroupName(connection)
+ 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.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 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
+ }
+ 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 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
+ }
+ return credentialUpdate(tx)
+ }); err != nil {
+ return nil, err
+ }
+ return s.repo.GetConnection(id)
+}
+
+func (s *ZammadService) DeleteConnection(id string) error {
+ 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 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")
+ }
+ groupPolicyChanged := connection.DefaultGroupID != current.DefaultGroupID ||
+ connection.DefaultGroupName != current.DefaultGroupName ||
+ !slices.Equal(effectiveZammadGroupRefs(connection), effectiveZammadSnapshotGroupRefs(current))
+ 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 {
+ allowedGroups := effectiveZammadGroupRefs(connection)
+ if len(allowedGroups) > 0 {
+ _, ok := zammadGroupRefByID(allowedGroups, groupID)
+ return ok
+ }
+ 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 {
+ currentGroups := effectiveZammadSnapshotGroupRefs(current)
+ if len(currentGroups) > 0 {
+ for _, group := range currentGroups {
+ if !zammadConnectionAllowsGroupSnapshot(proposed, group.ID, group.Name) {
+ 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(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 {
+ 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,
+ AllowedGroups: snapshot.AllowedGroups,
+ }, 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
+// 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.Enabled {
+ return "", zammadValidationError("connection is disabled")
+ }
+ 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.Enabled {
+ return result, zammadValidationError("connection is disabled")
+ }
+ 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, Generation: 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 {
+ if errors.Is(err, repository.ErrNotFound) {
+ return "", ErrZammadReauthorizationRequired
+ }
+ return "", err
+ }
+ if token.ReauthorizationRequired {
+ return "", ErrZammadReauthorizationRequired
+ }
+ if token.OAuthGeneration != connection.OAuthGeneration {
+ return "", ErrZammadOAuthSuperseded
+ }
+ if token.ExpiresAt.After(time.Now().Add(2 * time.Minute)) {
+ return s.resolveStoredZammadOAuthAccessToken(ctx, connection, workspaceID)
+ }
+ if s.oauthBeforeRefreshClaim != nil {
+ s.oauthBeforeRefreshClaim()
+ }
+ claimProviderID := connection.ProviderID
+ claimGeneration := token.OAuthGeneration
+ claimOwner := uuid.New().String()
+ 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() {
+ 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 {
+ if errors.Is(err, repository.ErrNotFound) {
+ return "", ErrZammadOAuthSuperseded
+ }
+ return "", err
+ }
+ 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) 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,
+ 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 := zammadRuntimeMetadata(ctx, connection, client)
+ if err == nil {
+ // Least-privilege API-token and OAuth service accounts intentionally need
+ // 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 = validateZammadStates(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
+ }
+ return zammadRuntimeMetadata(ctx, connection, client)
+}
+
+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
+ }
+ if _, err := allowedZammadGroup(connection, 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)
+}
+
+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.
+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 := zammadRuntimeMetadata(ctx, connection, client)
+ if err != nil {
+ return nil, err
+ }
+ group, err := allowedZammadGroup(connection, 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.reserveZammadTicketLink(connection, item, link, true); 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
+ }
+ }
+ 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 = zammadRuntimeMetadata(ctx, connection, client)
+ if err != nil {
+ return nil, err
+ }
+ group, err = allowedZammadGroup(connection, 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
+ }
+ }
+ 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.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)
+}
+
+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, 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
+ }
+ group, err := allowedZammadGroup(connection, groupID)
+ if err != nil {
+ return nil, err
+ }
+ 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)
+ 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.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
+ }
+ }
+ if link.TicketID != 0 {
+ return link, nil
+ }
+ 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)
+ }
+ if _, err := allowedZammadGroup(connection, 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 {
+ 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 {
+ 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
+ }
+ 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())
+ var markErr error
+ if wasUncertain || (postAttempted && zammadCreationOutcomeUncertain(requestErr)) {
+ markErr = s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, safeError)
+ } else {
+ markErr = s.repo.MarkTicketLinkFailed(link.ID, syncOwner, safeError)
+ }
+ if markErr != nil {
+ return nil, s.mapZammadSyncClaimError(markErr)
+ }
+ return nil, requestErr
+ }
+ if ticket.GroupID == 0 {
+ ticket, requestErr = client.GetTicket(ctx, ticket.ID)
+ if requestErr != nil {
+ if err := s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, RedactString(requestErr.Error())); err != nil {
+ return nil, s.mapZammadSyncClaimError(err)
+ }
+ return nil, requestErr
+ }
+ }
+ ticketGroupID := ticket.GroupID
+ 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)
+ }
+ 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.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.
+ 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)
+}
+
+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{}{}
+ }
+ 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 || 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 {
+ 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 := zammadRuntimeMetadata(ctx, connection, client)
+ if err != nil {
+ return nil, err
+ }
+
+ effectiveGroupID := current.GroupID
+ if req.GroupID != nil {
+ effectiveGroupID = *req.GroupID
+ }
+ if _, err := allowedZammadGroup(connection, 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")
+ }
+ }
+
+ 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, client, updated, metadata, syncOwner); 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
+ }
+ 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
+ }
+ connection, client, err := s.clientForUnlink(ctx, link.ProviderID, item.WorkspaceID)
+ if err != nil {
+ return nil, err
+ }
+ 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 link.TicketID == 0 || !errors.As(getErr, &apiErr) || apiErr.StatusCode != 404 {
+ return nil, getErr
+ }
+ 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.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
+}
+
+// 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 {
+ return nil, err
+ }
+ 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
+ }
+ return s.syncClaimedTicketLink(ctx, link, syncOwner)
+}
+
+// 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, 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, syncOwner, itemErr)
+ return nil, itemErr
+ }
+ connection, client, err := s.client(ctx, link.ProviderID, item.WorkspaceID)
+ if err != nil {
+ 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 {
+ 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, syncOwner, err)
+ return nil, err
+ }
+ allowedGroup, err := s.requireAllowedTicketGroup(ctx, connection, client, ticket, nil)
+ if err != nil {
+ s.recordZammadSyncError(link, syncOwner, 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 = zammadRuntimeMetadata(ctx, connection, client)
+ if err != nil {
+ s.recordZammadSyncError(link, syncOwner, err)
+ return nil, err
+ }
+ }
+ if err := s.persistTicketSnapshot(ctx, link, client, ticket, metadata, syncOwner); err != nil {
+ return nil, err
+ }
+ updated, err := s.repo.GetTicketLink(link.ID)
+ if err != nil {
+ return nil, err
+ }
+ PublishItemChange(updated.ItemID, ItemChangeZammad)
+ return updated, nil
+}
+
+func (s *ZammadService) recordZammadSyncError(link *models.ZammadTicketLink, syncOwner string, err error) {
+ if updateErr := 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); updateErr == nil {
+ PublishItemChange(link.ItemID, ItemChangeZammad)
+ }
+}
+
+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
+ }
+ groupName := zammadGroupName(metadata, ticket.GroupID, ticket.GroupName)
+ 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 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
+ }
+ 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())
+ updateErr := s.updateTicketLinkSyncWithCurrentGroupPolicy(link.ProviderID, link.ID, syncOwner, ticket.StateID, statusName,
+ ticket.GroupID, groupName, ticket.OwnerID, ownerName,
+ safeError, time.Now(), completionCommitted, completionCommitted)
+ if updateErr == nil {
+ PublishItemChange(link.ItemID, ItemChangeZammad)
+ }
+ return completionErr
+ }
+ completionApplied = true
+ }
+ }
+ setCompletionApplied := !isClosed || completionApplied
+ 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, allowedGroups, err := s.repo.ConnectionGroupPolicyTx(tx, providerID)
+ if err != nil {
+ return err
+ }
+ if !zammadConnectionAllowsGroupSnapshot(&models.ZammadConnection{
+ DefaultGroupID: defaultGroupID, DefaultGroupName: defaultGroupName, AllowedGroups: allowedGroups,
+ }, groupID, groupName) {
+ return ErrZammadTicketGroupPolicyChanged
+ }
+ return nil
+}
+
+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) []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, groupID int) (models.ZammadGroup, error) {
+ for _, group := range allowedZammadGroups(connection) {
+ 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(_ 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")
+ }
+ 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 &models.ZammadConnectionMetadata{Groups: allowedZammadGroups(connection), States: states}, nil
+}
+
+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
+ }
+ // The scheduler interval controls the polling cadence. Applying the same
+ // age threshold here caused a freshly synchronized link to miss the next
+ // tick and made the effective cadence roughly twice as long.
+ links, err := s.repo.ListDueTicketLinks(time.Now(), limit)
+ if err != nil {
+ return err
+ }
+ var firstError error
+ for _, link := range links {
+ 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, syncOwner); syncErr != nil && firstError == nil {
+ firstError = syncErr
+ }
+ }
+ return firstError
+}
+
+// SyncAllTicketLinks performs one explicit, system-wide refresh of every
+// complete link covered by an enabled, authorized connection. Per-link
+// failures are summarized so one broken upstream ticket does not prevent the
+// remaining links from being refreshed.
+func (s *ZammadService) SyncAllTicketLinks(ctx context.Context) (ZammadSyncSummary, error) {
+ links, err := s.repo.ListSyncableTicketLinks()
+ if err != nil {
+ return ZammadSyncSummary{}, err
+ }
+ summary := ZammadSyncSummary{Selected: len(links)}
+ for _, link := range links {
+ if err := ctx.Err(); err != nil {
+ return summary, err
+ }
+ syncOwner := uuid.New().String()
+ claimed, claimErr := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration))
+ if claimErr != nil {
+ summary.Failed++
+ continue
+ }
+ if !claimed {
+ summary.Skipped++
+ continue
+ }
+ if _, syncErr := s.syncClaimedTicketLink(ctx, link, syncOwner); syncErr != nil {
+ summary.Failed++
+ continue
+ }
+ summary.Succeeded++
+ }
+ return summary, nil
+}
+
+func (s *ZammadService) completeWindshiftItem(ctx context.Context, link *models.ZammadTicketLink, connection *models.ZammadConnection) (bool, error) {
+ if connection.CompletionStatusID == nil {
+ return false, nil
+ }
+ if connection.CreatedBy == nil {
+ 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 false, err
+ }
+ allowed, err := s.permission.HasWorkspacePermission(*connection.CreatedBy, item.WorkspaceID, models.PermissionItemEdit)
+ if err != nil {
+ return false, err
+ }
+ if !allowed {
+ 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,
+ 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 && result == nil {
+ return false, err
+ }
+ if !result.NoOp && s.events != nil {
+ s.events.EmitStatusChanged(result.Item, result.OldStatusID, result.NewStatusID, *connection.CreatedBy, "Zammad")
+ }
+ return true, err
+}
+
+func (s *ZammadService) client(ctx context.Context, id string, workspaceID int) (*models.ZammadConnection, *zammad.Client, error) {
+ connection, err := s.connection(id, workspaceID)
+ if err != nil {
+ return nil, nil, err
+ }
+ client, err := s.clientForConnection(ctx, connection, workspaceID)
+ if err != nil {
+ return nil, nil, err
+ }
+ 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
+ }
+ available, err := s.repo.IsConnectionAvailableToWorkspace(id, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ return nil, ErrCredentialScopeMismatch
+ }
+ return connection, 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 {
+ 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 {
+ states, err := client.States(ctx)
+ if err != nil {
+ return ""
+ }
+ for _, state := range 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 err := validateZammadGroupRefs(req.AllowedGroups); err != nil {
+ return nil, err
+ }
+ 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),
+ 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
+ }
+ 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.DefaultGroupID == 0 && connection.DefaultGroupName == "" {
+ return zammadValidationError("default_group_id or default_group_name is required")
+ }
+ 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")
+ }
+ 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/oauth/system/zammad/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 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 {
+ 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..0ec8f32a4
--- /dev/null
+++ b/internal/services/zammad_service_test.go
@@ -0,0 +1,3295 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "slices"
+ "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
+ postResponse map[string]any
+ putError error
+ searchError 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
+ createGroupID int
+ 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()
+ 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.searchError != nil {
+ err := f.searchError
+ f.searchError = nil
+ return nil, err
+ }
+ 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
+ }
+ createGroupID := f.createGroupID
+ if createGroupID == 0 {
+ createGroupID = 7
+ }
+ f.ticket = map[string]any{
+ "id": 901, "number": "420901", "group_id": createGroupID,
+ "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
+ }
+ 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++
+ 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
+ }
+ 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/"):
+ 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/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 recordingItemChangePublisher struct {
+ itemID int
+ kind ItemChangeKind
+}
+
+func (p *recordingItemChangePublisher) PublishItemChange(itemID int, kind ItemChangeKind) {
+ p.itemID = itemID
+ p.kind = kind
+}
+
+type fakeZammadWorkflow struct {
+ db database.Database
+ writes int
+ postCommitErr error
+}
+
+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
+ result := &PerformTransitionResult{Item: item, OldStatusID: oldStatusID, NewStatusID: &newStatusID}
+ if f.postCommitErr != nil {
+ return result, f.postCommitErr
+ }
+ return result, 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 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)
+ 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 := 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
+ 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 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 := 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)
+ 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 := 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)
+ 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 := 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)
+ 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 := 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()
+ 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 := 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)
+ 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 := 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)
+ 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 := zammadTestGroupRefs(7)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &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 := 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 := 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)
+ }
+
+ 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, AllowedGroups: zammadTestGroupRefs(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, AllowedGroups: zammadTestGroupRefs(7, 8, 9)},
+ },
+ {
+ name: "numeric expansion preserves name-only default through allowlist",
+ current: &repository.ZammadConnectionMutationSnapshot{
+ DefaultGroupName: "Support", AllowedGroups: zammadTestGroupRefs(7, 8),
+ },
+ proposed: &models.ZammadConnection{DefaultGroupName: "Support", AllowedGroups: zammadTestGroupRefs(7, 8, 9)},
+ },
+ {
+ name: "numeric narrowing", current: currentNumeric,
+ 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, AllowedGroups: zammadTestGroupRefs(7, 8)},
+ },
+ {
+ name: "old default excluded", current: &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7},
+ proposed: &models.ZammadConnection{DefaultGroupID: 8, AllowedGroups: zammadTestGroupRefs(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, AllowedGroups: zammadTestGroupRefs(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 := 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)
+ 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 := 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)
+ 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})
+ 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 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})
+ 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 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) {
+ 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, ErrItemHasProtectedIntegrationLinks) {
+ 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, ErrItemHasProtectedIntegrationLinks) {
+ 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, ErrWorkspaceHasProtectedIntegrationLinks) {
+ 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, ErrUserOffboardingHasProtectedIntegrationLinks) {
+ 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})
+ 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 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
+ 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 := 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)
+ 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 := zammadTestGroupRefs(7)
+ closedStates := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
+ AllowedGroups: &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()
+ 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)
+ }
+ 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 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})
+ 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 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 := zammadTestGroupRefs(8)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
+ DefaultGroupID: &newDefaultGroupID, DefaultGroupName: &newDefaultGroupName, AllowedGroups: &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 := zammadTestGroupRefs(8)
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{
+ DefaultGroupID: &newDefaultGroupID, DefaultGroupName: &newDefaultGroupName, AllowedGroups: &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)
+ }
+ if metadata.GroupCatalogVerified || metadata.CorrelationFieldVerified {
+ t.Fatalf("least-privilege checks must report admin-only metadata as unverified: %#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, AllowedGroups: zammadTestGroupRefs(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 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 := validateZammadStates(connection, metadata); !errors.As(err, &validationErr) {
+ t.Fatalf("missing configured closed state 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 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)
+ }
+ 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": 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")
+ }
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if workflow.writes != 1 || !stored.CompletionApplied {
+ t.Fatalf("committed transition was not fenced after hook error: writes=%d link=%#v", workflow.writes, stored)
+ }
+ if _, err := f.db.ExecWrite("UPDATE items SET status_id = ? WHERE id = ?", f.openStatus, f.item1); err != nil {
+ t.Fatal(err)
+ }
+ workflow.postCommitErr = nil
+ 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 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 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)
+ }
+ 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": 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)
+ }
+ 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 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)
+ }
+ 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": 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)
+ }
+ 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)
+ }
+ if workflow.writes != 0 || statusID != f.openStatus {
+ t.Fatalf("expired worker applied the superseded completion policy: writes=%d status=%d", workflow.writes, statusID)
+ }
+}
+
+func TestZammadTicketCreationClaimBlocksConcurrentUnlinkBeforeCompletion(t *testing.T) {
+ f := newZammadServiceFixture(t, nil)
+ 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
+ }
+ 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 genericLinks != 1 {
+ t.Fatalf("ticket creation committed %d generic links, want 1", genericLinks)
+ }
+}
+
+func TestZammadExistingTicketClaimBlocksConcurrentUnlinkBeforeCompletion(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"}
+ 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
+ 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)
+ }
+ 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)
+ }
+ 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)
+ }
+ 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 genericLinks != 1 {
+ t.Fatalf("existing-ticket completion committed %d generic links, want 1", genericLinks)
+ }
+}
+
+func TestZammadSyncReloadsCompletionStateAfterClaim(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)
+ }
+ closedStates := []int{4}
+ if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closedStates, CompletionStatusID: &f.doneStatus}); err != nil {
+ t.Fatal(err)
+ }
+ stale, err := f.service.GetTicketLink(link.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET completion_applied = true WHERE id = ?", link.ID); 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"}
+ 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 workflow.writes != 0 || !updated.CompletionApplied {
+ t.Fatalf("stale completion state was replayed: writes=%d link=%#v", workflow.writes, updated)
+ }
+ 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)
+ }
+}
+
+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)
+ }
+ 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 _, 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)
+ }
+ if err := f.service.repo.ReleaseSyncClaim(link.ID, "scheduler-owner"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestZammadSyncUsesPersistedGroupPolicyWithoutAdminLookup(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)
+ }
+ 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)
+ 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 != 1 || !stored.CompletionApplied {
+ t.Fatalf("persisted group policy did not complete normal sync: link=%#v writes=%d err=%v", stored, workflow.writes, getErr)
+ }
+}
+
+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 only the ticket refresh, got %d transport requests", requests)
+ }
+}
+
+func TestZammadDueSyncPollsFreshLinkOnNextSchedulerTick(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)
+ }
+ 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("fresh link missed the next scheduler tick: requests=%d", requests)
+ }
+}
+
+func TestZammadSyncAllTicketLinksOverridesRetryDelay(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 next_attempt_at = ? WHERE id = ?", time.Now().Add(time.Hour), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ f.transport.resetRequests()
+ summary, err := f.service.SyncAllTicketLinks(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if summary.Selected != 1 || summary.Succeeded != 1 || summary.Failed != 0 || summary.Skipped != 0 {
+ t.Fatalf("unexpected system refresh summary: %#v", summary)
+ }
+ requests, _ := f.transport.counts()
+ if requests != 1 {
+ t.Fatalf("system refresh did not override the retry delay: requests=%d", requests)
+ }
+}
+
+func TestZammadSyncAllTicketLinksReportsAlreadyClaimedLinks(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)
+ }
+ claimed, err := f.service.repo.ClaimSync(link.ID, "other-worker", time.Now().Add(time.Minute))
+ if err != nil || !claimed {
+ t.Fatalf("failed to create competing claim: claimed=%v err=%v", claimed, err)
+ }
+ f.transport.resetRequests()
+ summary, err := f.service.SyncAllTicketLinks(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if summary.Selected != 1 || summary.Succeeded != 0 || summary.Failed != 0 || summary.Skipped != 1 {
+ t.Fatalf("claimed link disappeared from system refresh summary: %#v", summary)
+ }
+ requests, _ := f.transport.counts()
+ if requests != 0 {
+ t.Fatalf("claimed link was refreshed concurrently: requests=%d", requests)
+ }
+}
+
+func TestZammadTicketSyncPublishesLivePanelUpdate(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)
+ }
+ publisher := &recordingItemChangePublisher{}
+ SetItemChangePublisher(publisher)
+ t.Cleanup(func() { SetItemChangePublisher(nil) })
+
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil {
+ t.Fatal(err)
+ }
+ if publisher.itemID != f.item1 || publisher.kind != ItemChangeZammad {
+ t.Fatalf("unexpected live-update event: item=%d kind=%q", publisher.itemID, publisher.kind)
+ }
+}
+
+func TestZammadTicketSyncFailurePublishesLivePanelUpdate(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)
+ }
+ publisher := &recordingItemChangePublisher{}
+ SetItemChangePublisher(publisher)
+ t.Cleanup(func() { SetItemChangePublisher(nil) })
+ f.transport.getStatus = http.StatusBadGateway
+
+ if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil {
+ t.Fatal("expected upstream refresh failure")
+ }
+ if publisher.itemID != f.item1 || publisher.kind != ItemChangeZammad {
+ t.Fatalf("failure did not publish a live-update event: item=%d kind=%q", publisher.itemID, publisher.kind)
+ }
+ stored, err := f.service.GetTicketLink(link.ID)
+ if err != nil || stored.LastError == "" || stored.SyncState != models.ZammadSyncFailed {
+ t.Fatalf("failure state was not persisted: link=%#v err=%v", stored, err)
+ }
+}
+
+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",
+ DefaultGroupName: "Support", 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", 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)
+ }
+ 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/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 {
+ 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",
+ DefaultGroupName: "Support", 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",
+ 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
+ }))
+ 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 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"))
+ 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",
+ 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": "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",
+ DefaultGroupName: "Support", 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",
+ DefaultGroupName: "Support", 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 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))
+ 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", DefaultGroupID: 7, DefaultGroupName: "Support",
+ AllowedGroups: zammadTestGroupRefs(7), 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
+ }
+ 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 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
+ }
+ 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/ticket_states" {
+ 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/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/ticket_states", 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.States(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
+ })
+}