From f915acefe8c8c5c4b73d81bf38c2bf95f7f5bd9f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 7 Jan 2026 07:24:15 -0800 Subject: [PATCH 1/3] fix: resolve Slack/WorkOS user ID duplicates in chapter memberships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Users who joined chapters via Slack had their Slack user ID stored. When they later linked their WorkOS account via web login, they got a different ID. The slack_user_mappings table linked these, but membership/leader records still had the old Slack IDs, causing duplicates and broken permissions. Solution: - Add resolveToCanonicalUserId() helper to resolve Slack IDs to WorkOS IDs - Apply resolution in all write paths: addMembership, addLeader, setLeaders, addMembershipWithInterest - Apply resolution in all read/check paths: isMember, isLeader, getMembership, removeMembership, removeLeader, deleteMembership, getCommitteesLedByUser - Add migration 149 to consolidate existing duplicate records - Add canonical_user_id to WorkingGroupLeader type for frontend compatibility - Update frontend and backend leader checks to use canonical_user_id 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-duplicate-members.md | 12 +++ server/public/working-groups/detail.html | 4 +- server/src/addie/mcp/admin-tools.ts | 8 +- .../149_consolidate_slack_workos_ids.sql | 49 ++++++++++++ server/src/db/working-group-db.ts | 78 ++++++++++++++----- server/src/routes/committees.ts | 8 +- server/src/types.ts | 1 + 7 files changed, 129 insertions(+), 31 deletions(-) create mode 100644 .changeset/fix-duplicate-members.md create mode 100644 server/src/db/migrations/149_consolidate_slack_workos_ids.sql diff --git a/.changeset/fix-duplicate-members.md b/.changeset/fix-duplicate-members.md new file mode 100644 index 0000000000..4bdaa56127 --- /dev/null +++ b/.changeset/fix-duplicate-members.md @@ -0,0 +1,12 @@ +--- +"adcontextprotocol": patch +--- + +Fix duplicate members and broken leader permissions on chapter pages when users have both Slack and web accounts + +Root cause: Users who joined via Slack had their Slack ID stored. When they later linked their WorkOS account, they had two separate records. + +Solution: +- Write paths (addMembership, addLeader, setLeaders) now resolve Slack IDs to canonical WorkOS IDs before storing +- Migration 149 consolidates existing duplicate records to use WorkOS IDs +- Queries simplified since data is now clean at the source diff --git a/server/public/working-groups/detail.html b/server/public/working-groups/detail.html index 4ba9cdbad9..0e0e6eba56 100644 --- a/server/public/working-groups/detail.html +++ b/server/public/working-groups/detail.html @@ -1006,9 +1006,9 @@

New Post

currentGroup = data.working_group; isMember = data.is_member || false; - // Check if current user is a leader + // Check if current user is a leader (use canonical_user_id for Slack/WorkOS ID resolution) if (currentUser && currentGroup) { - isLeader = currentGroup.leaders?.some(l => l.user_id === currentUser.id) ?? false; + isLeader = currentGroup.leaders?.some(l => l.canonical_user_id === currentUser.id) ?? false; } renderWorkingGroup(); diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index 2616576105..64b412352b 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -4131,9 +4131,9 @@ export function createAdminToolHandlers( return `❌ Committee "${committeeSlug}" not found. Use list_working_groups, list_chapters, or list_industry_gatherings to find the correct slug.`; } - // Check if already a leader + // Check if already a leader (use canonical_user_id for Slack/WorkOS resolution) const leaders = await wgDb.getLeaders(committee.id); - if (leaders.some((l: { user_id: string }) => l.user_id === userId)) { + if (leaders.some((l) => l.canonical_user_id === userId)) { return `â„šī¸ User is already a leader of "${committee.name}".`; } @@ -4199,9 +4199,9 @@ Committee management page: https://agenticadvertising.org/working-groups/${commi return `❌ Committee "${committeeSlug}" not found.`; } - // Check if they are a leader + // Check if they are a leader (use canonical_user_id for Slack/WorkOS resolution) const leaders = await wgDb.getLeaders(committee.id); - if (!leaders.some((l: { user_id: string }) => l.user_id === userId)) { + if (!leaders.some((l) => l.canonical_user_id === userId)) { return `â„šī¸ User ${userId} is not a leader of "${committee.name}".`; } diff --git a/server/src/db/migrations/149_consolidate_slack_workos_ids.sql b/server/src/db/migrations/149_consolidate_slack_workos_ids.sql new file mode 100644 index 0000000000..b8133df09a --- /dev/null +++ b/server/src/db/migrations/149_consolidate_slack_workos_ids.sql @@ -0,0 +1,49 @@ +-- Migration: 149_consolidate_slack_workos_ids.sql +-- Consolidate duplicate membership and leader records where users have both Slack and WorkOS IDs +-- +-- Problem: Users who joined via Slack and later linked their WorkOS account have duplicate records. +-- The same user appears with their Slack ID (e.g., U123) and their WorkOS ID (e.g., user_abc). +-- The slack_user_mappings table links these together. +-- +-- Solution: Update all records to use the canonical WorkOS user ID, then remove duplicates. + +-- Step 1: Update working_group_memberships to use WorkOS user IDs where mappings exist +-- This converts Slack IDs to their linked WorkOS IDs +UPDATE working_group_memberships wgm +SET workos_user_id = sm.workos_user_id +FROM slack_user_mappings sm +WHERE wgm.workos_user_id = sm.slack_user_id + AND sm.workos_user_id IS NOT NULL; + +-- Step 2: Remove duplicate memberships after consolidation +-- Keep the oldest record (first joined) when duplicates exist +DELETE FROM working_group_memberships wgm1 +WHERE EXISTS ( + SELECT 1 FROM working_group_memberships wgm2 + WHERE wgm1.working_group_id = wgm2.working_group_id + AND wgm1.workos_user_id = wgm2.workos_user_id + AND wgm1.id > wgm2.id -- Keep the older record +); + +-- Step 3: Update working_group_leaders to use WorkOS user IDs where mappings exist +UPDATE working_group_leaders wgl +SET user_id = sm.workos_user_id +FROM slack_user_mappings sm +WHERE wgl.user_id = sm.slack_user_id + AND sm.workos_user_id IS NOT NULL; + +-- Step 4: Remove duplicate leaders after consolidation +-- Keep the oldest record when duplicates exist +DELETE FROM working_group_leaders wgl1 +WHERE EXISTS ( + SELECT 1 FROM working_group_leaders wgl2 + WHERE wgl1.working_group_id = wgl2.working_group_id + AND wgl1.user_id = wgl2.user_id + AND wgl1.created_at > wgl2.created_at -- Keep the older record +); + +-- Log the migration completion +DO $$ +BEGIN + RAISE NOTICE 'Migration 149: Consolidated Slack/WorkOS user IDs in working_group_memberships and working_group_leaders'; +END $$; diff --git a/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts index 532a23a737..be51e90fc1 100644 --- a/server/src/db/working-group-db.ts +++ b/server/src/db/working-group-db.ts @@ -60,6 +60,21 @@ function extractSlackChannelId(url: string | null | undefined): string | null { * Database operations for working groups */ export class WorkingGroupDatabase { + /** + * Resolve a user ID to its canonical WorkOS user ID. + * If the ID is a Slack user ID with a linked WorkOS account, returns the WorkOS ID. + * Otherwise returns the original ID unchanged. + */ + async resolveToCanonicalUserId(userId: string): Promise { + // Check if this is a Slack user ID with a linked WorkOS account + const result = await query<{ workos_user_id: string }>( + `SELECT workos_user_id FROM slack_user_mappings + WHERE slack_user_id = $1 AND workos_user_id IS NOT NULL`, + [userId] + ); + return result.rows[0]?.workos_user_id ?? userId; + } + // ============== Working Groups ============== /** @@ -408,6 +423,9 @@ export class WorkingGroupDatabase { * Add a member to a working group */ async addMembership(input: AddWorkingGroupMemberInput): Promise { + // Resolve Slack IDs to canonical WorkOS IDs to prevent duplicates + const canonicalUserId = await this.resolveToCanonicalUserId(input.workos_user_id); + const result = await query( `INSERT INTO working_group_memberships ( working_group_id, workos_user_id, user_email, user_name, user_org_name, @@ -418,7 +436,7 @@ export class WorkingGroupDatabase { RETURNING *`, [ input.working_group_id, - input.workos_user_id, + canonicalUserId, input.user_email || null, input.user_name || null, input.user_org_name || null, @@ -434,11 +452,12 @@ export class WorkingGroupDatabase { * Remove a member from a working group (soft delete by setting status to inactive) */ async removeMembership(workingGroupId: string, userId: string): Promise { + const canonicalUserId = await this.resolveToCanonicalUserId(userId); const result = await query( `UPDATE working_group_memberships SET status = 'inactive', updated_at = NOW() WHERE working_group_id = $1 AND workos_user_id = $2`, - [workingGroupId, userId] + [workingGroupId, canonicalUserId] ); return (result.rowCount || 0) > 0; } @@ -447,10 +466,11 @@ export class WorkingGroupDatabase { * Hard delete a membership record */ async deleteMembership(workingGroupId: string, userId: string): Promise { + const canonicalUserId = await this.resolveToCanonicalUserId(userId); const result = await query( `DELETE FROM working_group_memberships WHERE working_group_id = $1 AND workos_user_id = $2`, - [workingGroupId, userId] + [workingGroupId, canonicalUserId] ); return (result.rowCount || 0) > 0; } @@ -459,10 +479,11 @@ export class WorkingGroupDatabase { * Get a specific membership */ async getMembership(workingGroupId: string, userId: string): Promise { + const canonicalUserId = await this.resolveToCanonicalUserId(userId); const result = await query( `SELECT * FROM working_group_memberships WHERE working_group_id = $1 AND workos_user_id = $2`, - [workingGroupId, userId] + [workingGroupId, canonicalUserId] ); return result.rows[0] || null; } @@ -471,11 +492,12 @@ export class WorkingGroupDatabase { * Check if user is a member of a working group */ async isMember(workingGroupId: string, userId: string): Promise { + const canonicalUserId = await this.resolveToCanonicalUserId(userId); const result = await query( `SELECT 1 FROM working_group_memberships WHERE working_group_id = $1 AND workos_user_id = $2 AND status = 'active' LIMIT 1`, - [workingGroupId, userId] + [workingGroupId, canonicalUserId] ); return result.rows.length > 0; } @@ -568,19 +590,19 @@ export class WorkingGroupDatabase { // 1. working_group_memberships (if they're a member with cached name) // 2. users table (canonical user data synced from WorkOS) // 3. organization_memberships (older sync table) - // 4. slack_user_mappings (if user_id is a Slack ID) - // 5. Falls back to user_id if no name found + // 4. Falls back to user_id if no name found + // + // Note: canonical_user_id equals user_id since we store canonical IDs at write time const result = await query( `SELECT wgl.user_id, + wgl.user_id AS canonical_user_id, COALESCE( NULLIF(wgm.user_name, ''), NULLIF(TRIM(CONCAT(u.first_name, ' ', u.last_name)), ''), NULLIF(TRIM(CONCAT(om.first_name, ' ', om.last_name)), ''), u.email, om.email, - NULLIF(sm.slack_real_name, ''), - sm.slack_email, wgl.user_id ) AS name, COALESCE(wgm.user_org_name, user_org.name, org.name) AS org_name, @@ -597,7 +619,6 @@ export class WorkingGroupDatabase { LIMIT 1 ) om ON true LEFT JOIN organizations org ON om.workos_organization_id = org.workos_organization_id - LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id WHERE wgl.working_group_id = $1 ORDER BY wgl.created_at`, [workingGroupId] @@ -618,14 +639,13 @@ export class WorkingGroupDatabase { `SELECT wgl.working_group_id, wgl.user_id, + wgl.user_id AS canonical_user_id, COALESCE( NULLIF(wgm.user_name, ''), NULLIF(TRIM(CONCAT(u.first_name, ' ', u.last_name)), ''), NULLIF(TRIM(CONCAT(om.first_name, ' ', om.last_name)), ''), u.email, om.email, - NULLIF(sm.slack_real_name, ''), - sm.slack_email, wgl.user_id ) AS name, COALESCE(wgm.user_org_name, user_org.name, org.name) AS org_name, @@ -642,7 +662,6 @@ export class WorkingGroupDatabase { LIMIT 1 ) om ON true LEFT JOIN organizations org ON om.workos_organization_id = org.workos_organization_id - LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id WHERE wgl.working_group_id = ANY($1) ORDER BY wgl.created_at`, [workingGroupIds] @@ -657,6 +676,7 @@ export class WorkingGroupDatabase { } leadersByGroup.get(groupId)!.push({ user_id: row.user_id, + canonical_user_id: row.canonical_user_id, name: row.name, org_name: row.org_name, created_at: row.created_at, @@ -670,6 +690,13 @@ export class WorkingGroupDatabase { * Set leaders for a working group (replaces existing leaders) */ async setLeaders(workingGroupId: string, userIds: string[]): Promise { + // Resolve all Slack IDs to canonical WorkOS IDs to prevent duplicates + const canonicalUserIds = await Promise.all( + userIds.map(id => this.resolveToCanonicalUserId(id)) + ); + // Dedupe in case multiple Slack IDs resolve to the same WorkOS ID + const uniqueUserIds = [...new Set(canonicalUserIds)]; + // Remove existing leaders await query( 'DELETE FROM working_group_leaders WHERE working_group_id = $1', @@ -677,13 +704,13 @@ export class WorkingGroupDatabase { ); // Add new leaders in a single bulk insert - if (userIds.length > 0) { - const values = userIds.map((_, i) => `($1, $${i + 2})`).join(', '); + if (uniqueUserIds.length > 0) { + const values = uniqueUserIds.map((_, i) => `($1, $${i + 2})`).join(', '); await query( `INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ${values} ON CONFLICT DO NOTHING`, - [workingGroupId, ...userIds] + [workingGroupId, ...uniqueUserIds] ); } @@ -695,11 +722,14 @@ export class WorkingGroupDatabase { * Add a leader to a working group */ async addLeader(workingGroupId: string, userId: string): Promise { + // Resolve Slack IDs to canonical WorkOS IDs to prevent duplicates + const canonicalUserId = await this.resolveToCanonicalUserId(userId); + await query( `INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, - [workingGroupId, userId] + [workingGroupId, canonicalUserId] ); // Ensure leader is a member @@ -710,9 +740,10 @@ export class WorkingGroupDatabase { * Remove a leader from a working group */ async removeLeader(workingGroupId: string, userId: string): Promise { + const canonicalUserId = await this.resolveToCanonicalUserId(userId); await query( 'DELETE FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2', - [workingGroupId, userId] + [workingGroupId, canonicalUserId] ); } @@ -720,11 +751,12 @@ export class WorkingGroupDatabase { * Check if a user is a leader of a working group */ async isLeader(workingGroupId: string, userId: string): Promise { + const canonicalUserId = await this.resolveToCanonicalUserId(userId); const result = await query( `SELECT 1 FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2 LIMIT 1`, - [workingGroupId, userId] + [workingGroupId, canonicalUserId] ); return result.rows.length > 0; } @@ -734,6 +766,7 @@ export class WorkingGroupDatabase { * Returns committees of all types where the user is a leader */ async getCommitteesLedByUser(userId: string): Promise { + const canonicalUserId = await this.resolveToCanonicalUserId(userId); const result = await query( `SELECT wg.*, COUNT(wgm.id)::int AS member_count FROM working_groups wg @@ -743,7 +776,7 @@ export class WorkingGroupDatabase { AND wg.status = 'active' GROUP BY wg.id ORDER BY wg.display_order, wg.name`, - [userId] + [canonicalUserId] ); const groups = result.rows; @@ -1238,6 +1271,9 @@ export class WorkingGroupDatabase { interest_level?: EventInterestLevel; interest_source?: EventInterestSource; }): Promise { + // Resolve Slack IDs to canonical WorkOS IDs to prevent duplicates + const canonicalUserId = await this.resolveToCanonicalUserId(input.workos_user_id); + const result = await query( `INSERT INTO working_group_memberships ( working_group_id, workos_user_id, user_email, user_name, user_org_name, @@ -1252,7 +1288,7 @@ export class WorkingGroupDatabase { RETURNING *`, [ input.working_group_id, - input.workos_user_id, + canonicalUserId, input.user_email || null, input.user_name || null, input.user_org_name || null, diff --git a/server/src/routes/committees.ts b/server/src/routes/committees.ts index 48459cc15a..1020b23612 100644 --- a/server/src/routes/committees.ts +++ b/server/src/routes/committees.ts @@ -969,7 +969,7 @@ export function createCommitteeRouters(): { }); } - const isLeader = group.leaders?.some(l => l.user_id === user.id) ?? false; + const isLeader = group.leaders?.some(l => l.canonical_user_id === user.id) ?? false; if (isLeader) { return res.status(403).json({ error: 'Cannot leave', @@ -1023,7 +1023,7 @@ export function createCommitteeRouters(): { }); } - const isLeader = group.leaders?.some(l => l.user_id === user.id) ?? false; + const isLeader = group.leaders?.some(l => l.canonical_user_id === user.id) ?? false; const finalMembersOnly = isLeader ? (is_members_only ?? true) : true; if (!title || !post_slug) { @@ -1136,7 +1136,7 @@ export function createCommitteeRouters(): { } const post = existing.rows[0]; - const isLeader = group.leaders?.some(l => l.user_id === user.id) ?? false; + const isLeader = group.leaders?.some(l => l.canonical_user_id === user.id) ?? false; const isAuthor = post.author_user_id === user.id; if (!isAuthor && !isLeader) { @@ -1240,7 +1240,7 @@ export function createCommitteeRouters(): { } const post = existing.rows[0]; - const isLeader = group.leaders?.some(l => l.user_id === user.id) ?? false; + const isLeader = group.leaders?.some(l => l.canonical_user_id === user.id) ?? false; const isAuthor = post.author_user_id === user.id; if (!isAuthor && !isLeader) { diff --git a/server/src/types.ts b/server/src/types.ts index 22dd807b7b..91e9db3dbf 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -358,6 +358,7 @@ export const COMMITTEE_TYPE_LABELS: Record = { export interface WorkingGroupLeader { user_id: string; + canonical_user_id: string; // WorkOS user ID if Slack user is mapped, else user_id name?: string; org_name?: string; created_at: Date; From af5de45649c8553d2c6b4fd10f4b33b63b9f7309 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 9 Jan 2026 10:03:11 -0500 Subject: [PATCH 2/3] fix: use joined_at for membership deduplication and empty changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 149: Use joined_at instead of UUID id for deduplication (UUIDs are not sequential, so id comparison doesn't determine age) - Empty changeset since this doesn't impact the protocol 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-duplicate-members.md | 10 ---------- .../db/migrations/149_consolidate_slack_workos_ids.sql | 3 ++- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.changeset/fix-duplicate-members.md b/.changeset/fix-duplicate-members.md index 4bdaa56127..a845151cc8 100644 --- a/.changeset/fix-duplicate-members.md +++ b/.changeset/fix-duplicate-members.md @@ -1,12 +1,2 @@ --- -"adcontextprotocol": patch --- - -Fix duplicate members and broken leader permissions on chapter pages when users have both Slack and web accounts - -Root cause: Users who joined via Slack had their Slack ID stored. When they later linked their WorkOS account, they had two separate records. - -Solution: -- Write paths (addMembership, addLeader, setLeaders) now resolve Slack IDs to canonical WorkOS IDs before storing -- Migration 149 consolidates existing duplicate records to use WorkOS IDs -- Queries simplified since data is now clean at the source diff --git a/server/src/db/migrations/149_consolidate_slack_workos_ids.sql b/server/src/db/migrations/149_consolidate_slack_workos_ids.sql index b8133df09a..ee7e5ce99c 100644 --- a/server/src/db/migrations/149_consolidate_slack_workos_ids.sql +++ b/server/src/db/migrations/149_consolidate_slack_workos_ids.sql @@ -17,12 +17,13 @@ WHERE wgm.workos_user_id = sm.slack_user_id -- Step 2: Remove duplicate memberships after consolidation -- Keep the oldest record (first joined) when duplicates exist +-- Note: id is UUID (not sequential), so we use joined_at timestamp DELETE FROM working_group_memberships wgm1 WHERE EXISTS ( SELECT 1 FROM working_group_memberships wgm2 WHERE wgm1.working_group_id = wgm2.working_group_id AND wgm1.workos_user_id = wgm2.workos_user_id - AND wgm1.id > wgm2.id -- Keep the older record + AND wgm1.joined_at > wgm2.joined_at -- Keep the older record ); -- Step 3: Update working_group_leaders to use WorkOS user IDs where mappings exist From e4a133982728955f4940effdddc7fdc04e69dfdf Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 9 Jan 2026 10:06:29 -0500 Subject: [PATCH 3/3] fix: rename migration 149 to 151 to avoid conflict with main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 149 and 150 already exist on main branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ..._slack_workos_ids.sql => 151_consolidate_slack_workos_ids.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/src/db/migrations/{149_consolidate_slack_workos_ids.sql => 151_consolidate_slack_workos_ids.sql} (100%) diff --git a/server/src/db/migrations/149_consolidate_slack_workos_ids.sql b/server/src/db/migrations/151_consolidate_slack_workos_ids.sql similarity index 100% rename from server/src/db/migrations/149_consolidate_slack_workos_ids.sql rename to server/src/db/migrations/151_consolidate_slack_workos_ids.sql