( orgId: string, seatType: SeatType )
| 420 | * and counts pending invitations as reserved seats. |
| 421 | */ |
| 422 | export async function canAddSeat( |
| 423 | orgId: string, |
| 424 | seatType: SeatType |
| 425 | ): Promise<{ allowed: boolean; reason?: string }> { |
| 426 | const pool = getPool(); |
| 427 | const client = await pool.connect(); |
| 428 | try { |
| 429 | await client.query('BEGIN'); |
| 430 | |
| 431 | // Lock the org row to serialize concurrent seat checks and resolve |
| 432 | // the tier from the same (locked) read. |
| 433 | const tier = await readMembershipTierFromClient(client, orgId, { forUpdate: true }); |
| 434 | const limits = getSeatLimits(tier); |
| 435 | |
| 436 | // Count active members (contributor = admin-assigned OR Slack-mapped OR in working group) |
| 437 | const memberResult = await client.query<{ total: string; contributor: string }>( |
| 438 | `SELECT |
| 439 | COUNT(*) as total, |
| 440 | COUNT(*) FILTER (WHERE |
| 441 | om.seat_type = 'contributor' |
| 442 | OR EXISTS (SELECT 1 FROM slack_user_mappings sm WHERE sm.workos_user_id = om.workos_user_id AND sm.mapping_status = 'mapped') |
| 443 | OR EXISTS (SELECT 1 FROM working_group_memberships wgm WHERE wgm.workos_user_id = om.workos_user_id AND wgm.status = 'active') |
| 444 | ) as contributor |
| 445 | FROM organization_memberships om |
| 446 | WHERE om.workos_organization_id = $1`, |
| 447 | [orgId] |
| 448 | ); |
| 449 | const total = parseInt(memberResult.rows[0]?.total ?? '0', 10); |
| 450 | const contributors = parseInt(memberResult.rows[0]?.contributor ?? '0', 10); |
| 451 | |
| 452 | // Pending invitations also reserve seats |
| 453 | const pendingResult = await client.query<{ seat_type: string; count: string }>( |
| 454 | `SELECT seat_type, COUNT(*) as count FROM invitation_seat_types WHERE workos_organization_id = $1 GROUP BY seat_type`, |
| 455 | [orgId] |
| 456 | ); |
| 457 | let pendingContributors = 0; |
| 458 | let pendingCommunity = 0; |
| 459 | for (const row of pendingResult.rows) { |
| 460 | if (row.seat_type === 'contributor') pendingContributors = parseInt(row.count, 10); |
| 461 | if (row.seat_type === 'community_only') pendingCommunity = parseInt(row.count, 10); |
| 462 | } |
| 463 | |
| 464 | const usage = { |
| 465 | contributor: contributors + pendingContributors, |
| 466 | community_only: (total - contributors) + pendingCommunity, |
| 467 | }; |
| 468 | |
| 469 | await client.query('COMMIT'); |
| 470 | |
| 471 | const limit = seatType === 'contributor' ? limits.contributor : limits.community; |
| 472 | const used = seatType === 'contributor' ? usage.contributor : usage.community_only; |
| 473 | |
| 474 | if (limit === -1) return { allowed: true }; |
| 475 | if (limit === 0) return { allowed: false, reason: `Your membership tier does not include ${seatType === 'contributor' ? 'contributor' : 'community'} seats. Upgrade at /membership.` }; |
| 476 | if (used >= limit) return { allowed: false, reason: `All ${limit} ${seatType === 'contributor' ? 'contributor' : 'community'} seats are in use. Upgrade at /membership to add more.` }; |
| 477 | return { allowed: true }; |
| 478 | } catch (error) { |
| 479 | await client.query('ROLLBACK'); |
no test coverage detected