import * as plugins from '../plugins.js'; import { Reception } from './classes.reception.js'; import { Organization } from './classes.organization.js'; import { User } from './classes.user.js'; export class OrganizationManager { public static readonly platformRoleKeys = ['owner', 'admin', 'editor', 'viewer', 'guest', 'outlaw']; public receptionRef: Reception; public get db() { return this.receptionRef.db.smartdataDb; } public typedrouter = new plugins.typedrequest.TypedRouter(); public COrganization = plugins.smartdata.setDefaultManagerForDoc(this, Organization); constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; this.receptionRef.typedrouter.addTypedRouter(this.typedrouter); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'createOrganization', async (requestArg) => { const nameIsAvailable = async () => { const existingOrg = await this.COrganization.getInstance({ data: { slug: requestArg.organizationSlug, }, }); const nameAvailable = !existingOrg; return nameAvailable; }; switch (requestArg.action) { case 'checkAvailability': return { nameAvailable: await nameIsAvailable(), }; break; case 'manifest': const nameCheckedOk = await nameIsAvailable(); const userData = await this.receptionRef.userManager.getUserByJwtValidation( requestArg.jwt ); const newOrg = await this.COrganization.createNewOrganizationForUser( this, userData.id, requestArg.organizationName, requestArg.organizationSlug ); const role = await this.receptionRef.roleManager.modifyRoleForUserAtOrg({ action: 'create', organizationId: newOrg.id, userId: userData.id, roles: ['owner'], }); newOrg.data.roleIds.push(role.id); await newOrg.save(); return { nameAvailable: true, resultingOrganization: await newOrg.createSavableObject(), role: await role.createSavableObject(), } break; } } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getOrganizationById', async (requestArg) => { const verifiedJwt = await this.receptionRef.jwtManager.verifyJWTAndGetData( requestArg.jwt ); const user = await this.receptionRef.userManager.CUser.getInstance({ id: verifiedJwt.data.userId, }); const organization = await this.COrganization.getInstance({ id: requestArg.id }); const role = await this.receptionRef.roleManager.CRole.getInstance({ data: { organizationId: organization.id, userId: user.id, } }); if (!role) { throw new plugins.typedrequest.TypedResponseError('User not authorized for the requested organization.'); } return { organization: await organization.createSavableObject() }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'updateOrganization', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); const organization = await this.updateOrganizationWithAudit({ user, organizationId: requestArg.organizationId, name: requestArg.name, slug: requestArg.slug, confirmationText: requestArg.confirmationText, }); return { success: true, organization: await organization.createSavableObject(), }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'deleteOrganization', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); await this.deleteOrganizationWithAudit({ user, organizationId: requestArg.organizationId, confirmationText: requestArg.confirmationText, }); return { success: true, deletedOrganizationId: requestArg.organizationId, }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getOrgRoleDefinitions', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); const organization = await this.getOrganizationOrThrow(requestArg.organizationId); await this.getRoleOrThrow(user, organization); return { roleDefinitions: this.getCustomRoleDefinitions(organization), }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'upsertOrgRoleDefinition', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); const roleDefinitions = await this.upsertOrgRoleDefinition({ user, organizationId: requestArg.organizationId, roleDefinition: requestArg.roleDefinition, }); return { success: true, roleDefinitions, }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'deleteOrgRoleDefinition', async (requestArg) => { const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt); const roleDefinitions = await this.deleteOrgRoleDefinition({ user, organizationId: requestArg.organizationId, roleKey: requestArg.roleKey, confirmationText: requestArg.confirmationText, }); return { success: true, roleDefinitions, }; } ) ); } private getCustomRoleDefinitions(organizationArg: Organization) { return organizationArg.data.roleDefinitions || []; } private normalizeRoleKey(roleKeyArg: string) { return (roleKeyArg || '').trim().toLowerCase(); } public validateRoleKey(roleKeyArg: string) { const roleKey = this.normalizeRoleKey(roleKeyArg); if (!roleKey || roleKey.length < 2 || roleKey.length > 64) { throw new plugins.typedrequest.TypedResponseError('Role key must be between 2 and 64 characters.'); } if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(roleKey)) { throw new plugins.typedrequest.TypedResponseError('Role key may only contain lowercase letters, numbers, and single dashes.'); } return roleKey; } public async getAvailableRoleKeys(organizationIdArg: string) { const organization = await this.getOrganizationOrThrow(organizationIdArg); return [ ...OrganizationManager.platformRoleKeys, ...this.getCustomRoleDefinitions(organization).map((roleDefinitionArg) => roleDefinitionArg.key), ]; } public async assertRoleKeysAreValid(organizationIdArg: string, roleKeysArg: string[]) { const normalizedRoleKeys = [...new Set((roleKeysArg || []).map((roleKeyArg) => this.validateRoleKey(roleKeyArg)))]; if (!normalizedRoleKeys.length) { throw new plugins.typedrequest.TypedResponseError('At least one role is required.'); } const availableRoleKeys = await this.getAvailableRoleKeys(organizationIdArg); const invalidRoleKeys = normalizedRoleKeys.filter((roleKeyArg) => !availableRoleKeys.includes(roleKeyArg)); if (invalidRoleKeys.length) { throw new plugins.typedrequest.TypedResponseError(`Unknown organization roles: ${invalidRoleKeys.join(', ')}.`); } return normalizedRoleKeys; } private normalizeSlug(slugArg: string) { return (slugArg || '').trim().toLowerCase(); } private validateSlug(slugArg: string) { const slug = this.normalizeSlug(slugArg); if (!slug || slug.length < 3 || slug.length > 64) { throw new plugins.typedrequest.TypedResponseError('Organization slug must be between 3 and 64 characters.'); } if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) { throw new plugins.typedrequest.TypedResponseError('Organization slug may only contain lowercase letters, numbers, and single dashes.'); } return slug; } private assertConfirmation(confirmationTextArg: string, expectedTextArg: string) { if ((confirmationTextArg || '').trim() !== expectedTextArg) { throw new plugins.typedrequest.TypedResponseError(`Confirmation text must be exactly "${expectedTextArg}".`); } } private async getOrganizationOrThrow(organizationIdArg: string) { const organization = await this.COrganization.getInstance({ id: organizationIdArg, }); if (!organization) { throw new plugins.typedrequest.TypedResponseError('Organization not found.'); } return organization; } private async getRoleOrThrow(userArg: User, organizationArg: Organization) { const role = await this.receptionRef.roleManager.getRoleForUserAndOrg(userArg, organizationArg); if (!role) { throw new plugins.typedrequest.TypedResponseError('User not authorized for this organization.'); } return role; } private async verifyAdmin(userArg: User, organizationArg: Organization) { const role = await this.getRoleOrThrow(userArg, organizationArg); if (!role.data.roles.some((roleArg) => ['owner', 'admin'].includes(roleArg))) { throw new plugins.typedrequest.TypedResponseError('Organization admin privileges required.'); } return role; } private async verifyOwner(userArg: User, organizationArg: Organization) { const role = await this.getRoleOrThrow(userArg, organizationArg); if (!role.data.roles.includes('owner')) { throw new plugins.typedrequest.TypedResponseError('Organization owner privileges required.'); } return role; } private async emitOrganizationAlert(optionsArg: { organizationId: string; eventType: string; severity: plugins.idpInterfaces.data.TAlertSeverity; title: string; body: string; actorUserId: string; relatedEntityId?: string; relatedEntityType?: string; }) { await this.receptionRef.alertManager.createAlertsForEvent({ category: 'admin', organizationId: optionsArg.organizationId, eventType: optionsArg.eventType, severity: optionsArg.severity, title: optionsArg.title, body: optionsArg.body, actorUserId: optionsArg.actorUserId, relatedEntityId: optionsArg.relatedEntityId, relatedEntityType: optionsArg.relatedEntityType, }); } public async upsertOrgRoleDefinition(optionsArg: { user: User; organizationId: string; roleDefinition: { key: string; name: string; description?: string; }; }) { const organization = await this.getOrganizationOrThrow(optionsArg.organizationId); await this.verifyAdmin(optionsArg.user, organization); const roleKey = this.validateRoleKey(optionsArg.roleDefinition.key); if (OrganizationManager.platformRoleKeys.includes(roleKey)) { throw new plugins.typedrequest.TypedResponseError('Platform roles cannot be redefined by an organization.'); } const roleName = (optionsArg.roleDefinition.name || '').trim(); if (!roleName) { throw new plugins.typedrequest.TypedResponseError('Role name is required.'); } const now = Date.now(); const roleDefinitions = this.getCustomRoleDefinitions(organization); const existingRoleDefinition = roleDefinitions.find((roleDefinitionArg) => roleDefinitionArg.key === roleKey); if (existingRoleDefinition) { existingRoleDefinition.name = roleName; existingRoleDefinition.description = optionsArg.roleDefinition.description?.trim() || ''; existingRoleDefinition.updatedAt = now; } else { roleDefinitions.push({ key: roleKey, name: roleName, description: optionsArg.roleDefinition.description?.trim() || '', createdAt: now, updatedAt: now, }); } organization.data.roleDefinitions = roleDefinitions.sort((leftArg, rightArg) => leftArg.name.localeCompare(rightArg.name)); await organization.save(); await this.receptionRef.activityLogManager.logActivity( optionsArg.user.id, 'role_changed', `${optionsArg.user.data.email} ${existingRoleDefinition ? 'updated' : 'created'} organization role ${roleKey}.`, { targetId: organization.id, targetType: 'organization-role', } ); await this.emitOrganizationAlert({ organizationId: organization.id, eventType: 'org_role_definition_updated', severity: 'medium', title: 'Organization role definition updated', body: `${optionsArg.user.data.email} ${existingRoleDefinition ? 'updated' : 'created'} organization role ${roleKey}.`, actorUserId: optionsArg.user.id, relatedEntityId: roleKey, relatedEntityType: 'organization-role', }); return organization.data.roleDefinitions; } public async deleteOrgRoleDefinition(optionsArg: { user: User; organizationId: string; roleKey: string; confirmationText: string; }) { const organization = await this.getOrganizationOrThrow(optionsArg.organizationId); await this.verifyAdmin(optionsArg.user, organization); const roleKey = this.validateRoleKey(optionsArg.roleKey); if (OrganizationManager.platformRoleKeys.includes(roleKey)) { throw new plugins.typedrequest.TypedResponseError('Platform roles cannot be deleted.'); } this.assertConfirmation(optionsArg.confirmationText, `delete role ${roleKey}`); const roleDefinitions = this.getCustomRoleDefinitions(organization); if (!roleDefinitions.some((roleDefinitionArg) => roleDefinitionArg.key === roleKey)) { throw new plugins.typedrequest.TypedResponseError('Organization role definition not found.'); } organization.data.roleDefinitions = roleDefinitions.filter((roleDefinitionArg) => roleDefinitionArg.key !== roleKey); await organization.save(); const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organization.id); for (const role of roles) { if (role.data.roles.includes(roleKey)) { role.data.roles = role.data.roles.filter((roleKeyArg) => roleKeyArg !== roleKey); if (!role.data.roles.length) { role.data.roles = ['viewer']; } await role.save(); } } const appConnections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({ 'data.organizationId': organization.id, }); for (const connection of appConnections) { if (connection.data.roleMappings?.some((mappingArg) => mappingArg.orgRoleKey === roleKey)) { connection.data.roleMappings = connection.data.roleMappings.filter((mappingArg) => mappingArg.orgRoleKey !== roleKey); await connection.save(); } } await this.receptionRef.activityLogManager.logActivity( optionsArg.user.id, 'role_changed', `${optionsArg.user.data.email} deleted organization role ${roleKey}.`, { targetId: organization.id, targetType: 'organization-role', } ); await this.emitOrganizationAlert({ organizationId: organization.id, eventType: 'org_role_definition_deleted', severity: 'high', title: 'Organization role definition deleted', body: `${optionsArg.user.data.email} deleted organization role ${roleKey}. Member assignments and app mappings were cleaned up.`, actorUserId: optionsArg.user.id, relatedEntityId: roleKey, relatedEntityType: 'organization-role', }); return organization.data.roleDefinitions; } public async updateOrganizationWithAudit(optionsArg: { user: User; organizationId: string; name?: string; slug?: string; confirmationText: string; }) { const organization = await this.getOrganizationOrThrow(optionsArg.organizationId); await this.verifyAdmin(optionsArg.user, organization); this.assertConfirmation(optionsArg.confirmationText, organization.data.slug); const previousName = organization.data.name; const previousSlug = organization.data.slug; const nextName = typeof optionsArg.name === 'string' ? optionsArg.name.trim() : previousName; const nextSlug = typeof optionsArg.slug === 'string' ? this.validateSlug(optionsArg.slug) : previousSlug; if (!nextName) { throw new plugins.typedrequest.TypedResponseError('Organization name is required.'); } if (nextSlug !== previousSlug) { const existingOrganization = await this.COrganization.getInstance({ data: { slug: nextSlug, }, }); if (existingOrganization && existingOrganization.id !== organization.id) { throw new plugins.typedrequest.TypedResponseError('Organization slug is already in use.'); } } organization.data.name = nextName; organization.data.slug = nextSlug; await organization.save(); const changes = [ previousName !== nextName ? `name "${previousName}" -> "${nextName}"` : '', previousSlug !== nextSlug ? `slug "${previousSlug}" -> "${nextSlug}"` : '', ].filter(Boolean).join(', ') || 'no field changes'; await this.receptionRef.activityLogManager.logActivity( optionsArg.user.id, 'org_updated', `Organization ${previousName} updated: ${changes}.`, { targetId: organization.id, targetType: 'organization', } ); await this.emitOrganizationAlert({ organizationId: organization.id, eventType: 'org_updated', severity: 'high', title: 'Organization settings updated', body: `${optionsArg.user.data.email} updated ${previousName}: ${changes}.`, actorUserId: optionsArg.user.id, relatedEntityId: organization.id, relatedEntityType: 'organization', }); return organization; } public async deleteOrganizationWithAudit(optionsArg: { user: User; organizationId: string; confirmationText: string; }) { const organization = await this.getOrganizationOrThrow(optionsArg.organizationId); await this.verifyOwner(optionsArg.user, organization); this.assertConfirmation(optionsArg.confirmationText, `delete ${organization.data.slug}`); const organizationName = organization.data.name; const organizationSlug = organization.data.slug; const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organization.id); const appConnections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({ 'data.organizationId': organization.id, }); const invitations = await this.receptionRef.userInvitationManager.CUserInvitation.getInstances({}); const billingPlans = await this.receptionRef.billingPlanManager.CBillingPlan.getInstances({ 'data.organizationId': organization.id, }); await this.receptionRef.activityLogManager.logActivity( optionsArg.user.id, 'org_deleted', `Organization ${organizationName} (${organizationSlug}) deleted.`, { targetId: organization.id, targetType: 'organization', } ); await this.emitOrganizationAlert({ organizationId: organization.id, eventType: 'org_deleted', severity: 'critical', title: 'Organization deleted', body: `${optionsArg.user.data.email} deleted ${organizationName}. ${roles.length} memberships and ${appConnections.length} app connections were removed.`, actorUserId: optionsArg.user.id, relatedEntityId: organization.id, relatedEntityType: 'organization', }); for (const connection of appConnections) { await connection.delete(); } for (const invitation of invitations) { if (invitation.data.organizationRefs.some((refArg) => refArg.organizationId === organization.id)) { await invitation.removeOrganization(organization.id); } } for (const billingPlan of billingPlans) { await billingPlan.delete(); } for (const role of roles) { const memberUser = await this.receptionRef.userManager.CUser.getInstance({ id: role.data.userId, }); if (memberUser?.data.connectedOrgs) { memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter( (organizationIdArg) => organizationIdArg !== organization.id ); await memberUser.save(); } await role.delete(); } await organization.delete(); } public async getAllOrganizationsForUser( userArg: User, ) { const organizations: Organization[] = []; const userRoles = await this.receptionRef.roleManager.getAllRolesForUser(userArg); for (const role of userRoles) { const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({ id: role.data.organizationId }); if (!organizations.find(orgArg => orgArg.id === organization.id)) { organizations.push(organization); } } return organizations; } }