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..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 @@ -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 Planned', + description: + "Automatically create an Azure DevOps work item when a post's status changes to Planned", }, ] 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/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/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/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 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..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 work items when feedback events occur. + * Creates a work item when a post's status changes to "Planned". */ 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 "Planned"). */ +const TARGET_STATUS_SLUG = 'planned' + 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')