From 054e6bf73e5907c5cf3bd730c3f419cecd60010d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 16 Jan 2026 10:11:13 -0500 Subject: [PATCH 1/2] feat: 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 - Added validation for slack_channel invite mode - Added error handling for channel member lookups Co-Authored-By: Claude Opus 4.5 --- .changeset/few-rivers-travel.md | 9 + server/public/admin-meetings.html | 107 ++++++++- server/public/admin-working-groups.html | 205 ++++++++++++++++++ server/src/addie/mcp/meeting-tools.ts | 169 ++++++++++++++- server/src/db/meetings-db.ts | 68 +++++- .../migrations/173_topic_slack_channels.sql | 24 ++ server/src/db/working-group-db.ts | 13 +- server/src/routes/meetings.ts | 89 +++++++- server/src/services/meeting-service.ts | 21 +- server/src/types.ts | 8 +- 10 files changed, 698 insertions(+), 15 deletions(-) create mode 100644 .changeset/few-rivers-travel.md create mode 100644 server/src/db/migrations/173_topic_slack_channels.sql 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 @@

Schedule Meeting

-
@@ -365,9 +365,10 @@

Schedule Meeting

- + Members will receive a calendar invite via email @@ -381,6 +382,16 @@

Schedule Meeting

+ + + +
+

Topics

+

+ Topics help organize meetings and filter invitations. Each topic can have its own Slack channel for subgroup discussions. +

+
+ + + + +
+

Leadership

@@ -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)}
` : ''} +
+
+ + +
+
+ `).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/173_topic_slack_channels.sql b/server/src/db/migrations/173_topic_slack_channels.sql new file mode 100644 index 0000000000..409f9a52ee --- /dev/null +++ b/server/src/db/migrations/173_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; } From a14a97e00b227241d48b914a9acd3ee30857ddaa Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 16 Jan 2026 14:09:45 -0500 Subject: [PATCH 2/2] fix: Rename migration to avoid version conflict Migration 173 already exists on main (173_setup_nudge_log.sql). Rename to 175 to resolve conflict. Co-Authored-By: Claude Opus 4.5 --- ...{173_topic_slack_channels.sql => 175_topic_slack_channels.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/src/db/migrations/{173_topic_slack_channels.sql => 175_topic_slack_channels.sql} (100%) diff --git a/server/src/db/migrations/173_topic_slack_channels.sql b/server/src/db/migrations/175_topic_slack_channels.sql similarity index 100% rename from server/src/db/migrations/173_topic_slack_channels.sql rename to server/src/db/migrations/175_topic_slack_channels.sql