From a719f5246582b38489b536358faa29e438a9ae11 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 9 Jan 2026 14:05:11 -0500 Subject: [PATCH 1/2] fix: resolve Slack/WorkOS leader IDs at read time for permission checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chapter leaders added via Slack ID before linking their WorkOS account couldn't manage events because the leader permission checks compared stored Slack IDs against login WorkOS IDs. Changes: - getLeaders/getLeadersBatch: Resolve canonical_user_id via slack_user_mappings - isLeader: Check both direct user_id match and Slack→WorkOS mapping - getCommitteesLedByUser: Support both ID types with DISTINCT count - content.ts: Fix is_leader subqueries to use mapping join - member-context.ts: Fix pending content leader query - outbound-db.ts: Fix capability counts for leaders 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/src/addie/member-context.ts | 4 ++- server/src/db/outbound-db.ts | 8 ++++-- server/src/db/working-group-db.ts | 39 ++++++++++++++++++++---------- server/src/routes/content.ts | 11 +++++++-- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/server/src/addie/member-context.ts b/server/src/addie/member-context.ts index 49cd49a006..7bbbd57ac7 100644 --- a/server/src/addie/member-context.ts +++ b/server/src/addie/member-context.ts @@ -34,11 +34,13 @@ async function getPendingContentForUser( const pool = getPool(); // Get committees user leads + // Join with slack_user_mappings to handle users who were added as leader via Slack ID const leaderResult = await pool.query( `SELECT wg.id, wg.name, wg.slug FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id JOIN working_groups wg ON wg.id = wgl.working_group_id - WHERE wgl.user_id = $1`, + WHERE wgl.user_id = $1 OR sm.workos_user_id = $1`, [workosUserId] ); const ledCommitteeIds = leaderResult.rows.map(c => c.id); diff --git a/server/src/db/outbound-db.ts b/server/src/db/outbound-db.ts index 695c0fec36..2663cd8fb6 100644 --- a/server/src/db/outbound-db.ts +++ b/server/src/db/outbound-db.ts @@ -745,13 +745,17 @@ export async function getMemberCapabilities( WHERE wg.committee_type = 'working_group' AND ( EXISTS(SELECT 1 FROM working_group_memberships wgm WHERE wgm.working_group_id = wg.id AND wgm.workos_user_id = $1) - OR EXISTS(SELECT 1 FROM working_group_leaders wgl WHERE wgl.working_group_id = wg.id AND wgl.user_id = $1) + OR EXISTS(SELECT 1 FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + WHERE wgl.working_group_id = wg.id AND (wgl.user_id = $1 OR sm.workos_user_id = $1)) )) as wg_count, (SELECT COUNT(DISTINCT wg.id) FROM working_groups wg WHERE wg.committee_type = 'council' AND ( EXISTS(SELECT 1 FROM working_group_memberships wgm WHERE wgm.working_group_id = wg.id AND wgm.workos_user_id = $1) - OR EXISTS(SELECT 1 FROM working_group_leaders wgl WHERE wgl.working_group_id = wg.id AND wgl.user_id = $1) + OR EXISTS(SELECT 1 FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + WHERE wgl.working_group_id = wg.id AND (wgl.user_id = $1 OR sm.workos_user_id = $1)) )) as council_count`, [workosUserId] ), diff --git a/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts index be51e90fc1..e50ff9c23b 100644 --- a/server/src/db/working-group-db.ts +++ b/server/src/db/working-group-db.ts @@ -592,11 +592,12 @@ export class WorkingGroupDatabase { // 3. organization_memberships (older sync table) // 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 + // canonical_user_id is resolved at read time via slack_user_mappings + // to handle cases where leaders were added via Slack ID before linking WorkOS const result = await query( `SELECT wgl.user_id, - wgl.user_id AS canonical_user_id, + COALESCE(sm.workos_user_id, wgl.user_id) AS canonical_user_id, COALESCE( NULLIF(wgm.user_name, ''), NULLIF(TRIM(CONCAT(u.first_name, ' ', u.last_name)), ''), @@ -608,13 +609,14 @@ export class WorkingGroupDatabase { COALESCE(wgm.user_org_name, user_org.name, org.name) AS org_name, wgl.created_at FROM working_group_leaders wgl - LEFT JOIN working_group_memberships wgm ON wgl.user_id = wgm.workos_user_id AND wgm.working_group_id = wgl.working_group_id - LEFT JOIN users u ON wgl.user_id = u.workos_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + LEFT JOIN working_group_memberships wgm ON COALESCE(sm.workos_user_id, wgl.user_id) = wgm.workos_user_id AND wgm.working_group_id = wgl.working_group_id + LEFT JOIN users u ON COALESCE(sm.workos_user_id, wgl.user_id) = u.workos_user_id LEFT JOIN organizations user_org ON u.primary_organization_id = user_org.workos_organization_id LEFT JOIN LATERAL ( SELECT om.first_name, om.last_name, om.email, om.workos_organization_id FROM organization_memberships om - WHERE om.workos_user_id = wgl.user_id + WHERE om.workos_user_id = COALESCE(sm.workos_user_id, wgl.user_id) ORDER BY om.created_at DESC LIMIT 1 ) om ON true @@ -635,11 +637,13 @@ export class WorkingGroupDatabase { return new Map(); } + // canonical_user_id is resolved at read time via slack_user_mappings + // to handle cases where leaders were added via Slack ID before linking WorkOS const result = await query( `SELECT wgl.working_group_id, wgl.user_id, - wgl.user_id AS canonical_user_id, + COALESCE(sm.workos_user_id, wgl.user_id) AS canonical_user_id, COALESCE( NULLIF(wgm.user_name, ''), NULLIF(TRIM(CONCAT(u.first_name, ' ', u.last_name)), ''), @@ -651,13 +655,14 @@ export class WorkingGroupDatabase { COALESCE(wgm.user_org_name, user_org.name, org.name) AS org_name, wgl.created_at FROM working_group_leaders wgl - LEFT JOIN working_group_memberships wgm ON wgl.user_id = wgm.workos_user_id AND wgm.working_group_id = wgl.working_group_id - LEFT JOIN users u ON wgl.user_id = u.workos_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + LEFT JOIN working_group_memberships wgm ON COALESCE(sm.workos_user_id, wgl.user_id) = wgm.workos_user_id AND wgm.working_group_id = wgl.working_group_id + LEFT JOIN users u ON COALESCE(sm.workos_user_id, wgl.user_id) = u.workos_user_id LEFT JOIN organizations user_org ON u.primary_organization_id = user_org.workos_organization_id LEFT JOIN LATERAL ( SELECT om.first_name, om.last_name, om.email, om.workos_organization_id FROM organization_memberships om - WHERE om.workos_user_id = wgl.user_id + WHERE om.workos_user_id = COALESCE(sm.workos_user_id, wgl.user_id) ORDER BY om.created_at DESC LIMIT 1 ) om ON true @@ -749,12 +754,18 @@ export class WorkingGroupDatabase { /** * Check if a user is a leader of a working group + * Handles both WorkOS and Slack user IDs by checking both directions of the mapping */ async isLeader(workingGroupId: string, userId: string): Promise { const canonicalUserId = await this.resolveToCanonicalUserId(userId); + // Check if: + // 1. The leader record has the canonical user ID directly, OR + // 2. The leader record has a Slack ID that maps to this WorkOS user ID const result = await query( - `SELECT 1 FROM working_group_leaders - WHERE working_group_id = $1 AND user_id = $2 + `SELECT 1 FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + WHERE wgl.working_group_id = $1 + AND (wgl.user_id = $2 OR sm.workos_user_id = $2) LIMIT 1`, [workingGroupId, canonicalUserId] ); @@ -764,15 +775,17 @@ export class WorkingGroupDatabase { /** * Get all committees led by a user * Returns committees of all types where the user is a leader + * Handles both WorkOS and Slack user IDs by checking both directions of the mapping */ async getCommitteesLedByUser(userId: string): Promise { const canonicalUserId = await this.resolveToCanonicalUserId(userId); const result = await query( - `SELECT wg.*, COUNT(wgm.id)::int AS member_count + `SELECT wg.*, COUNT(DISTINCT wgm.id)::int AS member_count FROM working_groups wg INNER JOIN working_group_leaders wgl ON wg.id = wgl.working_group_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id LEFT JOIN working_group_memberships wgm ON wg.id = wgm.working_group_id AND wgm.status = 'active' - WHERE wgl.user_id = $1 + WHERE (wgl.user_id = $1 OR sm.workos_user_id = $1) AND wg.status = 'active' GROUP BY wg.id ORDER BY wg.display_order, wg.name`, diff --git a/server/src/routes/content.ts b/server/src/routes/content.ts index 79ba41bca4..168a1cf998 100644 --- a/server/src/routes/content.ts +++ b/server/src/routes/content.ts @@ -104,9 +104,14 @@ export function createContentRouter(): Router { ); // Get committees user is a member of (non-public ones) + // Join with slack_user_mappings to handle users who were added as leader via Slack ID const memberResult = await pool.query( `SELECT wg.id, wg.slug, wg.name, wg.description, - EXISTS(SELECT 1 FROM working_group_leaders wgl WHERE wgl.working_group_id = wg.id AND wgl.user_id = $1) as is_leader + EXISTS( + SELECT 1 FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + WHERE wgl.working_group_id = wg.id AND (wgl.user_id = $1 OR sm.workos_user_id = $1) + ) as is_leader FROM working_group_memberships wgm JOIN working_groups wg ON wg.id = wgm.working_group_id WHERE wgm.workos_user_id = $1 @@ -320,11 +325,13 @@ export function createContentRouter(): Router { const pool = getPool(); // Get committees user leads + // Join with slack_user_mappings to handle users who were added as leader via Slack ID const leaderResult = await pool.query( `SELECT wg.id, wg.name, wg.slug FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id JOIN working_groups wg ON wg.id = wgl.working_group_id - WHERE wgl.user_id = $1`, + WHERE wgl.user_id = $1 OR sm.workos_user_id = $1`, [user.id] ); const ledCommittees = leaderResult.rows; From e10f9acd0328fe43cb16604df540bcc67f79eb3f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 9 Jan 2026 14:13:32 -0500 Subject: [PATCH 2/2] refactor: remove redundant resolveToCanonicalUserId calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on code review feedback: - Remove redundant resolveToCanonicalUserId() calls in isLeader() and getCommitteesLedByUser() since the JOIN handles ID resolution - Add consistent AND sm.workos_user_id IS NOT NULL to all JOINs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/david-event-mgmt-fix.md | 4 ++++ server/src/addie/member-context.ts | 2 +- server/src/db/outbound-db.ts | 4 ++-- server/src/db/working-group-db.ts | 12 +++++------- server/src/routes/content.ts | 4 ++-- 5 files changed, 14 insertions(+), 12 deletions(-) create mode 100644 .changeset/david-event-mgmt-fix.md diff --git a/.changeset/david-event-mgmt-fix.md b/.changeset/david-event-mgmt-fix.md new file mode 100644 index 0000000000..b0344a2e57 --- /dev/null +++ b/.changeset/david-event-mgmt-fix.md @@ -0,0 +1,4 @@ +--- +--- + +Fix chapter leader event management permission checks for users added via Slack diff --git a/server/src/addie/member-context.ts b/server/src/addie/member-context.ts index 7bbbd57ac7..b07df117e0 100644 --- a/server/src/addie/member-context.ts +++ b/server/src/addie/member-context.ts @@ -38,7 +38,7 @@ async function getPendingContentForUser( const leaderResult = await pool.query( `SELECT wg.id, wg.name, wg.slug FROM working_group_leaders wgl - LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL JOIN working_groups wg ON wg.id = wgl.working_group_id WHERE wgl.user_id = $1 OR sm.workos_user_id = $1`, [workosUserId] diff --git a/server/src/db/outbound-db.ts b/server/src/db/outbound-db.ts index 2663cd8fb6..9997fba7d8 100644 --- a/server/src/db/outbound-db.ts +++ b/server/src/db/outbound-db.ts @@ -746,7 +746,7 @@ export async function getMemberCapabilities( AND ( EXISTS(SELECT 1 FROM working_group_memberships wgm WHERE wgm.working_group_id = wg.id AND wgm.workos_user_id = $1) OR EXISTS(SELECT 1 FROM working_group_leaders wgl - LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL WHERE wgl.working_group_id = wg.id AND (wgl.user_id = $1 OR sm.workos_user_id = $1)) )) as wg_count, (SELECT COUNT(DISTINCT wg.id) FROM working_groups wg @@ -754,7 +754,7 @@ export async function getMemberCapabilities( AND ( EXISTS(SELECT 1 FROM working_group_memberships wgm WHERE wgm.working_group_id = wg.id AND wgm.workos_user_id = $1) OR EXISTS(SELECT 1 FROM working_group_leaders wgl - LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL WHERE wgl.working_group_id = wg.id AND (wgl.user_id = $1 OR sm.workos_user_id = $1)) )) as council_count`, [workosUserId] diff --git a/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts index e50ff9c23b..c777a3fc67 100644 --- a/server/src/db/working-group-db.ts +++ b/server/src/db/working-group-db.ts @@ -757,17 +757,16 @@ export class WorkingGroupDatabase { * Handles both WorkOS and Slack user IDs by checking both directions of the mapping */ async isLeader(workingGroupId: string, userId: string): Promise { - const canonicalUserId = await this.resolveToCanonicalUserId(userId); // Check if: - // 1. The leader record has the canonical user ID directly, OR + // 1. The leader record has the user ID directly, OR // 2. The leader record has a Slack ID that maps to this WorkOS user ID const result = await query( `SELECT 1 FROM working_group_leaders wgl - LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL WHERE wgl.working_group_id = $1 AND (wgl.user_id = $2 OR sm.workos_user_id = $2) LIMIT 1`, - [workingGroupId, canonicalUserId] + [workingGroupId, userId] ); return result.rows.length > 0; } @@ -778,18 +777,17 @@ export class WorkingGroupDatabase { * Handles both WorkOS and Slack user IDs by checking both directions of the mapping */ async getCommitteesLedByUser(userId: string): Promise { - const canonicalUserId = await this.resolveToCanonicalUserId(userId); const result = await query( `SELECT wg.*, COUNT(DISTINCT wgm.id)::int AS member_count FROM working_groups wg INNER JOIN working_group_leaders wgl ON wg.id = wgl.working_group_id - LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL LEFT JOIN working_group_memberships wgm ON wg.id = wgm.working_group_id AND wgm.status = 'active' WHERE (wgl.user_id = $1 OR sm.workos_user_id = $1) AND wg.status = 'active' GROUP BY wg.id ORDER BY wg.display_order, wg.name`, - [canonicalUserId] + [userId] ); const groups = result.rows; diff --git a/server/src/routes/content.ts b/server/src/routes/content.ts index 168a1cf998..598bd4c909 100644 --- a/server/src/routes/content.ts +++ b/server/src/routes/content.ts @@ -109,7 +109,7 @@ export function createContentRouter(): Router { `SELECT wg.id, wg.slug, wg.name, wg.description, EXISTS( SELECT 1 FROM working_group_leaders wgl - LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL WHERE wgl.working_group_id = wg.id AND (wgl.user_id = $1 OR sm.workos_user_id = $1) ) as is_leader FROM working_group_memberships wgm @@ -329,7 +329,7 @@ export function createContentRouter(): Router { const leaderResult = await pool.query( `SELECT wg.id, wg.name, wg.slug FROM working_group_leaders wgl - LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL JOIN working_groups wg ON wg.id = wgl.working_group_id WHERE wgl.user_id = $1 OR sm.workos_user_id = $1`, [user.id]