diff --git a/.changeset/few-rivers-travel.md b/.changeset/few-rivers-travel.md
new file mode 100644
index 0000000000..abd698c584
--- /dev/null
+++ b/.changeset/few-rivers-travel.md
@@ -0,0 +1,9 @@
+---
+---
+
+Add topic Slack channels for working groups
+
+- Working group topics can now have an optional Slack channel ID
+- New invite mode 'slack_channel' allows scheduling meetings for Slack channel members
+- Admin UI for managing topics with Slack channel linking
+- Addie can manage topics via manage_committee_topics tool
diff --git a/server/public/admin-meetings.html b/server/public/admin-meetings.html
index 88c10ad2f3..3e5feab888 100644
--- a/server/public/admin-meetings.html
+++ b/server/public/admin-meetings.html
@@ -317,7 +317,7 @@
@@ -808,6 +848,149 @@
${escapeHtml(group.name)}
// Current leaders in the form
let currentLeaders = [];
+ // Current topics in the form
+ let currentTopics = [];
+
+ // Topic management functions
+ function showAddTopicForm() {
+ document.getElementById('editingTopicIndex').value = '-1';
+ document.getElementById('topicName').value = '';
+ document.getElementById('topicSlug').value = '';
+ document.getElementById('topicDescription').value = '';
+ document.getElementById('topicSlackUrl').value = '';
+ validateTopicSlackUrl();
+ document.getElementById('topicForm').style.display = 'block';
+ }
+
+ function editTopic(index) {
+ const topic = currentTopics[index];
+ if (!topic) return;
+
+ document.getElementById('editingTopicIndex').value = index;
+ document.getElementById('topicName').value = topic.name || '';
+ document.getElementById('topicSlug').value = topic.slug || '';
+ document.getElementById('topicDescription').value = topic.description || '';
+ // Convert channel ID back to URL format for editing
+ document.getElementById('topicSlackUrl').value = topic.slack_channel_id
+ ? `https://agenticads.slack.com/archives/${topic.slack_channel_id}`
+ : '';
+ validateTopicSlackUrl();
+ document.getElementById('topicForm').style.display = 'block';
+ }
+
+ function cancelTopicEdit() {
+ document.getElementById('topicForm').style.display = 'none';
+ }
+
+ function autoGenerateTopicSlug() {
+ const name = document.getElementById('topicName').value;
+ const slugInput = document.getElementById('topicSlug');
+ // Only auto-generate if slug is empty or matches previous auto-generation
+ if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') {
+ const slug = name.toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ slugInput.value = slug;
+ slugInput.dataset.autoGenerated = 'true';
+ }
+ }
+
+ function validateTopicSlackUrl() {
+ const url = document.getElementById('topicSlackUrl').value.trim();
+ const statusEl = document.getElementById('topicSlackUrlStatus');
+
+ if (!url) {
+ statusEl.innerHTML = '
Optional - link a separate channel for this topic\'s discussions ';
+ return;
+ }
+
+ const channelId = extractSlackChannelId(url);
+ if (channelId) {
+ statusEl.innerHTML = `
ā Channel ID: ${escapeHtml(channelId)} `;
+ } else {
+ statusEl.innerHTML = '
ā Could not detect channel ID from URL ';
+ }
+ }
+
+ function saveTopic() {
+ const name = document.getElementById('topicName').value.trim();
+ const slug = document.getElementById('topicSlug').value.trim();
+ const description = document.getElementById('topicDescription').value.trim();
+ const slackUrl = document.getElementById('topicSlackUrl').value.trim();
+ const editingIndex = parseInt(document.getElementById('editingTopicIndex').value);
+
+ if (!name || !slug) {
+ alert('Topic name and slug are required');
+ return;
+ }
+
+ // Validate slug format
+ if (!/^[a-z0-9-]+$/.test(slug)) {
+ alert('Slug must contain only lowercase letters, numbers, and hyphens');
+ return;
+ }
+
+ // Check for duplicate slugs (excluding current topic if editing)
+ const duplicateIndex = currentTopics.findIndex((t, i) => t.slug === slug && i !== editingIndex);
+ if (duplicateIndex !== -1) {
+ alert('A topic with this slug already exists');
+ return;
+ }
+
+ const topic = {
+ name,
+ slug,
+ description: description || undefined,
+ slack_channel_id: slackUrl ? extractSlackChannelId(slackUrl) : undefined,
+ };
+
+ if (editingIndex >= 0) {
+ currentTopics[editingIndex] = topic;
+ } else {
+ currentTopics.push(topic);
+ }
+
+ renderTopicsList();
+ cancelTopicEdit();
+ }
+
+ function removeTopic(index) {
+ if (!confirm('Remove this topic?')) return;
+ currentTopics.splice(index, 1);
+ renderTopicsList();
+ }
+
+ function renderTopicsList() {
+ const listDiv = document.getElementById('topicsList');
+
+ if (currentTopics.length === 0) {
+ listDiv.innerHTML = '
No topics configured
';
+ return;
+ }
+
+ listDiv.innerHTML = currentTopics.map((t, i) => `
+
+
+
${escapeHtml(t.name)}
+
(${escapeHtml(t.slug)})
+ ${t.slack_channel_id ? `
š¢ ${escapeHtml(t.slack_channel_id)} ` : ''}
+ ${t.description ? `
${escapeHtml(t.description)}
` : ''}
+
+
+ Edit
+ Remove
+
+
+ `).join('');
+ }
+
+ function clearTopics() {
+ currentTopics = [];
+ renderTopicsList();
+ }
+
// Search users
async function searchUsers(type) {
const inputId = type === 'leader' ? 'leaderSearch' : 'memberSearch';
@@ -1183,6 +1366,8 @@
${escapeHtml(group.name)}
document.getElementById('gatheringWebsite').value = '';
document.getElementById('gatheringLogo').value = '';
clearLeaders();
+ clearTopics();
+ cancelTopicEdit(); // Hide topic form if open
validateSlackUrl(); // Reset status message
updateRegionVisibility(); // Hide region for non-chapters, hide gathering fields for non-gatherings
updateMembersSectionVisibility(); // Hide members section for new groups
@@ -1226,6 +1411,16 @@
${escapeHtml(group.name)}
}));
renderLeadersList();
+ // Topics
+ currentTopics = (editingGroup.topics || []).map(t => ({
+ name: t.name,
+ slug: t.slug,
+ description: t.description,
+ slack_channel_id: t.slack_channel_id
+ }));
+ renderTopicsList();
+ cancelTopicEdit();
+
// Show members section for existing groups
updateMembersSectionVisibility();
await loadMembers(id);
@@ -1257,6 +1452,10 @@
${escapeHtml(group.name)}
document.getElementById('interestSection').style.display = 'none';
document.getElementById('interestList').innerHTML = '';
+ // Reset topics section
+ clearTopics();
+ cancelTopicEdit();
+
// Reset committee type fields
document.getElementById('committeeType').value = 'working_group';
document.getElementById('region').value = '';
@@ -1281,6 +1480,12 @@
${escapeHtml(group.name)}
is_private: document.getElementById('isPrivate').checked,
display_order: parseInt(document.getElementById('displayOrder').value) || 0,
leader_user_ids: currentLeaders.map(l => l.user_id),
+ topics: currentTopics.map(t => ({
+ name: t.name,
+ slug: t.slug,
+ description: t.description || undefined,
+ slack_channel_id: t.slack_channel_id || undefined
+ })),
// Industry gathering-specific fields
event_start_date: isGathering ? (document.getElementById('gatheringStartDate').value || null) : null,
event_end_date: isGathering ? (document.getElementById('gatheringEndDate').value || null) : null,
diff --git a/server/src/addie/mcp/meeting-tools.ts b/server/src/addie/mcp/meeting-tools.ts
index ae3915d1db..6c3be4ab62 100644
--- a/server/src/addie/mcp/meeting-tools.ts
+++ b/server/src/addie/mcp/meeting-tools.ts
@@ -263,8 +263,12 @@ Example prompts this handles:
},
invite_mode: {
type: 'string',
- enum: ['all_members', 'topic_subscribers', 'none'],
- description: 'Who to invite: all_members (default - invite everyone in the working group), topic_subscribers (only those subscribed to the topics), or none (opt-in - create meeting but let people join themselves)',
+ enum: ['all_members', 'topic_subscribers', 'slack_channel', 'none'],
+ description: 'Who to invite: all_members (default - invite everyone in the working group), topic_subscribers (only those subscribed to the topics), slack_channel (invite members of a specific Slack channel - requires invite_slack_channel_id), or none (opt-in - create meeting but let people join themselves)',
+ },
+ invite_slack_channel_id: {
+ type: 'string',
+ description: 'Slack channel ID to invite members from (required when invite_mode is slack_channel). Can be found in the channel URL or settings.',
},
recurrence: {
type: 'object',
@@ -467,6 +471,42 @@ IMPORTANT: For start_time, provide the time in the user's timezone WITHOUT a Z s
required: ['working_group_slug', 'topic_slugs'],
},
},
+ {
+ name: 'manage_committee_topics',
+ description: `Manage topics for a working group/committee. Topics help organize meetings and filter invitations. Each topic can optionally have its own Slack channel for subgroup discussions. Use action='list' to see current topics, action='add' to create a new topic, action='update' to modify an existing topic, or action='remove' to delete a topic.`,
+ usage_hints: 'use when someone wants to add a topic to a working group, update topic channels, or see what topics exist',
+ input_schema: {
+ type: 'object' as const,
+ properties: {
+ working_group_slug: {
+ type: 'string',
+ description: 'Working group slug (e.g., "technical", "governance")',
+ },
+ action: {
+ type: 'string',
+ enum: ['list', 'add', 'update', 'remove'],
+ description: 'Action to perform: list (show topics), add (create new), update (modify), remove (delete)',
+ },
+ topic_slug: {
+ type: 'string',
+ description: 'Topic slug for add/update/remove actions',
+ },
+ topic_name: {
+ type: 'string',
+ description: 'Display name for the topic (required for add, optional for update)',
+ },
+ topic_description: {
+ type: 'string',
+ description: 'Optional description of what this topic covers',
+ },
+ slack_channel_id: {
+ type: 'string',
+ description: 'Optional Slack channel ID for this topic (e.g., C09HEERCY8P). Members of this channel can be invited to topic meetings.',
+ },
+ },
+ required: ['working_group_slug', 'action'],
+ },
+ },
];
/**
@@ -660,7 +700,13 @@ export function createMeetingToolHandlers(
}
// One-time meeting
- const inviteMode = input.invite_mode as 'all_members' | 'topic_subscribers' | 'none' | undefined;
+ const inviteMode = input.invite_mode as 'all_members' | 'topic_subscribers' | 'slack_channel' | 'none' | undefined;
+ const inviteSlackChannelId = input.invite_slack_channel_id as string | undefined;
+
+ // Validate slack_channel mode has a channel ID
+ if (inviteMode === 'slack_channel' && !inviteSlackChannelId) {
+ return `ā When using invite_mode='slack_channel', you must also provide invite_slack_channel_id.`;
+ }
try {
const result = await meetingService.scheduleMeeting({
@@ -674,6 +720,7 @@ export function createMeetingToolHandlers(
timezone,
createdByUserId: getUserId(),
inviteMode,
+ inviteSlackChannelId,
});
let response = `ā
Scheduled: **${title}**\n\n`;
@@ -690,6 +737,8 @@ export function createMeetingToolHandlers(
response += result.errors.map(e => `⢠${e}`).join('\n');
} else if (inviteMode === 'none') {
response += `\nš Meeting created as **opt-in** - no invites sent. Members can join using the Zoom link.`;
+ } else if (inviteMode === 'slack_channel') {
+ response += `\nš§ Calendar invites sent to Slack channel members.`;
} else if (inviteMode === 'topic_subscribers') {
response += `\nš§ Calendar invites sent to topic subscribers.`;
} else {
@@ -1211,5 +1260,119 @@ export function createMeetingToolHandlers(
return `ā
Updated topic subscriptions for ${workingGroup.name}:\n${validTopics.map(t => `⢠${t}`).join('\n')}\n\nYou'll receive meeting invites for these topics.`;
});
+ // Manage committee topics (add, update, remove, list)
+ handlers.set('manage_committee_topics', async (input) => {
+ const permCheck = await checkSchedulePermission();
+ if (permCheck) return permCheck;
+
+ const workingGroupSlug = input.working_group_slug as string;
+ const action = input.action as 'list' | 'add' | 'update' | 'remove';
+
+ const workingGroup = await workingGroupDb.getWorkingGroupBySlug(workingGroupSlug);
+ if (!workingGroup) {
+ return `ā Working group not found: "${workingGroupSlug}"`;
+ }
+
+ const currentTopics = workingGroup.topics || [];
+
+ // List topics
+ if (action === 'list') {
+ if (currentTopics.length === 0) {
+ return `š **${workingGroup.name}** has no topics configured yet.\n\nUse action='add' to create a topic for organizing meetings and invitations.`;
+ }
+
+ let response = `š **Topics for ${workingGroup.name}:**\n\n`;
+ for (const topic of currentTopics) {
+ response += `**${topic.name}** (\`${topic.slug}\`)\n`;
+ if (topic.description) {
+ response += ` ${topic.description}\n`;
+ }
+ if (topic.slack_channel_id) {
+ response += ` š¢ Slack channel: ${topic.slack_channel_id}\n`;
+ }
+ response += '\n';
+ }
+ return response.trim();
+ }
+
+ // All other actions require topic_slug
+ const topicSlug = input.topic_slug as string | undefined;
+ if (!topicSlug) {
+ return `ā topic_slug is required for action='${action}'`;
+ }
+
+ // Validate topic slug format (lowercase letters, numbers, hyphens only)
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(topicSlug)) {
+ return `ā Invalid topic slug "${topicSlug}". Slugs must be lowercase letters, numbers, and hyphens only (e.g., "my-topic-1").`;
+ }
+
+ // Add topic
+ if (action === 'add') {
+ const topicName = input.topic_name as string | undefined;
+ if (!topicName) {
+ return `ā topic_name is required when adding a new topic`;
+ }
+
+ // Check for duplicate slug
+ if (currentTopics.some(t => t.slug === topicSlug)) {
+ return `ā A topic with slug "${topicSlug}" already exists in ${workingGroup.name}`;
+ }
+
+ const newTopic = {
+ slug: topicSlug,
+ name: topicName,
+ description: input.topic_description as string | undefined,
+ slack_channel_id: input.slack_channel_id as string | undefined,
+ };
+
+ const updatedTopics = [...currentTopics, newTopic];
+ await workingGroupDb.updateWorkingGroup(workingGroup.id, { topics: updatedTopics });
+
+ let response = `ā
Added topic **${topicName}** (\`${topicSlug}\`) to ${workingGroup.name}`;
+ if (newTopic.slack_channel_id) {
+ response += `\nš¢ Linked Slack channel: ${newTopic.slack_channel_id}`;
+ }
+ return response;
+ }
+
+ // Find existing topic for update/remove
+ const existingIndex = currentTopics.findIndex(t => t.slug === topicSlug);
+ if (existingIndex === -1) {
+ return `ā Topic "${topicSlug}" not found in ${workingGroup.name}`;
+ }
+
+ // Update topic
+ if (action === 'update') {
+ const existing = currentTopics[existingIndex];
+ const updatedTopic = {
+ ...existing,
+ name: (input.topic_name as string | undefined) || existing.name,
+ description: input.topic_description !== undefined ? (input.topic_description as string | undefined) : existing.description,
+ slack_channel_id: input.slack_channel_id !== undefined ? (input.slack_channel_id as string | undefined) : existing.slack_channel_id,
+ };
+
+ const updatedTopics = [...currentTopics];
+ updatedTopics[existingIndex] = updatedTopic;
+ await workingGroupDb.updateWorkingGroup(workingGroup.id, { topics: updatedTopics });
+
+ let response = `ā
Updated topic **${updatedTopic.name}** (\`${topicSlug}\`) in ${workingGroup.name}`;
+ if (updatedTopic.slack_channel_id) {
+ response += `\nš¢ Linked Slack channel: ${updatedTopic.slack_channel_id}`;
+ }
+ return response;
+ }
+
+ // Remove topic
+ if (action === 'remove') {
+ const removedTopic = currentTopics[existingIndex];
+ const updatedTopics = currentTopics.filter((_, i) => i !== existingIndex);
+ await workingGroupDb.updateWorkingGroup(workingGroup.id, { topics: updatedTopics });
+
+ return `ā
Removed topic **${removedTopic.name}** (\`${topicSlug}\`) from ${workingGroup.name}`;
+ }
+
+ return `ā Unknown action: ${action}`;
+ });
+
return handlers;
}
diff --git a/server/src/db/meetings-db.ts b/server/src/db/meetings-db.ts
index 3429826397..581b6bbe08 100644
--- a/server/src/db/meetings-db.ts
+++ b/server/src/db/meetings-db.ts
@@ -32,8 +32,8 @@ export class MeetingsDatabase {
`INSERT INTO meeting_series (
working_group_id, title, description, topic_slugs,
recurrence_rule, default_start_time, duration_minutes,
- timezone, invite_mode, created_by_user_id
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ timezone, invite_mode, invite_slack_channel_id, created_by_user_id
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *`,
[
input.working_group_id,
@@ -45,6 +45,7 @@ export class MeetingsDatabase {
input.duration_minutes ?? 60,
input.timezone ?? 'America/New_York',
input.invite_mode ?? 'topic_subscribers',
+ input.invite_slack_channel_id || null,
input.created_by_user_id || null,
]
);
@@ -81,6 +82,7 @@ export class MeetingsDatabase {
google_calendar_id: 'google_calendar_id',
google_event_series_id: 'google_event_series_id',
invite_mode: 'invite_mode',
+ invite_slack_channel_id: 'invite_slack_channel_id',
status: 'status',
};
@@ -742,4 +744,66 @@ export class MeetingsDatabase {
[workingGroupId, topicSlug]
);
}
+
+ /**
+ * Add attendees from a Slack channel to a meeting.
+ * Looks up channel members in slack_user_mappings and adds those with WorkOS mappings.
+ * @param meetingId The meeting to add attendees to
+ * @param slackChannelMembers Array of Slack user IDs from the channel
+ * @returns Number of attendees added
+ */
+ async addAttendeesFromSlackChannel(
+ meetingId: string,
+ slackChannelMembers: string[]
+ ): Promise
{
+ if (slackChannelMembers.length === 0) {
+ return 0;
+ }
+
+ // Look up Slack users who have WorkOS mappings and get their user info
+ const result = await query<{
+ workos_user_id: string;
+ email: string;
+ name: string;
+ }>(
+ `SELECT
+ m.workos_user_id,
+ COALESCE(u.email, m.slack_email) as email,
+ COALESCE(u.first_name || ' ' || u.last_name, m.slack_real_name, m.slack_display_name) as name
+ FROM slack_user_mappings m
+ LEFT JOIN users u ON u.workos_user_id = m.workos_user_id
+ WHERE m.slack_user_id = ANY($1)
+ AND m.workos_user_id IS NOT NULL
+ AND m.slack_is_bot = false
+ AND m.slack_is_deleted = false`,
+ [slackChannelMembers]
+ );
+
+ if (result.rows.length === 0) {
+ return 0;
+ }
+
+ // Bulk insert attendees
+ const values = result.rows.map((_, i) => {
+ const base = i * 4;
+ return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, 'pending', 'auto')`;
+ }).join(', ');
+
+ const insertParams = result.rows.flatMap(m => [
+ meetingId,
+ m.workos_user_id,
+ m.email,
+ m.name,
+ ]);
+
+ const insertResult = await query(
+ `INSERT INTO meeting_attendees (meeting_id, workos_user_id, email, name, rsvp_status, invite_source)
+ VALUES ${values}
+ ON CONFLICT (meeting_id, workos_user_id) DO NOTHING`,
+ insertParams
+ );
+
+ // Return actual number of rows inserted (accounts for conflicts)
+ return insertResult.rowCount || 0;
+ }
}
diff --git a/server/src/db/migrations/175_topic_slack_channels.sql b/server/src/db/migrations/175_topic_slack_channels.sql
new file mode 100644
index 0000000000..409f9a52ee
--- /dev/null
+++ b/server/src/db/migrations/175_topic_slack_channels.sql
@@ -0,0 +1,24 @@
+-- Migration: 173_topic_slack_channels.sql
+-- Allow topics to have their own Slack channels for targeted meeting invitations
+
+-- =====================================================
+-- MEETING SERIES: Add slack_channel invite mode
+-- =====================================================
+
+-- Drop and recreate the invite_mode constraint to include 'slack_channel'
+ALTER TABLE meeting_series
+DROP CONSTRAINT IF EXISTS meeting_series_invite_mode_check;
+
+ALTER TABLE meeting_series
+ADD CONSTRAINT meeting_series_invite_mode_check
+CHECK (invite_mode IN ('all_members', 'topic_subscribers', 'slack_channel', 'manual'));
+
+-- Add column for explicit Slack channel to invite from
+ALTER TABLE meeting_series
+ADD COLUMN IF NOT EXISTS invite_slack_channel_id VARCHAR(50);
+
+COMMENT ON COLUMN meeting_series.invite_slack_channel_id IS 'Slack channel ID to pull invitees from when invite_mode is slack_channel';
+
+-- Note: working_groups.topics is JSONB, so adding slack_channel_id to topic objects
+-- doesn't require a schema change - it's just an optional field in the JSON structure.
+-- Topic structure: {slug, name, description?, slack_channel_id?}
diff --git a/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts
index ff86c68652..bc25a4b051 100644
--- a/server/src/db/working-group-db.ts
+++ b/server/src/db/working-group-db.ts
@@ -97,8 +97,8 @@ export class WorkingGroupDatabase {
name, slug, description, slack_channel_url, slack_channel_id,
is_private, status, display_order, committee_type, region,
linked_event_id, event_start_date, event_end_date, event_location, auto_archive_after_event,
- logo_url, website_url
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
+ logo_url, website_url, topics
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
RETURNING *`,
[
input.name,
@@ -118,6 +118,7 @@ export class WorkingGroupDatabase {
input.auto_archive_after_event ?? true,
input.logo_url || null,
input.website_url || null,
+ input.topics ? JSON.stringify(input.topics) : '[]',
]
);
@@ -201,6 +202,8 @@ export class WorkingGroupDatabase {
for (const [key, value] of Object.entries(updates)) {
// Handle leaders separately
if (key === 'leader_user_ids') continue;
+ // Handle topics separately (JSONB)
+ if (key === 'topics') continue;
const columnName = COLUMN_MAP[key];
if (!columnName) {
@@ -210,6 +213,12 @@ export class WorkingGroupDatabase {
params.push(value);
}
+ // Handle topics (JSONB column)
+ if (updates.topics !== undefined) {
+ setClauses.push(`topics = $${paramIndex++}`);
+ params.push(JSON.stringify(updates.topics));
+ }
+
// Update working group fields if any
if (setClauses.length > 0) {
params.push(id);
diff --git a/server/src/routes/meetings.ts b/server/src/routes/meetings.ts
index 970fc02a61..426d250aae 100644
--- a/server/src/routes/meetings.ts
+++ b/server/src/routes/meetings.ts
@@ -11,6 +11,7 @@ import { createLogger } from "../logger.js";
import { requireAuth, requireAdmin, optionalAuth } from "../middleware/auth.js";
import { MeetingsDatabase } from "../db/meetings-db.js";
import { WorkingGroupDatabase } from "../db/working-group-db.js";
+import { getChannelMembers } from "../slack/client.js";
import type { WorkingGroupTopic, MeetingStatus } from "../types.js";
// UUID validation helper
@@ -121,6 +122,7 @@ export function createMeetingRouters(): {
duration_minutes,
timezone,
invite_mode,
+ invite_slack_channel_id,
} = req.body;
if (!working_group_id || !title) {
@@ -156,6 +158,7 @@ export function createMeetingRouters(): {
duration_minutes,
timezone,
invite_mode,
+ invite_slack_channel_id,
created_by_user_id: user.id,
});
@@ -279,6 +282,8 @@ export function createMeetingRouters(): {
end_time,
timezone,
status,
+ invite_mode,
+ invite_slack_channel_id,
} = req.body;
if (!working_group_id || !title || !start_time) {
@@ -311,6 +316,14 @@ export function createMeetingRouters(): {
});
}
+ // Validate slack_channel mode requires channel ID
+ if (invite_mode === 'slack_channel' && !invite_slack_channel_id) {
+ return res.status(400).json({
+ error: 'Missing Slack channel ID',
+ message: 'invite_slack_channel_id is required when invite_mode is "slack_channel"',
+ });
+ }
+
const meeting = await meetingsDb.createMeeting({
series_id,
working_group_id,
@@ -325,7 +338,21 @@ export function createMeetingRouters(): {
created_by_user_id: user.id,
});
- res.status(201).json(meeting);
+ // Handle invite mode
+ let invitedCount = 0;
+ if (invite_mode === 'slack_channel' && invite_slack_channel_id) {
+ const channelMembers = await getChannelMembers(invite_slack_channel_id);
+ invitedCount = await meetingsDb.addAttendeesFromSlackChannel(meeting.id, channelMembers);
+ logger.info({ meetingId: meeting.id, invitedCount, invite_mode, channelId: invite_slack_channel_id }, 'Invited Slack channel members');
+ } else if (invite_mode === 'all_members') {
+ invitedCount = await meetingsDb.addAttendeesFromGroup(meeting.id, working_group_id);
+ logger.info({ meetingId: meeting.id, invitedCount, invite_mode }, 'Invited all group members');
+ } else if (invite_mode === 'topic_subscribers' && topic_slugs?.length > 0) {
+ invitedCount = await meetingsDb.addAttendeesFromGroup(meeting.id, working_group_id, topic_slugs);
+ logger.info({ meetingId: meeting.id, invitedCount, invite_mode }, 'Invited topic subscribers');
+ }
+
+ res.status(201).json({ ...meeting, invited_count: invitedCount });
} catch (error) {
logger.error({ err: error }, 'Create meeting error');
res.status(500).json({
@@ -477,6 +504,66 @@ export function createMeetingRouters(): {
}
});
+ // POST /api/admin/meetings/:id/invite-channel - Invite Slack channel members
+ adminApiRouter.post('/:id/invite-channel', requireAuth, requireAdmin, async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+ const { slack_channel_id } = req.body;
+
+ if (!isValidUuid(id)) {
+ return res.status(400).json({
+ error: 'Invalid meeting ID',
+ message: 'Meeting ID must be a valid UUID',
+ });
+ }
+
+ if (!slack_channel_id) {
+ return res.status(400).json({
+ error: 'Missing required field',
+ message: 'slack_channel_id is required',
+ });
+ }
+
+ const meeting = await meetingsDb.getMeetingById(id);
+ if (!meeting) {
+ return res.status(404).json({
+ error: 'Meeting not found',
+ message: 'No meeting found with the specified ID',
+ });
+ }
+
+ // Validate that the Slack channel is associated with the working group
+ const workingGroup = await workingGroupDb.getWorkingGroupById(meeting.working_group_id);
+ if (workingGroup) {
+ const validChannels = [
+ workingGroup.slack_channel_id,
+ ...(workingGroup.topics?.map(t => t.slack_channel_id) || []),
+ ].filter(Boolean);
+
+ if (!validChannels.includes(slack_channel_id)) {
+ return res.status(400).json({
+ error: 'Invalid Slack channel',
+ message: 'The specified Slack channel is not associated with this working group',
+ });
+ }
+ }
+
+ // Get channel members from Slack
+ const channelMembers = await getChannelMembers(slack_channel_id);
+
+ // Add attendees from the Slack channel
+ const count = await meetingsDb.addAttendeesFromSlackChannel(id, channelMembers);
+
+ res.json({ success: true, invited_count: count, channel_member_count: channelMembers.length });
+ } catch (error) {
+ logger.error({ err: error }, 'Invite Slack channel to meeting error');
+ res.status(500).json({
+ error: 'Failed to invite channel members',
+ message: 'An internal error occurred',
+ });
+ }
+ });
+
// POST /api/admin/meetings/:id/attendees - Add attendee manually
adminApiRouter.post('/:id/attendees', requireAuth, requireAdmin, async (req: Request, res: Response) => {
try {
diff --git a/server/src/services/meeting-service.ts b/server/src/services/meeting-service.ts
index 71cd02af07..63e50cb58c 100644
--- a/server/src/services/meeting-service.ts
+++ b/server/src/services/meeting-service.ts
@@ -17,6 +17,7 @@ import {
notifyMeetingStarted,
notifyMeetingEnded,
} from '../notifications/slack.js';
+import { getChannelMembers } from '../slack/client.js';
import type {
CreateMeetingInput,
UpdateMeetingInput,
@@ -49,8 +50,10 @@ export interface ScheduleMeetingOptions {
createZoomMeeting?: boolean;
sendCalendarInvites?: boolean;
announceInSlack?: boolean;
- // Control who gets invited: 'all_members', 'topic_subscribers', or 'none' (opt-in)
- inviteMode?: 'all_members' | 'topic_subscribers' | 'none';
+ // Control who gets invited: 'all_members', 'topic_subscribers', 'slack_channel', or 'none' (opt-in)
+ inviteMode?: 'all_members' | 'topic_subscribers' | 'slack_channel' | 'none';
+ // Slack channel ID for invite_mode='slack_channel'
+ inviteSlackChannelId?: string;
}
export interface ScheduleMeetingResult {
@@ -137,11 +140,21 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise<
meeting.zoom_passcode = zoomMeeting.password;
}
- // Invite working group members to the meeting based on inviteMode
+ // Invite members to the meeting based on inviteMode
let invitedCount = 0;
const inviteMode = options.inviteMode || 'all_members';
- if (inviteMode !== 'none') {
+ if (inviteMode === 'slack_channel' && options.inviteSlackChannelId) {
+ // Invite members from a Slack channel
+ try {
+ const channelMembers = await getChannelMembers(options.inviteSlackChannelId);
+ invitedCount = await meetingsDb.addAttendeesFromSlackChannel(meeting.id, channelMembers);
+ logger.info({ meetingId: meeting.id, invitedCount, inviteMode, channelId: options.inviteSlackChannelId }, 'Invited Slack channel members to meeting');
+ } catch (error) {
+ logger.error({ error, channelId: options.inviteSlackChannelId, meetingId: meeting.id }, 'Failed to fetch Slack channel members');
+ errors.push(`Failed to invite Slack channel members: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ } else if (inviteMode !== 'none') {
// 'all_members' passes undefined topicSlugs, 'topic_subscribers' passes the actual topics
const topicFilter = inviteMode === 'topic_subscribers' ? options.topicSlugs : undefined;
invitedCount = await meetingsDb.addAttendeesFromGroup(
diff --git a/server/src/types.ts b/server/src/types.ts
index 54a53ea43f..9df7856b23 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -372,6 +372,7 @@ export interface WorkingGroupTopic {
slug: string;
name: string;
description?: string;
+ slack_channel_id?: string;
}
export interface WorkingGroup {
@@ -433,6 +434,7 @@ export interface CreateWorkingGroupInput {
display_order?: number;
committee_type?: CommitteeType;
region?: string;
+ topics?: WorkingGroupTopic[];
// Industry gathering fields
linked_event_id?: string;
event_start_date?: Date;
@@ -454,6 +456,7 @@ export interface UpdateWorkingGroupInput {
display_order?: number;
committee_type?: CommitteeType;
region?: string;
+ topics?: WorkingGroupTopic[];
// Industry gathering fields
linked_event_id?: string;
event_start_date?: Date;
@@ -857,7 +860,7 @@ export interface EventSponsorDisplay {
export type MeetingStatus = 'draft' | 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
export type MeetingSeriesStatus = 'active' | 'paused' | 'archived';
-export type MeetingInviteMode = 'all_members' | 'topic_subscribers' | 'manual';
+export type MeetingInviteMode = 'all_members' | 'topic_subscribers' | 'slack_channel' | 'manual';
export type RsvpStatus = 'pending' | 'accepted' | 'declined' | 'tentative';
export type MeetingInviteSource = 'auto' | 'manual' | 'request';
@@ -891,6 +894,7 @@ export interface MeetingSeries {
google_calendar_id?: string;
google_event_series_id?: string;
invite_mode: MeetingInviteMode;
+ invite_slack_channel_id?: string;
status: MeetingSeriesStatus;
created_by_user_id?: string;
created_at: Date;
@@ -907,6 +911,7 @@ export interface CreateMeetingSeriesInput {
duration_minutes?: number;
timezone?: string;
invite_mode?: MeetingInviteMode;
+ invite_slack_channel_id?: string;
created_by_user_id?: string;
}
@@ -924,6 +929,7 @@ export interface UpdateMeetingSeriesInput {
google_calendar_id?: string;
google_event_series_id?: string;
invite_mode?: MeetingInviteMode;
+ invite_slack_channel_id?: string;
status?: MeetingSeriesStatus;
}