diff --git a/.changeset/fix-duplicate-members.md b/.changeset/fix-duplicate-members.md
new file mode 100644
index 0000000000..a845151cc8
--- /dev/null
+++ b/.changeset/fix-duplicate-members.md
@@ -0,0 +1,2 @@
+---
+---
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/151_consolidate_slack_workos_ids.sql b/server/src/db/migrations/151_consolidate_slack_workos_ids.sql
new file mode 100644
index 0000000000..ee7e5ce99c
--- /dev/null
+++ b/server/src/db/migrations/151_consolidate_slack_workos_ids.sql
@@ -0,0 +1,50 @@
+-- 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
+-- 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.joined_at > wgm2.joined_at -- 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;