384 lines
11 KiB
TypeScript
384 lines
11 KiB
TypeScript
/**
|
|
* 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<TierDeterminationResult | null> {
|
|
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<TierDeterminationResult> {
|
|
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;
|
|
}
|
|
} |