import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Tenant } from './tenant.entity'; import { TenantSetting } from './tenant-setting.entity'; import { TenantMember } from './tenant-member.entity'; import { I18nService } from '../i18n/i18n.service'; import { UserRole } from '../user/user-role.enum'; @Injectable() export class TenantService { public static readonly DEFAULT_TENANT_NAME = 'Default'; constructor( @InjectRepository(Tenant) private readonly tenantRepository: Repository, @InjectRepository(TenantSetting) private readonly tenantSettingRepository: Repository, @InjectRepository(TenantMember) private readonly tenantMemberRepository: Repository, private readonly i18nService: I18nService, ) {} async findAll( page?: number, limit?: number, ): Promise<{ data: Tenant[]; total: number } | Tenant[]> { const queryBuilder = this.tenantRepository .createQueryBuilder('tenant') .leftJoinAndSelect('tenant.members', 'members') .leftJoinAndSelect('members.user', 'user') .orderBy('tenant.createdAt', 'ASC'); if (page !== undefined && limit !== undefined) { const [data, total] = await queryBuilder .skip((page - 1) * limit) .take(limit) .getManyAndCount(); return { data, total }; } return queryBuilder.getMany(); } async findById(id: string): Promise { const tenant = await this.tenantRepository.findOneBy({ id }); if (!tenant) throw new NotFoundException( this.i18nService.getMessage('tenantNotFound'), ); return tenant; } async findByName(name: string): Promise { return this.tenantRepository.findOneBy({ name }); } async create( name: string, domain?: string, parentId?: string, isSystem: boolean = false, ): Promise { const tenant = this.tenantRepository.create({ name, domain, parentId, isSystem, }); return this.tenantRepository.save(tenant); } async update(id: string, data: Partial): Promise { await this.tenantRepository.update(id, data); return this.findById(id); } async remove(id: string): Promise { await this.tenantRepository.delete(id); } async getSettings(tenantId: string): Promise { return this.tenantSettingRepository.findOneBy({ tenantId }); } async updateSettings( tenantId: string, data: Partial, ): Promise { let setting = await this.tenantSettingRepository.findOneBy({ tenantId }); if (!setting) { setting = this.tenantSettingRepository.create({ tenantId, ...data }); } else { if (data.enabledModelIds) { if ( setting.selectedLLMId && !data.enabledModelIds.includes(setting.selectedLLMId) ) { data.selectedLLMId = null as any; } if ( setting.selectedEmbeddingId && !data.enabledModelIds.includes(setting.selectedEmbeddingId) ) { data.selectedEmbeddingId = null as any; } if ( setting.selectedRerankId && !data.enabledModelIds.includes(setting.selectedRerankId) ) { data.selectedRerankId = null as any; } if ( setting.selectedVisionId && !data.enabledModelIds.includes(setting.selectedVisionId) ) { data.selectedVisionId = null as any; } } Object.assign(setting, data); } return this.tenantSettingRepository.save(setting); } async updateMemberRole( tenantId: string, userId: string, role: string, ): Promise { const existing = await this.tenantMemberRepository.findOneBy({ tenantId, userId, }); if (!existing) { throw new ForbiddenException(`Member not found in this organization`); } existing.role = role as any; return this.tenantMemberRepository.save(existing); } async addMember( tenantId: string, userId: string, role: string = 'USER', ): Promise { const existing = await this.tenantMemberRepository.findOneBy({ tenantId, userId, }); if (existing) { existing.role = role as any; return this.tenantMemberRepository.save(existing); } const member = this.tenantMemberRepository.create({ tenantId, userId, role: role as any, }); return this.tenantMemberRepository.save(member); } async removeMember(tenantId: string, userId: string): Promise { await this.tenantMemberRepository.delete({ tenantId, userId }); } async getMembers( tenantId: string, page?: number, limit?: number, ): Promise<{ data: TenantMember[]; total: number }> { const queryBuilder = this.tenantMemberRepository .createQueryBuilder('member') .leftJoinAndSelect('member.user', 'user') .where('member.tenantId = :tenantId', { tenantId }) .select([ 'member', 'user.id', 'user.username', 'user.displayName', 'user.isAdmin', ]) .orderBy('member.createdAt', 'DESC'); if (page !== undefined && limit !== undefined) { const [data, total] = await queryBuilder .skip((page - 1) * limit) .take(limit) .getManyAndCount(); return { data, total }; } const [data, total] = await queryBuilder.getManyAndCount(); return { data, total }; } /** * Ensure a "Default" tenant exists for data migration purposes. * Called during app bootstrap. */ async ensureDefaultTenant(): Promise { let defaultTenant = await this.findByName( TenantService.DEFAULT_TENANT_NAME, ); if (!defaultTenant) { defaultTenant = await this.create( TenantService.DEFAULT_TENANT_NAME, 'default.localhost', undefined, true, ); } else if (!defaultTenant.isSystem) { defaultTenant.isSystem = true; await this.tenantRepository.save(defaultTenant); } return defaultTenant; } async getMemberIds(tenantId: string): Promise { const members = await this.tenantMemberRepository.find({ where: { tenantId }, select: ['userId'], }); return members.map((m) => m.userId); } /** * Check if a user can access a tenant's resources. * Returns true if: * 1. Target tenant matches user's primary/current tenantId * 2. User is a member of the target tenant (any role) * 3. (Optional/Future) User is a global SUPER_ADMIN */ async canAccessTenant( userId: string, targetTenantId: string, currentTenantId?: string, ): Promise { // Case 1: Direct match with current/active tenant if (currentTenantId && currentTenantId === targetTenantId) { return true; } // Case 2: Global Super Admin check const user = await this.tenantMemberRepository.query( 'SELECT isAdmin FROM users WHERE id = ?', [userId], ); if (user && user[0] && user[0].isAdmin) { return true; } // Case 3: Hierarchical check (Direct membership or Parent Admin) let checkTenantId = targetTenantId; const seenTenantIds = new Set(); while (checkTenantId && !seenTenantIds.has(checkTenantId)) { seenTenantIds.add(checkTenantId); const membership = await this.tenantMemberRepository.findOneBy({ userId, tenantId: checkTenantId, }); if (membership) { // If it's the exact target tenant, any membership role works if (checkTenantId === targetTenantId) { return true; } // If it's an ancestor, user must be a TENANT_ADMIN or SUPER_ADMIN of that ancestor if ( membership.role === UserRole.TENANT_ADMIN || membership.role === UserRole.SUPER_ADMIN ) { return true; } } // Move up to parent const tenant = await this.tenantRepository.findOneBy({ id: checkTenantId, }); checkTenantId = tenant?.parentId as string; } return false; } /** * Determine the user's role in a specific tenant. * Checks for global SUPER_ADMIN status first. */ async getUserRole(userId: string, tenantId: string): Promise { // 1. Check if user is a global SUPER_ADMIN const user = await this.tenantRepository.query( 'SELECT isAdmin FROM users WHERE id = ?', [userId], ); if (user && user[0] && user[0].isAdmin) { return UserRole.SUPER_ADMIN; } // 2. Check for tenant-specific role const membership = await this.tenantMemberRepository.findOneBy({ userId, tenantId, }); if (membership) { return membership.role; } // Default to USER return UserRole.USER; } }