/** * Member Tiers Utility Service * * Provides centralized tier determination logic for members * Replaces hardcoded default 'user' tier assignments with proper data-driven logic */ import type { KeycloakUserRepresentation } from '~/utils/types'; export type MemberTier = 'user' | 'board' | 'admin'; interface TierDeterminationResult { tier: MemberTier; source: 'portal_group' | 'keycloak_groups' | 'keycloak_roles' | 'nocodb' | 'default' | 'override'; confidence: 'high' | 'medium' | 'low'; reason: string; } /** * Determine member tier from NocoDB member data * This is the primary source of truth for member tiers */ export async function determineMemberTierFromNocoDB(memberId: string): Promise { try { const { getMemberById } = await import('~/server/utils/nocodb'); const member = await getMemberById(memberId); if (!member) { return null; } // Check portal_group field first (highest priority) if (member.portal_group) { const tier = mapPortalGroupToTier(member.portal_group); if (tier) { return { tier, source: 'portal_group', confidence: 'high', reason: `Member has portal_group: ${member.portal_group}` }; } } // Check membership_type as secondary source if (member.membership_type) { const tier = mapMembershipTypeToTier(member.membership_type); if (tier) { return { tier, source: 'nocodb', confidence: 'medium', reason: `Derived from membership_type: ${member.membership_type}` }; } } // Check if member has board position if (member.board_position || member.board_role) { return { tier: 'board', source: 'nocodb', confidence: 'high', reason: `Member has board position: ${member.board_position || member.board_role}` }; } return null; } catch (error) { console.error('[member-tiers] Error fetching member from NocoDB:', error); return null; } } /** * Determine member tier from Keycloak user data * Used as fallback when NocoDB data is not available */ export function determineMemberTierFromKeycloak(user: KeycloakUserRepresentation): TierDeterminationResult { // Check user groups (primary method) if (user.groups && Array.isArray(user.groups)) { for (const group of user.groups) { const groupName = typeof group === 'string' ? group : group.name; if (groupName === 'admin' || groupName === '/admin') { return { tier: 'admin', source: 'keycloak_groups', confidence: 'high', reason: 'User is in admin group' }; } if (groupName === 'board' || groupName === '/board') { return { tier: 'board', source: 'keycloak_groups', confidence: 'high', reason: 'User is in board group' }; } if (groupName === 'user' || groupName === '/user' || groupName === '/users') { return { tier: 'user', source: 'keycloak_groups', confidence: 'high', reason: 'User is in user group' }; } } } // Check user attributes if (user.attributes) { if (user.attributes.membershipTier) { const tier = Array.isArray(user.attributes.membershipTier) ? user.attributes.membershipTier[0] : user.attributes.membershipTier; if (isValidTier(tier)) { return { tier: tier as MemberTier, source: 'keycloak_groups', confidence: 'medium', reason: 'Tier from user attributes' }; } } if (user.attributes.tier) { const tier = Array.isArray(user.attributes.tier) ? user.attributes.tier[0] : user.attributes.tier; if (isValidTier(tier)) { return { tier: tier as MemberTier, source: 'keycloak_groups', confidence: 'medium', reason: 'Tier from user attributes' }; } } } // Check realm roles as last resort if (user.realmRoles && Array.isArray(user.realmRoles)) { if (user.realmRoles.includes('admin')) { return { tier: 'admin', source: 'keycloak_roles', confidence: 'medium', reason: 'User has admin realm role' }; } if (user.realmRoles.includes('board')) { return { tier: 'board', source: 'keycloak_roles', confidence: 'medium', reason: 'User has board realm role' }; } } // No tier information found - return null instead of defaulting return { tier: 'user', source: 'default', confidence: 'low', reason: 'No tier information found, requires manual assignment' }; } /** * Determine member tier by email - combines all sources */ export async function determineMemberTierByEmail(email: string): Promise { try { // First try to find member in NocoDB by email const { getMemberByEmail } = await import('~/server/utils/nocodb'); const member = await getMemberByEmail(email); if (member && member.Id) { const nocodbResult = await determineMemberTierFromNocoDB(member.Id); if (nocodbResult && nocodbResult.confidence !== 'low') { return nocodbResult; } } // If not found in NocoDB or low confidence, check Keycloak const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin'); const keycloakAdmin = createKeycloakAdminClient(); const users = await keycloakAdmin.findUserByEmail(email); if (users && users.length > 0) { const keycloakResult = determineMemberTierFromKeycloak(users[0]); if (keycloakResult.confidence !== 'low') { return keycloakResult; } } // Return default with low confidence if nothing found return { tier: 'user', source: 'default', confidence: 'low', reason: 'Unable to determine tier from available data sources' }; } catch (error) { console.error('[member-tiers] Error determining tier by email:', error); return { tier: 'user', source: 'default', confidence: 'low', reason: 'Error occurred while determining tier' }; } } /** * Map portal_group values to tiers */ function mapPortalGroupToTier(portalGroup: string): MemberTier | null { const normalized = portalGroup.toLowerCase().trim(); switch (normalized) { case 'admin': case 'administrator': return 'admin'; case 'board': case 'board_member': case 'board-member': return 'board'; case 'user': case 'member': case 'regular': return 'user'; default: return null; } } /** * Map membership_type to tier (fallback logic) */ function mapMembershipTypeToTier(membershipType: string): MemberTier | null { const normalized = membershipType.toLowerCase().trim(); if (normalized.includes('board') || normalized.includes('executive') || normalized.includes('officer')) { return 'board'; } if (normalized.includes('admin') || normalized.includes('staff')) { return 'admin'; } if (normalized.includes('member') || normalized.includes('regular') || normalized.includes('standard')) { return 'user'; } return null; } /** * Validate if a string is a valid tier */ function isValidTier(tier: string): boolean { return ['user', 'board', 'admin'].includes(tier); } /** * Update member tier in both NocoDB and Keycloak */ export async function updateMemberTier( memberId: string, newTier: MemberTier, updateKeycloak: boolean = true ): Promise<{ success: boolean; message: string }> { try { // Update in NocoDB const { updateMember } = await import('~/server/utils/nocodb'); await updateMember(memberId, { portal_group: newTier }); if (updateKeycloak) { // Get member email from NocoDB const { getMemberById } = await import('~/server/utils/nocodb'); const member = await getMemberById(memberId); if (member && member.email) { // Find user in Keycloak const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin'); const keycloakAdmin = createKeycloakAdminClient(); const users = await keycloakAdmin.findUserByEmail(member.email); if (users && users.length > 0) { const userId = users[0].id; // Change user's primary group await keycloakAdmin.changeUserPrimaryGroup(userId, newTier); } } } return { success: true, message: `Successfully updated member tier to ${newTier}` }; } catch (error: any) { console.error('[member-tiers] Error updating member tier:', error); return { success: false, message: error.message || 'Failed to update member tier' }; } } /** * Audit and fix tier mismatches between NocoDB and Keycloak */ export async function auditAndFixTierMismatches(): Promise<{ checked: number; mismatches: number; fixed: number; errors: string[]; }> { const results = { checked: 0, mismatches: 0, fixed: 0, errors: [] as string[] }; try { const { getMembers } = await import('~/server/utils/nocodb'); const members = await getMembers(); if (!members?.list) { return results; } for (const member of members.list) { results.checked++; if (!member.email) continue; try { // Get tier from NocoDB const nocodbResult = await determineMemberTierFromNocoDB(member.Id); if (!nocodbResult) continue; // Get tier from Keycloak const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin'); const keycloakAdmin = createKeycloakAdminClient(); const users = await keycloakAdmin.findUserByEmail(member.email); if (users && users.length > 0) { const keycloakResult = determineMemberTierFromKeycloak(users[0]); // Check for mismatch if (nocodbResult.tier !== keycloakResult.tier) { results.mismatches++; console.log(`[member-tiers] Tier mismatch for ${member.email}: NocoDB=${nocodbResult.tier}, Keycloak=${keycloakResult.tier}`); // Fix by updating Keycloak to match NocoDB (NocoDB is source of truth) try { await keycloakAdmin.changeUserPrimaryGroup(users[0].id, nocodbResult.tier); results.fixed++; } catch (fixError: any) { results.errors.push(`Failed to fix tier for ${member.email}: ${fixError.message}`); } } } } catch (error: any) { results.errors.push(`Error checking ${member.email}: ${error.message}`); } } return results; } catch (error: any) { console.error('[member-tiers] Error in audit:', error); results.errors.push(`Fatal error: ${error.message}`); return results; } }