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/ten-experts-fall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
25 changes: 22 additions & 3 deletions server/src/routes/admin/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;

Expand All @@ -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();
Expand All @@ -348,6 +366,7 @@ export function createAdminSlackRouter(): Router {

res.json({
linked,
chapters_joined: chaptersJoined,
errors,
});
} catch (error) {
Expand Down
42 changes: 39 additions & 3 deletions server/src/routes/committees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -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) {
Expand All @@ -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({
Expand Down
38 changes: 38 additions & 0 deletions server/src/slack/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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;
}
139 changes: 135 additions & 4 deletions server/src/slack/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -158,8 +159,6 @@ export interface SyncWorkingGroupMembersResult {
export async function syncWorkingGroupMembersFromSlack(
workingGroupId: string
): Promise<SyncWorkingGroupMembersResult> {
const workingGroupDb = new WorkingGroupDatabase();

// Get the working group
const workingGroup = await workingGroupDb.getWorkingGroupById(workingGroupId);
if (!workingGroup) {
Expand Down Expand Up @@ -276,7 +275,6 @@ export async function syncWorkingGroupMembersFromSlack(
* Sync all working groups that have Slack channels configured
*/
export async function syncAllWorkingGroupMembersFromSlack(): Promise<SyncWorkingGroupMembersResult[]> {
const workingGroupDb = new WorkingGroupDatabase();
const results: SyncWorkingGroupMembersResult[] = [];

const workingGroups = await workingGroupDb.listWorkingGroupsWithSlackChannel();
Expand All @@ -288,3 +286,136 @@ export async function syncAllWorkingGroupMembersFromSlack(): Promise<SyncWorking

return results;
}

// ============== User Account Link Sync ==============

export interface SyncUserChaptersResult {
workos_user_id: string;
slack_user_id: string;
channels_checked: number;
chapters_joined: number;
chapters_already_member: number;
chapters: Array<{
id: string;
name: string;
action: 'joined' | 'already_member';
}>;
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<SyncUserChaptersResult> {
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;
}
}