import * as plugins from '../plugins.js'; import { Reception } from './classes.reception.js'; import { UserInvitation } from './classes.userinvitation.js'; import { Organization } from './classes.organization.js'; import { User } from './classes.user.js'; import { Role } from './classes.role.js'; export class UserInvitationManager { public receptionRef: Reception; public get db() { return this.receptionRef.db.smartdataDb; } public typedrouter = new plugins.typedrequest.TypedRouter(); public CUserInvitation = plugins.smartdata.setDefaultManagerForDoc(this, UserInvitation); constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; this.receptionRef.typedrouter.addTypedRouter(this.typedrouter); this.setupHandlers(); } private setupHandlers() { // Create invitation this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'createInvitation', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId); const email = requestArg.email.toLowerCase().trim(); // Check if user with this email already exists const existingUser = await this.receptionRef.userManager.CUser.getInstance({ data: { email }, }); if (existingUser) { // User already exists - just add them to the org directly const existingRole = await this.receptionRef.roleManager.CRole.getInstance({ data: { userId: existingUser.id, organizationId: requestArg.organizationId, }, }); if (existingRole) { return { success: false, isNew: false, message: 'User is already a member of this organization.', }; } // Add user to org with the specified roles await this.receptionRef.roleManager.modifyRoleForUserAtOrg({ action: 'create', userId: existingUser.id, organizationId: requestArg.organizationId, roles: requestArg.roles, }); return { success: true, isNew: false, message: 'Existing user has been added to the organization.', }; } // Check if invitation already exists for this email let invitation = await this.CUserInvitation.getInstance({ data: { email }, }); let isNew = false; if (invitation) { // Add org to existing invitation await invitation.addOrganization(requestArg.organizationId, user.id, requestArg.roles); } else { // Create new invitation invitation = await UserInvitation.createNewInvitation( email, requestArg.organizationId, user.id, requestArg.roles ); isNew = true; } // Send invitation email await this.sendInvitationEmail(invitation, requestArg.organizationId); return { success: true, invitation: await invitation.createSavableObject(), isNew, }; } ) ); // Get org invitations this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getOrgInvitations', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId); const allInvitations = await this.CUserInvitation.getInstances({}); const orgInvitations = allInvitations.filter(inv => inv.data.status === 'pending' && !inv.isExpired() && inv.data.organizationRefs.some(ref => ref.organizationId === requestArg.organizationId) ); return { invitations: await Promise.all(orgInvitations.map(inv => inv.createSavableObject())), }; } ) ); // Get org members this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getOrgMembers', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); await this.verifyUserIsMemberOfOrg(user.id, requestArg.organizationId); const roles = await this.receptionRef.roleManager.CRole.getInstances({ data: { organizationId: requestArg.organizationId }, }); const members: Array<{ user: plugins.idpInterfaces.data.IUser; role: plugins.idpInterfaces.data.IRole; }> = []; for (const role of roles) { const memberUser = await this.receptionRef.userManager.CUser.getInstance({ id: role.data.userId, }); if (memberUser) { members.push({ user: await memberUser.createSavableObject(), role: await role.createSavableObject(), }); } } return { members }; } ) ); // Cancel invitation this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'cancelInvitation', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId); const invitation = await this.CUserInvitation.getInstance({ id: requestArg.invitationId }); if (!invitation) { return { success: false, message: 'Invitation not found.' }; } await invitation.removeOrganization(requestArg.organizationId); return { success: true }; } ) ); // Resend invitation this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'resendInvitation', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId); const invitation = await this.CUserInvitation.getInstance({ id: requestArg.invitationId }); if (!invitation) { return { success: false, message: 'Invitation not found.' }; } await invitation.regenerateToken(); await this.sendInvitationEmail(invitation, requestArg.organizationId); return { success: true, message: 'Invitation resent.' }; } ) ); // Remove member this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'removeMember', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId); // Cannot remove yourself if you're the only owner const role = await this.receptionRef.roleManager.CRole.getInstance({ data: { userId: requestArg.userId, organizationId: requestArg.organizationId, }, }); if (!role) { return { success: false, message: 'Member not found.' }; } // Check if trying to remove an owner if (role.data.roles.includes('owner')) { // Count owners const allRoles = await this.receptionRef.roleManager.CRole.getInstances({ data: { organizationId: requestArg.organizationId }, }); const ownerCount = allRoles.filter(r => r.data.roles.includes('owner')).length; if (ownerCount <= 1) { return { success: false, message: 'Cannot remove the last owner. Transfer ownership first.', }; } } await role.delete(); // Remove org from user's connectedOrgs const memberUser = await this.receptionRef.userManager.CUser.getInstance({ id: requestArg.userId, }); if (memberUser && memberUser.data.connectedOrgs) { memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter( orgId => orgId !== requestArg.organizationId ); await memberUser.save(); } return { success: true }; } ) ); // Update member roles this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'updateMemberRoles', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId); const role = await this.receptionRef.roleManager.CRole.getInstance({ data: { userId: requestArg.userId, organizationId: requestArg.organizationId, }, }); if (!role) { return { success: false, message: 'Member not found.' }; } // If removing owner role, check we're not removing the last owner if (role.data.roles.includes('owner') && !requestArg.roles.includes('owner')) { const allRoles = await this.receptionRef.roleManager.CRole.getInstances({ data: { organizationId: requestArg.organizationId }, }); const ownerCount = allRoles.filter(r => r.data.roles.includes('owner')).length; if (ownerCount <= 1) { return { success: false, message: 'Cannot remove owner role from the last owner.', }; } } role.data.roles = requestArg.roles; await role.save(); return { success: true, role: await role.createSavableObject() }; } ) ); // Transfer ownership this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'transferOwnership', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); // Verify current user is an owner const currentUserRole = await this.receptionRef.roleManager.CRole.getInstance({ data: { userId: user.id, organizationId: requestArg.organizationId, }, }); if (!currentUserRole || !currentUserRole.data.roles.includes('owner')) { throw new plugins.typedrequest.TypedResponseError( 'Only owners can transfer ownership.' ); } // Get new owner's role const newOwnerRole = await this.receptionRef.roleManager.CRole.getInstance({ data: { userId: requestArg.newOwnerId, organizationId: requestArg.organizationId, }, }); if (!newOwnerRole) { return { success: false, message: 'New owner must be a member of the organization.' }; } // Add owner role to new owner if (!newOwnerRole.data.roles.includes('owner')) { newOwnerRole.data.roles.push('owner'); await newOwnerRole.save(); } // Remove owner role from current user (but keep other roles) currentUserRole.data.roles = currentUserRole.data.roles.filter(r => r !== 'owner'); if (currentUserRole.data.roles.length === 0) { currentUserRole.data.roles = ['admin']; // Demote to admin } await currentUserRole.save(); return { success: true }; } ) ); // Get invitation by token this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getInvitationByToken', async (requestArg) => { const invitation = await this.CUserInvitation.getInstance({ data: { token: requestArg.token }, }); if (!invitation) { return { isExpired: true, requiresRegistration: false }; } if (invitation.isExpired()) { return { isExpired: true, requiresRegistration: false }; } // Get organization names const organizations: Array<{ id: string; name: string }> = []; for (const ref of invitation.data.organizationRefs) { const org = await this.receptionRef.organizationmanager.COrganization.getInstance({ id: ref.organizationId, }); if (org) { organizations.push({ id: org.id, name: org.data.name }); } } // Check if user with this email exists const existingUser = await this.receptionRef.userManager.CUser.getInstance({ data: { email: invitation.data.email }, }); return { invitation: await invitation.createSavableObject(), organizations, isExpired: false, requiresRegistration: !existingUser, }; } ) ); // Accept invitation this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'acceptInvitation', async (requestArg) => { const invitation = await this.CUserInvitation.getInstance({ data: { token: requestArg.token }, }); if (!invitation) { return { success: false, message: 'Invalid invitation token.' }; } if (invitation.isExpired()) { return { success: false, message: 'This invitation has expired.' }; } const user = await this.receptionRef.userManager.CUser.getInstance({ id: requestArg.userId, }); if (!user) { return { success: false, message: 'User not found.' }; } // Create roles for each organization const organizations: plugins.idpInterfaces.data.IOrganization[] = []; const roles: plugins.idpInterfaces.data.IRole[] = []; for (const ref of invitation.data.organizationRefs) { // Check if role already exists let role = await this.receptionRef.roleManager.CRole.getInstance({ data: { userId: user.id, organizationId: ref.organizationId, }, }); if (!role) { role = await this.receptionRef.roleManager.modifyRoleForUserAtOrg({ action: 'create', userId: user.id, organizationId: ref.organizationId, roles: ref.roles, }); } roles.push(await role.createSavableObject()); const org = await this.receptionRef.organizationmanager.COrganization.getInstance({ id: ref.organizationId, }); if (org) { // Add role to org's roleIds if not already there if (!org.data.roleIds.includes(role.id)) { org.data.roleIds.push(role.id); await org.save(); } organizations.push(await org.createSavableObject()); } // Update user's connectedOrgs if (!user.data.connectedOrgs) { user.data.connectedOrgs = []; } if (!user.data.connectedOrgs.includes(ref.organizationId)) { user.data.connectedOrgs.push(ref.organizationId); } } await user.save(); await invitation.accept(user.id); return { success: true, organizations, roles }; } ) ); // Bulk create invitations this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'bulkCreateInvitations', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId); const org = await this.receptionRef.organizationmanager.COrganization.getInstance({ id: requestArg.organizationId, }); const orgName = org?.data.name || 'an organization'; const results: Array<{ email: string; success: boolean; status: 'invited' | 'already_member' | 'invalid_email' | 'error'; message?: string; }> = []; const summary = { total: 0, invited: 0, alreadyMembers: 0, invalid: 0, errors: 0, }; // Deduplicate emails in the batch const processedEmails = new Set(); for (const inv of requestArg.invitations) { summary.total++; const email = inv.email?.toLowerCase().trim(); // Validate email format if (!email || !this.isValidEmail(email)) { results.push({ email: inv.email || '', success: false, status: 'invalid_email', message: 'Invalid email format', }); summary.invalid++; continue; } // Skip duplicates within batch if (processedEmails.has(email)) { results.push({ email, success: false, status: 'invalid_email', message: 'Duplicate email in batch', }); summary.invalid++; continue; } processedEmails.add(email); try { // Check if user with this email already exists const existingUser = await this.receptionRef.userManager.CUser.getInstance({ data: { email }, }); if (existingUser) { // Check if already a member const existingRole = await this.receptionRef.roleManager.CRole.getInstance({ data: { userId: existingUser.id, organizationId: requestArg.organizationId, }, }); if (existingRole) { results.push({ email, success: false, status: 'already_member', message: 'Already a member of this organization', }); summary.alreadyMembers++; continue; } // Add existing user to org const roles = inv.roles?.length ? inv.roles : requestArg.defaultRoles; await this.receptionRef.roleManager.modifyRoleForUserAtOrg({ action: 'create', userId: existingUser.id, organizationId: requestArg.organizationId, roles, }); results.push({ email, success: true, status: 'invited', message: 'Existing user added to organization', }); summary.invited++; continue; } // Check if invitation already exists let invitation = await this.CUserInvitation.getInstance({ data: { email }, }); const roles = inv.roles?.length ? inv.roles : requestArg.defaultRoles; if (invitation) { // Add org to existing invitation await invitation.addOrganization(requestArg.organizationId, user.id, roles); } else { // Create new invitation invitation = await UserInvitation.createNewInvitation( email, requestArg.organizationId, user.id, roles ); } // Send invitation email await this.receptionRef.receptionMailer.sendInvitationEmail( email, orgName, invitation.data.token, this.receptionRef.options.baseUrl ); results.push({ email, success: true, status: 'invited', }); summary.invited++; } catch (error: any) { results.push({ email, success: false, status: 'error', message: error.message || 'Unknown error', }); summary.errors++; } } return { success: true, results, summary }; } ) ); } /** * Find invitation by email */ public async getInvitationByEmail(email: string): Promise { return this.CUserInvitation.getInstance({ data: { email: email.toLowerCase().trim() }, }); } /** * Get pending invitations for an email (for registration flow) */ public async getPendingInvitationsForEmail(email: string): Promise { const invitation = await this.getInvitationByEmail(email); if (invitation && invitation.data.status === 'pending' && !invitation.isExpired()) { return invitation; } return null; } /** * Clean up expired invitations */ public async cleanupExpiredInvitations(): Promise { const allInvitations = await this.CUserInvitation.getInstances({ data: { status: 'pending' }, }); let cleanedCount = 0; for (const invitation of allInvitations) { if (invitation.isExpired()) { invitation.data.status = 'expired'; await invitation.save(); cleanedCount++; } } return cleanedCount; } /** * Send invitation email */ private async sendInvitationEmail( invitation: UserInvitation, organizationId: string ): Promise { const org = await this.receptionRef.organizationmanager.COrganization.getInstance({ id: organizationId, }); const orgName = org?.data.name || 'an organization'; await this.receptionRef.receptionMailer.sendInvitationEmail( invitation.data.email, orgName, invitation.data.token, this.receptionRef.options.baseUrl ); } /** * Verify user is admin/owner of organization */ private async verifyUserIsAdminOfOrg(userId: string, organizationId: string): Promise { const role = await this.receptionRef.roleManager.CRole.getInstance({ data: { userId, organizationId }, }); if (!role) { throw new plugins.typedrequest.TypedResponseError('Not a member of this organization.'); } const hasAdminRole = role.data.roles.some(r => ['owner', 'admin'].includes(r) ); if (!hasAdminRole) { throw new plugins.typedrequest.TypedResponseError( 'You do not have permission to perform this action.' ); } } /** * Verify user is member of organization */ private async verifyUserIsMemberOfOrg(userId: string, organizationId: string): Promise { const role = await this.receptionRef.roleManager.CRole.getInstance({ data: { userId, organizationId }, }); if (!role) { throw new plugins.typedrequest.TypedResponseError('Not a member of this organization.'); } } /** * Validate email format */ private isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } }