Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/eleven-wolves-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
135 changes: 135 additions & 0 deletions server/src/notifications/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean> {
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<void> {
// 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,
});
}
49 changes: 31 additions & 18 deletions server/src/routes/committees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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');
});
}

Expand Down Expand Up @@ -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');
});
}

Expand Down
50 changes: 45 additions & 5 deletions server/src/routes/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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]
);

Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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,
Expand Down