From 29a9e41e5a0e93237bf14817aceb41e9fa26dbea Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 13 Jan 2026 11:51:47 -0500 Subject: [PATCH] feat: sync working group posts to Slack channels Automatically share published working group posts to their linked Slack channels. When a post is published, a formatted message is sent to the working group's Slack channel (if configured). - Add notifyWorkingGroupSlackChannel() for posting to specific channels - Add notifyPublishedPost() wrapper that checks privacy and channel config - Update committees.ts routes to use new notification function - Add notifications to content.ts for propose/approve endpoints - Skip members-only posts to respect privacy - Use Block Kit for rich message formatting Closes #719 Co-Authored-By: Claude Opus 4.5 --- .changeset/eleven-wolves-return.md | 2 + server/src/notifications/slack.ts | 135 +++++++++++++++++++++++++++++ server/src/routes/committees.ts | 49 +++++++---- server/src/routes/content.ts | 50 +++++++++-- 4 files changed, 213 insertions(+), 23 deletions(-) create mode 100644 .changeset/eleven-wolves-return.md diff --git a/.changeset/eleven-wolves-return.md b/.changeset/eleven-wolves-return.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/eleven-wolves-return.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/notifications/slack.ts b/server/src/notifications/slack.ts index 81cc79b88c..b29126cfb0 100644 --- a/server/src/notifications/slack.ts +++ b/server/src/notifications/slack.ts @@ -3,8 +3,11 @@ */ import { logger } from '../logger.js'; +import { sendChannelMessage, isSlackConfigured } from '../slack/client.js'; +import type { SlackBlockMessage } from '../slack/types.js'; const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL; +const APP_URL = process.env.APP_URL || 'https://agenticadvertising.org'; interface SlackMessage { text: string; @@ -381,3 +384,135 @@ export async function notifyWorkingGroupPost(data: { ], }); } + +/** + * Send a notification to a working group's Slack channel about a published post. + * Uses the chapter bot to post directly to the channel. + */ +export async function notifyWorkingGroupSlackChannel(data: { + slackChannelId: string; + workingGroupName: string; + workingGroupSlug: string; + postTitle: string; + postSlug: string; + authorName: string; + contentType: 'article' | 'link'; + excerpt?: string; + externalUrl?: string; + category?: string; +}): Promise { + if (!isSlackConfigured()) { + logger.debug('Slack not configured, skipping channel notification'); + return false; + } + + const emoji = data.contentType === 'link' ? '🔗' : '📝'; + const headerText = data.contentType === 'link' ? 'New Link Shared' : 'New Post Published'; + const postUrl = `${APP_URL}/working-groups/${data.workingGroupSlug}#post-${data.postSlug}`; + + // Build message blocks + const message: SlackBlockMessage = { + text: `${emoji} ${headerText} in ${data.workingGroupName}: ${data.postTitle}`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text' as const, + text: `${emoji} ${headerText}`, + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `*<${postUrl}|${data.postTitle}>*${data.excerpt ? `\n${data.excerpt}` : ''}`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `Posted by ${data.authorName}${data.category ? ` • ${data.category}` : ''}`, + }, + }, + // Add external URL for link posts + ...(data.contentType === 'link' && data.externalUrl ? [{ + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `🔗 ${data.externalUrl}`, + }, + }] : []), + ], + }; + + try { + const result = await sendChannelMessage(data.slackChannelId, message); + if (result.ok) { + logger.info( + { channelId: data.slackChannelId, postSlug: data.postSlug }, + 'Sent working group post notification to Slack channel' + ); + return true; + } else { + logger.warn( + { channelId: data.slackChannelId, error: result.error }, + 'Failed to send working group post notification' + ); + return false; + } + } catch (error) { + logger.error({ error, channelId: data.slackChannelId }, 'Error sending Slack channel notification'); + return false; + } +} + +/** + * Notify a working group's Slack channel about a published post. + * Checks if the post is members-only (skips) and if the working group has a channel configured. + * + * @param slackChannelId - The Slack channel ID (required) + */ +export async function notifyPublishedPost(data: { + slackChannelId?: string; + workingGroupName: string; + workingGroupSlug: string; + postTitle: string; + postSlug: string; + authorName: string; + contentType: 'article' | 'link'; + excerpt?: string; + externalUrl?: string; + category?: string; + isMembersOnly: boolean; +}): Promise { + // Skip members-only posts - don't share private content to channels + if (data.isMembersOnly) { + logger.debug({ postSlug: data.postSlug }, 'Skipping Slack notification for members-only post'); + return; + } + + // Skip if no channel ID provided + if (!data.slackChannelId) { + logger.debug( + { workingGroupSlug: data.workingGroupSlug }, + 'No Slack channel configured for working group, skipping notification' + ); + return; + } + + // Send notification (fire-and-forget, errors are logged internally) + await notifyWorkingGroupSlackChannel({ + slackChannelId: data.slackChannelId, + workingGroupName: data.workingGroupName, + workingGroupSlug: data.workingGroupSlug, + postTitle: data.postTitle, + postSlug: data.postSlug, + authorName: data.authorName, + contentType: data.contentType, + excerpt: data.excerpt, + externalUrl: data.externalUrl, + category: data.category, + }); +} diff --git a/server/src/routes/committees.ts b/server/src/routes/committees.ts index a29d879541..3772b6da7b 100644 --- a/server/src/routes/committees.ts +++ b/server/src/routes/committees.ts @@ -14,7 +14,7 @@ import { WorkingGroupDatabase } from "../db/working-group-db.js"; import { eventsDb } from "../db/events-db.js"; import { invalidateMemberContextCache } from "../addie/index.js"; import { syncWorkingGroupMembersFromSlack, syncAllWorkingGroupMembersFromSlack } from "../slack/sync.js"; -import { notifyWorkingGroupPost } from "../notifications/slack.js"; +import { notifyPublishedPost } from "../notifications/slack.js"; import { decodeHtmlEntities } from "../utils/html-entities.js"; import { reindexDocument } from "../addie/jobs/committee-document-indexer.js"; import { createChannel, setChannelPurpose } from "../slack/client.js"; @@ -1255,19 +1255,22 @@ export function createCommitteeRouters(): { ] ); - if (!finalMembersOnly) { - notifyWorkingGroupPost({ - workingGroupName: group.name, - workingGroupSlug: slug, - postTitle: title, - postSlug: post_slug, - authorName, - contentType: content_type || 'article', - category: category || undefined, - }).catch(err => { - logger.warn({ err }, 'Failed to send Slack notification for working group post'); - }); - } + // Send Slack notification to the working group's channel + notifyPublishedPost({ + slackChannelId: group.slack_channel_id ?? undefined, + workingGroupName: group.name, + workingGroupSlug: slug, + postTitle: title, + postSlug: post_slug, + authorName, + contentType: content_type || 'article', + excerpt: excerpt || undefined, + externalUrl: external_url || undefined, + category: category || undefined, + isMembersOnly: finalMembersOnly, + }).catch(err => { + logger.warn({ err }, 'Failed to send Slack channel notification for working group post'); + }); res.status(201).json({ post: result.rows[0] }); } catch (error) { @@ -1975,17 +1978,22 @@ export function createCommitteeRouters(): { const createdPost = result.rows[0]; + // Send Slack notification to the working group's channel if (status === 'published') { - notifyWorkingGroupPost({ + notifyPublishedPost({ + slackChannelId: group.slack_channel_id ?? undefined, workingGroupName: group.name, workingGroupSlug: slug, postTitle: title, postSlug: post_slug, authorName: authorNameFinal, contentType: content_type || 'article', + excerpt: excerpt || undefined, + externalUrl: external_url || undefined, category: category || undefined, + isMembersOnly: is_members_only || false, }).catch(err => { - logger.warn({ err }, 'Failed to send Slack notification for working group post'); + logger.warn({ err }, 'Failed to send Slack channel notification for working group post'); }); } @@ -2099,17 +2107,22 @@ export function createCommitteeRouters(): { const updatedPost = result.rows[0]; + // Send Slack notification when post transitions to published if (willBePublished && !wasPublished) { - notifyWorkingGroupPost({ + notifyPublishedPost({ + slackChannelId: group.slack_channel_id ?? undefined, workingGroupName: group.name, workingGroupSlug: slug, postTitle: updatedPost.title, postSlug: updatedPost.slug, authorName: updatedPost.author_name || 'Unknown', contentType: updatedPost.content_type || 'article', + excerpt: updatedPost.excerpt || undefined, + externalUrl: updatedPost.external_url || undefined, category: updatedPost.category || undefined, + isMembersOnly: updatedPost.is_members_only || false, }).catch(err => { - logger.warn({ err }, 'Failed to send Slack notification for working group post'); + logger.warn({ err }, 'Failed to send Slack channel notification for working group post'); }); } diff --git a/server/src/routes/content.ts b/server/src/routes/content.ts index c6adc382c2..5563baf164 100644 --- a/server/src/routes/content.ts +++ b/server/src/routes/content.ts @@ -14,6 +14,7 @@ import { requireAuth } from '../middleware/auth.js'; import { getPool } from '../db/client.js'; import { isWebUserAdmin } from '../addie/mcp/admin-tools.js'; import { sendChannelMessage } from '../slack/client.js'; +import { notifyPublishedPost } from '../notifications/slack.js'; const logger = createLogger('content-routes'); @@ -248,7 +249,7 @@ export function createContentRouter(): Router { // Resolve the collection (working group) const committeeResult = await pool.query( - `SELECT id, accepts_public_submissions FROM working_groups WHERE slug = $1`, + `SELECT id, name, accepts_public_submissions, slack_channel_id FROM working_groups WHERE slug = $1`, [committeeSlug] ); @@ -259,8 +260,11 @@ export function createContentRouter(): Router { }); } - const committeeId = committeeResult.rows[0].id as string; - const acceptsPublicSubmissions = committeeResult.rows[0].accepts_public_submissions; + const committee = committeeResult.rows[0]; + const committeeId = committee.id as string; + const committeeName = committee.name as string; + const committeeSlackChannelId = committee.slack_channel_id as string | null; + const acceptsPublicSubmissions = committee.accepts_public_submissions; // Check if user can submit to this collection const userIsLead = await isCommitteeLead(committeeId, user.id); @@ -352,6 +356,23 @@ export function createContentRouter(): Router { notifyWorkingGroupOfPendingContent(committeeId, perspective, authorName).catch(err => { logger.error({ err, perspectiveId: perspective.id, committeeId, authorName }, 'Failed to send content notification'); }); + } else if (status === 'published') { + // Send Slack notification to the working group's channel + notifyPublishedPost({ + slackChannelId: committeeSlackChannelId ?? undefined, + workingGroupName: committeeName, + workingGroupSlug: committeeSlug, + postTitle: title, + postSlug: perspective.slug, + authorName, + contentType: content_type, + excerpt: excerpt || undefined, + externalUrl: external_url || undefined, + category: category || undefined, + isMembersOnly: false, // Content proposed through unified system is public + }).catch(err => { + logger.warn({ err }, 'Failed to send Slack channel notification for proposed content'); + }); } const message = canPublishDirectly @@ -490,9 +511,9 @@ export function createContentRouter(): Router { const { publish_immediately = true } = req.body; const pool = getPool(); - // Get the content + // Get the content with working group details const contentResult = await pool.query( - `SELECT p.*, wg.slug as committee_slug + `SELECT p.*, wg.slug as committee_slug, wg.name as committee_name, wg.slack_channel_id FROM perspectives p LEFT JOIN working_groups wg ON wg.id = p.working_group_id WHERE p.id = $1`, @@ -547,6 +568,25 @@ export function createContentRouter(): Router { committeeSlug: content.committee_slug, }, 'Content approved'); + // Send Slack notification when content is published + if (newStatus === 'published' && content.committee_slug) { + notifyPublishedPost({ + slackChannelId: content.slack_channel_id ?? undefined, + workingGroupName: content.committee_name, + workingGroupSlug: content.committee_slug, + postTitle: content.title, + postSlug: content.slug, + authorName: content.author_name || 'Unknown', + contentType: content.content_type || 'article', + excerpt: content.excerpt || undefined, + externalUrl: content.external_url || undefined, + category: content.category || undefined, + isMembersOnly: content.is_members_only || false, + }).catch(err => { + logger.warn({ err }, 'Failed to send Slack channel notification for approved content'); + }); + } + res.json({ success: true, status: newStatus,