diff --git a/.changeset/ten-experts-fall.md b/.changeset/ten-experts-fall.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/ten-experts-fall.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/routes/admin/slack.ts b/server/src/routes/admin/slack.ts index 78f558fd8..2417ed1b2 100644 --- a/server/src/routes/admin/slack.ts +++ b/server/src/routes/admin/slack.ts @@ -14,7 +14,7 @@ import { requireAuth, requireAdmin } from '../../middleware/auth.js'; import { SlackDatabase } from '../../db/slack-db.js'; import { getPool } from '../../db/client.js'; import { isSlackConfigured, testSlackConnection } from '../../slack/client.js'; -import { syncSlackUsers, getSyncStatus } from '../../slack/sync.js'; +import { syncSlackUsers, getSyncStatus, syncUserToChaptersFromSlackChannels } from '../../slack/sync.js'; import { invalidateUnifiedUsersCache } from '../../cache/unified-users.js'; import { invalidateMemberContextCache } from '../../addie/index.js'; @@ -182,10 +182,22 @@ export function createAdminSlackRouter(): Router { 'Slack user manually linked' ); + // Sync user to chapters based on their Slack channel memberships + const chapterSyncResult = await syncUserToChaptersFromSlackChannels(workos_user_id, slackUserId); + if (chapterSyncResult.chapters_joined > 0) { + logger.info( + { slackUserId, workos_user_id, chaptersJoined: chapterSyncResult.chapters_joined }, + 'User added to chapters based on Slack channel memberships' + ); + } + invalidateUnifiedUsersCache(); invalidateMemberContextCache(slackUserId); - res.json({ mapping: updated }); + res.json({ + mapping: updated, + chapter_sync: chapterSyncResult, + }); } catch (error) { logger.error({ err: error }, 'Link Slack user error'); res.status(500).json({ @@ -317,6 +329,8 @@ export function createAdminSlackRouter(): Router { let linked = 0; const errors: string[] = []; + let chaptersJoined = 0; + for (const slackUser of unmappedSlack) { if (!slackUser.slack_email) continue; @@ -334,12 +348,16 @@ export function createAdminSlackRouter(): Router { }); linked++; mappedWorkosUserIds.add(workosUserId); + + // Sync user to chapters based on their Slack channel memberships + const chapterSyncResult = await syncUserToChaptersFromSlackChannels(workosUserId, slackUser.slack_user_id); + chaptersJoined += chapterSyncResult.chapters_joined; } catch (err) { errors.push(`Failed to link ${slackUser.slack_email}: ${err instanceof Error ? err.message : 'Unknown error'}`); } } - logger.info({ linked, errors: errors.length, adminUserId: adminUser?.id }, 'Auto-linked suggested matches'); + logger.info({ linked, chaptersJoined, errors: errors.length, adminUserId: adminUser?.id }, 'Auto-linked suggested matches'); if (linked > 0) { invalidateUnifiedUsersCache(); @@ -348,6 +366,7 @@ export function createAdminSlackRouter(): Router { res.json({ linked, + chapters_joined: chaptersJoined, errors, }); } catch (error) { diff --git a/server/src/routes/committees.ts b/server/src/routes/committees.ts index 9f0e07871..50adb61ad 100644 --- a/server/src/routes/committees.ts +++ b/server/src/routes/committees.ts @@ -18,7 +18,7 @@ import { notifyWorkingGroupPost } from "../notifications/slack.js"; const logger = createLogger("committee-routes"); // Valid committee types -const VALID_COMMITTEE_TYPES = ['working_group', 'council', 'chapter', 'governance'] as const; +const VALID_COMMITTEE_TYPES = ['working_group', 'council', 'chapter', 'governance', 'event'] as const; type CommitteeType = typeof VALID_COMMITTEE_TYPES[number]; // Initialize WorkOS client only if authentication is enabled @@ -266,7 +266,23 @@ export function createCommitteeRouters(): { leader_user_ids, committee_type, region: finalRegion }); - res.status(201).json(group); + // Auto-sync members from Slack channel if a channel was linked to a chapter or event + let syncResult = null; + if (group.slack_channel_id && (group.committee_type === 'chapter' || group.committee_type === 'event')) { + syncResult = await syncWorkingGroupMembersFromSlack(group.id); + if (syncResult.members_added > 0) { + logger.info( + { workingGroupId: group.id, membersAdded: syncResult.members_added }, + 'Auto-synced members after chapter/event was created with Slack channel' + ); + invalidateMemberContextCache(); + } + } + + res.status(201).json({ + ...group, + sync_result: syncResult, + }); } catch (error) { logger.error({ err: error }, 'Create working group error:'); res.status(500).json({ @@ -293,6 +309,10 @@ export function createCommitteeRouters(): { updates.region = null; } + // Check if we're adding/changing a Slack channel + const existingGroup = await workingGroupDb.getWorkingGroupById(id); + const isAddingChannel = updates.slack_channel_url && (!existingGroup?.slack_channel_id || updates.slack_channel_url !== existingGroup.slack_channel_url); + const group = await workingGroupDb.updateWorkingGroup(id, updates); if (!group) { @@ -302,7 +322,23 @@ export function createCommitteeRouters(): { }); } - res.json(group); + // Auto-sync members from Slack channel if a new channel was linked to a chapter or event + let syncResult = null; + if (isAddingChannel && group.slack_channel_id && (group.committee_type === 'chapter' || group.committee_type === 'event')) { + syncResult = await syncWorkingGroupMembersFromSlack(id); + if (syncResult.members_added > 0) { + logger.info( + { workingGroupId: id, membersAdded: syncResult.members_added }, + 'Auto-synced members after channel was linked' + ); + invalidateMemberContextCache(); + } + } + + res.json({ + ...group, + sync_result: syncResult, + }); } catch (error) { logger.error({ err: error }, 'Update working group error:'); res.status(500).json({ diff --git a/server/src/slack/client.ts b/server/src/slack/client.ts index e3d2d5672..71ebe8048 100644 --- a/server/src/slack/client.ts +++ b/server/src/slack/client.ts @@ -684,3 +684,41 @@ export async function setChannelPurpose( return { ok: false, error: errorMessage }; } } + +/** + * Get channels that a specific user is a member of + * Uses users.conversations API to list user's channel memberships + * + * @param userId - The Slack user ID to query + * @returns Array of channel IDs the user is a member of + */ +export async function getUserChannels(userId: string): Promise { + const channelIds: string[] = []; + let cursor: string | undefined; + + do { + const response = await slackRequest<{ + channels: Array<{ id: string; name: string }>; + response_metadata?: { next_cursor?: string }; + }>('users.conversations', { + user: userId, + types: 'public_channel', + exclude_archived: true, + limit: 200, + cursor, + }); + + if (response.channels) { + channelIds.push(...response.channels.map(c => c.id)); + } + + cursor = response.response_metadata?.next_cursor; + + if (cursor) { + await sleep(RATE_LIMIT_DELAY_MS); + } + } while (cursor); + + logger.debug({ userId, channelCount: channelIds.length }, 'Fetched user channel memberships'); + return channelIds; +} diff --git a/server/src/slack/sync.ts b/server/src/slack/sync.ts index 7ed1bbe0d..4a1309446 100644 --- a/server/src/slack/sync.ts +++ b/server/src/slack/sync.ts @@ -7,12 +7,13 @@ */ import { logger } from '../logger.js'; -import { getSlackUsers, getChannelMembers, isSlackConfigured } from './client.js'; +import { getSlackUsers, getChannelMembers, getUserChannels, isSlackConfigured } from './client.js'; import { SlackDatabase } from '../db/slack-db.js'; import { WorkingGroupDatabase } from '../db/working-group-db.js'; import type { SyncSlackUsersResult } from './types.js'; const slackDb = new SlackDatabase(); +const workingGroupDb = new WorkingGroupDatabase(); /** * Sync all Slack users to the database @@ -158,8 +159,6 @@ export interface SyncWorkingGroupMembersResult { export async function syncWorkingGroupMembersFromSlack( workingGroupId: string ): Promise { - const workingGroupDb = new WorkingGroupDatabase(); - // Get the working group const workingGroup = await workingGroupDb.getWorkingGroupById(workingGroupId); if (!workingGroup) { @@ -276,7 +275,6 @@ export async function syncWorkingGroupMembersFromSlack( * Sync all working groups that have Slack channels configured */ export async function syncAllWorkingGroupMembersFromSlack(): Promise { - const workingGroupDb = new WorkingGroupDatabase(); const results: SyncWorkingGroupMembersResult[] = []; const workingGroups = await workingGroupDb.listWorkingGroupsWithSlackChannel(); @@ -288,3 +286,136 @@ export async function syncAllWorkingGroupMembersFromSlack(): Promise; + errors: string[]; +} + +/** + * Sync a user to chapters based on their Slack channel memberships + * + * Called when a user's Slack account is linked to their WorkOS account. + * Checks which channels they're in and adds them to corresponding chapters. + */ +export async function syncUserToChaptersFromSlackChannels( + workosUserId: string, + slackUserId: string +): Promise { + const result: SyncUserChaptersResult = { + workos_user_id: workosUserId, + slack_user_id: slackUserId, + channels_checked: 0, + chapters_joined: 0, + chapters_already_member: 0, + chapters: [], + errors: [], + }; + + if (!isSlackConfigured()) { + result.errors.push('Slack is not configured (ADDIE_BOT_TOKEN missing)'); + return result; + } + + try { + logger.info( + { workosUserId, slackUserId }, + 'Starting user chapter sync from Slack channels' + ); + + // Get all channels the user is a member of + let userChannelIds: string[]; + try { + userChannelIds = await getUserChannels(slackUserId); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`Failed to fetch user channels: ${errorMessage}`); + logger.error({ error, slackUserId }, 'Failed to fetch user channels from Slack'); + return result; + } + result.channels_checked = userChannelIds.length; + + if (userChannelIds.length === 0) { + logger.info({ slackUserId }, 'User is not a member of any channels'); + return result; + } + + // Get all chapters/events that have Slack channels configured + const workingGroups = await workingGroupDb.listWorkingGroupsWithSlackChannel(); + + // Filter to only chapters and events (groups that auto-add members) + const autoAddGroups = workingGroups.filter( + wg => wg.committee_type === 'chapter' || wg.committee_type === 'event' + ); + + // Get user's Slack info for membership record + const slackMapping = await slackDb.getBySlackUserId(slackUserId); + + // Check each auto-add group + for (const group of autoAddGroups) { + if (!group.slack_channel_id) continue; + + // Check if user is in this channel + if (!userChannelIds.includes(group.slack_channel_id)) continue; + + try { + // Check if already a member + const isMember = await workingGroupDb.isMember(group.id, workosUserId); + if (isMember) { + result.chapters_already_member++; + result.chapters.push({ + id: group.id, + name: group.name, + action: 'already_member', + }); + continue; + } + + // Add to the group + await workingGroupDb.addMembershipWithInterest({ + working_group_id: group.id, + workos_user_id: workosUserId, + user_email: slackMapping?.slack_email || undefined, + user_name: slackMapping?.slack_real_name || slackMapping?.slack_display_name || undefined, + interest_level: group.committee_type === 'event' ? 'interested' : undefined, + interest_source: 'slack_join', + }); + + result.chapters_joined++; + result.chapters.push({ + id: group.id, + name: group.name, + action: 'joined', + }); + + logger.info( + { workosUserId, groupId: group.id, groupName: group.name }, + 'Added user to chapter based on Slack channel membership' + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`Failed to add to ${group.name}: ${errorMessage}`); + logger.error({ error, groupId: group.id }, 'Failed to add user to chapter'); + } + } + + logger.info(result, 'User chapter sync from Slack channels completed'); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`Sync failed: ${errorMessage}`); + logger.error({ error, workosUserId, slackUserId }, 'User chapter sync from Slack failed'); + return result; + } +}