From f15735027da8fece77f47a2b42fad972ad4d8657 Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Mon, 6 Jul 2026 11:28:42 +0200 Subject: [PATCH 1/3] fix(integrations): forward integration config to outbound hooks getIntegrationTargets() only passed {accessToken, rootUrl} into each hook's config, dropping integration-specific fields stored in integrations.config (organizationName, cloudId, teamId, etc). Azure DevOps work item creation failed with a misleading "reconnect" auth error because organizationName never reached createWorkItem() - same gap affects Jira/Teams/Monday/Trello/Asana. Also stop discarding the Azure DevOps API response body on failure, so future errors log the actual validation message instead of a bare status code. --- apps/web/src/lib/server/events/targets.ts | 7 +++++-- .../src/lib/server/integrations/azure-devops/api.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/server/events/targets.ts b/apps/web/src/lib/server/events/targets.ts index fd0f371d2..fa5f3982b 100644 --- a/apps/web/src/lib/server/events/targets.ts +++ b/apps/web/src/lib/server/events/targets.ts @@ -317,7 +317,10 @@ async function getIntegrationTargets( const secrets = decryptSecrets<{ accessToken?: string }>(m.secrets) accessToken = secrets.accessToken } catch (error) { - log.error({ err: error, integration_type: m.integrationType }, 'failed to decrypt integration secrets') + log.error( + { err: error, integration_type: m.integrationType }, + 'failed to decrypt integration secrets' + ) continue } } @@ -325,7 +328,7 @@ async function getIntegrationTargets( targets.push({ type: m.integrationType, target: { channelId }, - config: { accessToken, rootUrl: context.portalBaseUrl }, + config: { ...integrationConfig, accessToken, rootUrl: context.portalBaseUrl }, }) } diff --git a/apps/web/src/lib/server/integrations/azure-devops/api.ts b/apps/web/src/lib/server/integrations/azure-devops/api.ts index 7690a84ec..cad288800 100644 --- a/apps/web/src/lib/server/integrations/azure-devops/api.ts +++ b/apps/web/src/lib/server/integrations/azure-devops/api.ts @@ -36,11 +36,12 @@ async function azureDevOpsApi( if (!response.ok) { const status = response.status - if (status === 401) throw Object.assign(new Error('Unauthorized'), { status }) - if (status === 403) throw Object.assign(new Error('Forbidden'), { status }) - if (status === 429) throw Object.assign(new Error('Rate limited'), { status }) - if (status >= 500) throw Object.assign(new Error(`Server error ${status}`), { status }) - throw Object.assign(new Error(`HTTP ${status}`), { status }) + const detail = await response.text().catch(() => '') + if (status === 401) throw Object.assign(new Error('Unauthorized'), { status, detail }) + if (status === 403) throw Object.assign(new Error('Forbidden'), { status, detail }) + if (status === 429) throw Object.assign(new Error('Rate limited'), { status, detail }) + if (status >= 500) throw Object.assign(new Error(`Server error ${status}`), { status, detail }) + throw Object.assign(new Error(`HTTP ${status}: ${detail}`), { status, detail }) } return response From 1a74e9fce4cc0e872440804a5f709c8ea4e8b5ea Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Mon, 6 Jul 2026 15:22:03 +0200 Subject: [PATCH 2/3] feat(azure-devops): create work item on status change to Under Review Previously the Azure DevOps hook created a work item for every new post regardless of status. Switch the trigger to post.status_changed, only firing when the post moves to "Under Review". Match by status slug rather than display name, since names are admin-editable but slugs are fixed at creation - threading previousStatusSlug/newStatusSlug through the post.status_changed event payload (dispatch.ts + its three call sites) so the hook survives a future rename of the status. --- .../azure-devops/azure-devops-config.tsx | 7 ++++--- .../lib/server/domains/comments/comment.service.ts | 10 ++++++++-- .../src/lib/server/domains/posts/post.service.ts | 4 +++- .../web/src/lib/server/domains/posts/post.status.ts | 4 +++- apps/web/src/lib/server/events/dispatch.ts | 6 ++++-- apps/web/src/lib/server/events/types.ts | 3 +++ .../lib/server/integrations/azure-devops/hook.ts | 7 +++++-- .../lib/server/integrations/azure-devops/message.ts | 13 ++++--------- 8 files changed, 34 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/admin/settings/integrations/azure-devops/azure-devops-config.tsx b/apps/web/src/components/admin/settings/integrations/azure-devops/azure-devops-config.tsx index fc31e8cd6..8d7bdcf7b 100644 --- a/apps/web/src/components/admin/settings/integrations/azure-devops/azure-devops-config.tsx +++ b/apps/web/src/components/admin/settings/integrations/azure-devops/azure-devops-config.tsx @@ -40,9 +40,10 @@ interface AzureDevOpsConfigProps { const EVENT_CONFIG = [ { - id: 'post.created' as const, - label: 'Create work item from new feedback', - description: 'Automatically create an Azure DevOps work item when new feedback is submitted', + id: 'post.status_changed' as const, + label: 'Create work item when feedback is Under Review', + description: + "Automatically create an Azure DevOps work item when a post's status changes to Under Review", }, ] diff --git a/apps/web/src/lib/server/domains/comments/comment.service.ts b/apps/web/src/lib/server/domains/comments/comment.service.ts index c4c5fef15..b43800771 100644 --- a/apps/web/src/lib/server/domains/comments/comment.service.ts +++ b/apps/web/src/lib/server/domains/comments/comment.service.ts @@ -158,6 +158,8 @@ export async function createComment( let comment: Comment let previousStatusName: string | null = null let newStatusName: string | null = null + let previousStatusSlug: string | null = null + let newStatusSlug: string | null = null if (shouldChangeStatus) { // Fetch new status and current post status in parallel @@ -174,6 +176,8 @@ export async function createComment( previousStatusName = prevStatus?.name ?? 'Open' newStatusName = newStatus.name + previousStatusSlug = prevStatus?.slug ?? 'open' + newStatusSlug = newStatus.slug // Atomic transaction: insert comment + update post status + conditionally increment comment count const result = await db.transaction(async (tx) => { @@ -308,7 +312,7 @@ export async function createComment( ) // Dispatch status change event if status was changed - if (shouldChangeStatus && previousStatusName && newStatusName) { + if (shouldChangeStatus && previousStatusName && newStatusName && newStatusSlug) { await dispatchPostStatusChanged( buildEventActor(author), { @@ -318,7 +322,9 @@ export async function createComment( boardSlug: board.slug, }, previousStatusName, - newStatusName + newStatusName, + previousStatusSlug ?? 'open', + newStatusSlug ) } } diff --git a/apps/web/src/lib/server/domains/posts/post.service.ts b/apps/web/src/lib/server/domains/posts/post.service.ts index f668108cb..0bae104eb 100644 --- a/apps/web/src/lib/server/domains/posts/post.service.ts +++ b/apps/web/src/lib/server/domains/posts/post.service.ts @@ -437,7 +437,9 @@ export async function updatePost( boardSlug: board.slug, }, previousStatusName, - newStatus.name + newStatus.name, + previousStatus?.slug ?? 'open', + newStatus.slug ) createActivity({ diff --git a/apps/web/src/lib/server/domains/posts/post.status.ts b/apps/web/src/lib/server/domains/posts/post.status.ts index bb9094023..dd847f6d6 100644 --- a/apps/web/src/lib/server/domains/posts/post.status.ts +++ b/apps/web/src/lib/server/domains/posts/post.status.ts @@ -82,7 +82,9 @@ export async function changeStatus( boardSlug: board.slug, }, previousStatusName, - newStatus.name + newStatus.name, + prevStatus?.slug ?? 'open', + newStatus.slug ) createActivity({ diff --git a/apps/web/src/lib/server/events/dispatch.ts b/apps/web/src/lib/server/events/dispatch.ts index a7aa5804e..2c4cb941d 100644 --- a/apps/web/src/lib/server/events/dispatch.ts +++ b/apps/web/src/lib/server/events/dispatch.ts @@ -130,12 +130,14 @@ export async function dispatchPostStatusChanged( actor: EventActor, post: PostStatusChangedInput, previousStatus: string, - newStatus: string + newStatus: string, + previousStatusSlug: string, + newStatusSlug: string ): Promise { await dispatchEvent({ ...eventEnvelope(actor), type: 'post.status_changed', - data: { post, previousStatus, newStatus }, + data: { post, previousStatus, newStatus, previousStatusSlug, newStatusSlug }, }) } diff --git a/apps/web/src/lib/server/events/types.ts b/apps/web/src/lib/server/events/types.ts index 1b7c1207e..e8a7df3ec 100644 --- a/apps/web/src/lib/server/events/types.ts +++ b/apps/web/src/lib/server/events/types.ts @@ -93,6 +93,9 @@ export interface PostStatusChangedPayload { post: EventPostRef previousStatus: string newStatus: string + /** Stable identifiers (names are editable) so consumers can match a status across renames. */ + previousStatusSlug: string + newStatusSlug: string } export interface CommentCreatedPayload { diff --git a/apps/web/src/lib/server/integrations/azure-devops/hook.ts b/apps/web/src/lib/server/integrations/azure-devops/hook.ts index 648632abb..9bc4b8d27 100644 --- a/apps/web/src/lib/server/integrations/azure-devops/hook.ts +++ b/apps/web/src/lib/server/integrations/azure-devops/hook.ts @@ -1,6 +1,6 @@ /** * Azure DevOps hook handler. - * Creates work items when feedback events occur. + * Creates a work item when a post's status changes to "Under Review". */ import type { HookHandler, HookResult } from '../../events/hook-types' @@ -12,6 +12,9 @@ import { logger } from '@/lib/server/logger' const log = logger.child({ component: 'azure-devops' }) +/** Slug of the status that triggers work item creation (stable across renames of "Under Review"). */ +const TARGET_STATUS_SLUG = 'under_review' + export interface AzureDevOpsTarget { channelId: string // "projectName:workItemType" } @@ -28,7 +31,7 @@ export const azureDevOpsHook: HookHandler = { const { channelId } = target as AzureDevOpsTarget const { accessToken, organizationName, rootUrl } = config as AzureDevOpsConfig - if (event.type !== 'post.created') { + if (event.type !== 'post.status_changed' || event.data.newStatusSlug !== TARGET_STATUS_SLUG) { return { success: true } } diff --git a/apps/web/src/lib/server/integrations/azure-devops/message.ts b/apps/web/src/lib/server/integrations/azure-devops/message.ts index 9b74b1bc9..455046ff8 100644 --- a/apps/web/src/lib/server/integrations/azure-devops/message.ts +++ b/apps/web/src/lib/server/integrations/azure-devops/message.ts @@ -4,26 +4,21 @@ */ import type { EventData } from '../../events/types' -import { stripHtml, truncate } from '../../events/hook-utils' -import { buildPostUrl, escapeHtml, getAuthorName } from '../message-utils' +import { buildPostUrl, escapeHtml } from '../message-utils' export function buildAzureDevOpsWorkItemBody( event: EventData, rootUrl: string ): { title: string; description: string } { - if (event.type !== 'post.created') { + if (event.type !== 'post.status_changed') { return { title: 'Feedback', description: '' } } - const { post } = event.data + const { post, previousStatus, newStatus } = event.data const postUrl = buildPostUrl(rootUrl, post.boardSlug, post.id) - const content = truncate(stripHtml(post.content), 2000) - const author = getAuthorName(post) const description = [ - `

${escapeHtml(content)}

`, - '
', - `

Submitted by: ${escapeHtml(author)}

`, + `

Status: ${escapeHtml(previousStatus)} → ${escapeHtml(newStatus)}

`, `

Board: ${escapeHtml(post.boardSlug)}

`, `

View in Quackback

`, ].join('\n') From 2444abc1e28ae42ca1804af8a061ad958b1ee311 Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Thu, 16 Jul 2026 11:10:29 +0200 Subject: [PATCH 3/3] changing azure wi creation logic so it only creates items that are planned in QB. --- .../integrations/azure-devops/azure-devops-config.tsx | 4 ++-- apps/web/src/lib/server/integrations/azure-devops/hook.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/admin/settings/integrations/azure-devops/azure-devops-config.tsx b/apps/web/src/components/admin/settings/integrations/azure-devops/azure-devops-config.tsx index 8d7bdcf7b..5e862bd3d 100644 --- a/apps/web/src/components/admin/settings/integrations/azure-devops/azure-devops-config.tsx +++ b/apps/web/src/components/admin/settings/integrations/azure-devops/azure-devops-config.tsx @@ -41,9 +41,9 @@ interface AzureDevOpsConfigProps { const EVENT_CONFIG = [ { id: 'post.status_changed' as const, - label: 'Create work item when feedback is Under Review', + label: 'Create work item when feedback is Planned', description: - "Automatically create an Azure DevOps work item when a post's status changes to Under Review", + "Automatically create an Azure DevOps work item when a post's status changes to Planned", }, ] diff --git a/apps/web/src/lib/server/integrations/azure-devops/hook.ts b/apps/web/src/lib/server/integrations/azure-devops/hook.ts index 9bc4b8d27..03f4c35d3 100644 --- a/apps/web/src/lib/server/integrations/azure-devops/hook.ts +++ b/apps/web/src/lib/server/integrations/azure-devops/hook.ts @@ -1,6 +1,6 @@ /** * Azure DevOps hook handler. - * Creates a work item when a post's status changes to "Under Review". + * Creates a work item when a post's status changes to "Planned". */ import type { HookHandler, HookResult } from '../../events/hook-types' @@ -12,8 +12,8 @@ import { logger } from '@/lib/server/logger' const log = logger.child({ component: 'azure-devops' }) -/** Slug of the status that triggers work item creation (stable across renames of "Under Review"). */ -const TARGET_STATUS_SLUG = 'under_review' +/** Slug of the status that triggers work item creation (stable across renames of "Planned"). */ +const TARGET_STATUS_SLUG = 'planned' export interface AzureDevOpsTarget { channelId: string // "projectName:workItemType"