0a9588abb7
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
322 lines
8.9 KiB
TypeScript
322 lines
8.9 KiB
TypeScript
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<Tenant>,
|
|
@InjectRepository(TenantSetting)
|
|
private readonly tenantSettingRepository: Repository<TenantSetting>,
|
|
@InjectRepository(TenantMember)
|
|
private readonly tenantMemberRepository: Repository<TenantMember>,
|
|
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<Tenant> {
|
|
const tenant = await this.tenantRepository.findOneBy({ id });
|
|
if (!tenant)
|
|
throw new NotFoundException(
|
|
this.i18nService.getMessage('tenantNotFound'),
|
|
);
|
|
return tenant;
|
|
}
|
|
|
|
async findByName(name: string): Promise<Tenant | null> {
|
|
return this.tenantRepository.findOneBy({ name });
|
|
}
|
|
|
|
async create(
|
|
name: string,
|
|
domain?: string,
|
|
parentId?: string,
|
|
isSystem: boolean = false,
|
|
): Promise<Tenant> {
|
|
const tenant = this.tenantRepository.create({
|
|
name,
|
|
domain,
|
|
parentId,
|
|
isSystem,
|
|
});
|
|
return this.tenantRepository.save(tenant);
|
|
}
|
|
|
|
async update(id: string, data: Partial<Tenant>): Promise<Tenant> {
|
|
await this.tenantRepository.update(id, data);
|
|
return this.findById(id);
|
|
}
|
|
|
|
async remove(id: string): Promise<void> {
|
|
await this.tenantRepository.delete(id);
|
|
}
|
|
|
|
async getSettings(tenantId: string): Promise<TenantSetting | null> {
|
|
return this.tenantSettingRepository.findOneBy({ tenantId });
|
|
}
|
|
|
|
async updateSettings(
|
|
tenantId: string,
|
|
data: Partial<TenantSetting>,
|
|
): Promise<TenantSetting> {
|
|
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<TenantMember> {
|
|
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<TenantMember> {
|
|
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<void> {
|
|
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<Tenant> {
|
|
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<string[]> {
|
|
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<boolean> {
|
|
// 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<string>();
|
|
|
|
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<UserRole> {
|
|
// 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;
|
|
}
|
|
}
|