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/fix-duplicate-members.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
4 changes: 2 additions & 2 deletions server/public/working-groups/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -1006,9 +1006,9 @@ <h2 id="modalTitle">New Post</h2>
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();
Expand Down
8 changes: 4 additions & 4 deletions server/src/addie/mcp/admin-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}".`;
}

Expand Down Expand Up @@ -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}".`;
}

Expand Down
50 changes: 50 additions & 0 deletions server/src/db/migrations/151_consolidate_slack_workos_ids.sql
Original file line number Diff line number Diff line change
@@ -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 $$;
78 changes: 57 additions & 21 deletions server/src/db/working-group-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
// 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 ==============

/**
Expand Down Expand Up @@ -408,6 +423,9 @@ export class WorkingGroupDatabase {
* Add a member to a working group
*/
async addMembership(input: AddWorkingGroupMemberInput): Promise<WorkingGroupMembership> {
// Resolve Slack IDs to canonical WorkOS IDs to prevent duplicates
const canonicalUserId = await this.resolveToCanonicalUserId(input.workos_user_id);

const result = await query<WorkingGroupMembership>(
`INSERT INTO working_group_memberships (
working_group_id, workos_user_id, user_email, user_name, user_org_name,
Expand All @@ -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,
Expand All @@ -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<boolean> {
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;
}
Expand All @@ -447,10 +466,11 @@ export class WorkingGroupDatabase {
* Hard delete a membership record
*/
async deleteMembership(workingGroupId: string, userId: string): Promise<boolean> {
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;
}
Expand All @@ -459,10 +479,11 @@ export class WorkingGroupDatabase {
* Get a specific membership
*/
async getMembership(workingGroupId: string, userId: string): Promise<WorkingGroupMembership | null> {
const canonicalUserId = await this.resolveToCanonicalUserId(userId);
const result = await query<WorkingGroupMembership>(
`SELECT * FROM working_group_memberships
WHERE working_group_id = $1 AND workos_user_id = $2`,
[workingGroupId, userId]
[workingGroupId, canonicalUserId]
);
return result.rows[0] || null;
}
Expand All @@ -471,11 +492,12 @@ export class WorkingGroupDatabase {
* Check if user is a member of a working group
*/
async isMember(workingGroupId: string, userId: string): Promise<boolean> {
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;
}
Expand Down Expand Up @@ -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<WorkingGroupLeader>(
`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,
Expand All @@ -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]
Expand All @@ -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,
Expand All @@ -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]
Expand All @@ -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,
Expand All @@ -670,20 +690,27 @@ export class WorkingGroupDatabase {
* Set leaders for a working group (replaces existing leaders)
*/
async setLeaders(workingGroupId: string, userIds: string[]): Promise<void> {
// 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',
[workingGroupId]
);

// 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]
);
}

Expand All @@ -695,11 +722,14 @@ export class WorkingGroupDatabase {
* Add a leader to a working group
*/
async addLeader(workingGroupId: string, userId: string): Promise<void> {
// 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
Expand All @@ -710,21 +740,23 @@ export class WorkingGroupDatabase {
* Remove a leader from a working group
*/
async removeLeader(workingGroupId: string, userId: string): Promise<void> {
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]
);
}

/**
* Check if a user is a leader of a working group
*/
async isLeader(workingGroupId: string, userId: string): Promise<boolean> {
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;
}
Expand All @@ -734,6 +766,7 @@ export class WorkingGroupDatabase {
* Returns committees of all types where the user is a leader
*/
async getCommitteesLedByUser(userId: string): Promise<WorkingGroupWithMemberCount[]> {
const canonicalUserId = await this.resolveToCanonicalUserId(userId);
const result = await query<WorkingGroupWithMemberCount>(
`SELECT wg.*, COUNT(wgm.id)::int AS member_count
FROM working_groups wg
Expand All @@ -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;
Expand Down Expand Up @@ -1238,6 +1271,9 @@ export class WorkingGroupDatabase {
interest_level?: EventInterestLevel;
interest_source?: EventInterestSource;
}): Promise<WorkingGroupMembership> {
// Resolve Slack IDs to canonical WorkOS IDs to prevent duplicates
const canonicalUserId = await this.resolveToCanonicalUserId(input.workos_user_id);

const result = await query<WorkingGroupMembership>(
`INSERT INTO working_group_memberships (
working_group_id, workos_user_id, user_email, user_name, user_org_name,
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions server/src/routes/committees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading