forked from hangshuo652/aurak
feat: implement QuestionBank CRUD with pagination and template query
- 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
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
InsertEvent,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
import { tenantStore } from './tenant.store';
|
||||
|
||||
@EventSubscriber()
|
||||
export class TenantEntitySubscriber implements EntitySubscriberInterface {
|
||||
/**
|
||||
* Called before entity insertion.
|
||||
*/
|
||||
beforeInsert(event: InsertEvent<any>) {
|
||||
this.injectTenantId(event.entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before entity update.
|
||||
*/
|
||||
beforeUpdate(event: UpdateEvent<any>) {
|
||||
this.injectTenantId(event.entity);
|
||||
}
|
||||
|
||||
private injectTenantId(entity: any) {
|
||||
if (!entity) return;
|
||||
|
||||
// Check if the entity has a tenantId property
|
||||
if ('tenantId' in entity) {
|
||||
const store = tenantStore.getStore();
|
||||
const currentTenantId = store?.tenantId;
|
||||
|
||||
// Only set if it's not already set and we have a tenantId in context
|
||||
if (!entity.tenantId && currentTenantId) {
|
||||
entity.tenantId = currentTenantId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../user/user.entity';
|
||||
import { UserRole } from '../user/user-role.enum';
|
||||
import { Tenant } from './tenant.entity';
|
||||
|
||||
/**
|
||||
* Join table for User and Tenant to support Many-to-Many relationship.
|
||||
* A user can belong to multiple tenants with different roles in each.
|
||||
*/
|
||||
@Entity('tenant_members')
|
||||
export class TenantMember {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.tenantMembers, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@Column({ name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@ManyToOne(() => Tenant, (tenant) => tenant.members, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@Column({
|
||||
type: 'simple-enum',
|
||||
enum: UserRole,
|
||||
default: UserRole.USER,
|
||||
})
|
||||
role: UserRole;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from './tenant.entity';
|
||||
|
||||
/**
|
||||
* Organization-wide default settings.
|
||||
* UserSetting can still override these on a per-user basis.
|
||||
*/
|
||||
@Entity('tenant_settings')
|
||||
export class TenantSetting {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
tenantId: string;
|
||||
|
||||
@OneToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'tenantId' })
|
||||
tenant: Tenant;
|
||||
|
||||
// Default LLM model (override per user in UserSetting)
|
||||
@Column({ type: 'text', nullable: true })
|
||||
selectedLLMId: string;
|
||||
|
||||
// Default embedding model
|
||||
@Column({ type: 'text', nullable: true })
|
||||
selectedEmbeddingId: string;
|
||||
|
||||
// Default rerank model
|
||||
@Column({ type: 'text', nullable: true })
|
||||
selectedRerankId: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
selectedVisionId: string;
|
||||
|
||||
// Search configuration defaults
|
||||
@Column({ type: 'real', default: 0.3 })
|
||||
similarityThreshold: number;
|
||||
|
||||
@Column({ type: 'real', default: 0.5 })
|
||||
rerankSimilarityThreshold: number;
|
||||
|
||||
@Column({ type: 'integer', default: 5 })
|
||||
topK: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
enableFullTextSearch: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
enableRerank: boolean;
|
||||
|
||||
@Column({ type: 'real', default: 0.7 })
|
||||
hybridVectorWeight: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
enableQueryExpansion: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
enableHyDE: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isNotebookEnabled: boolean;
|
||||
|
||||
@Column({ type: 'integer', default: 1000 })
|
||||
chunkSize: number;
|
||||
|
||||
@Column({ type: 'integer', default: 100 })
|
||||
chunkOverlap: number;
|
||||
|
||||
// LLM generation defaults
|
||||
@Column({ type: 'real', default: 0.7 })
|
||||
temperature: number;
|
||||
|
||||
@Column({ type: 'integer', default: 2048 })
|
||||
maxTokens: number;
|
||||
|
||||
// The model IDs that the Tenant Admin has enabled for this tenant
|
||||
@Column({ type: 'simple-array', nullable: true })
|
||||
enabledModelIds: string[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Request,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { TenantService } from './tenant.service';
|
||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||
import { SuperAdminGuard } from '../auth/super-admin.guard';
|
||||
|
||||
@Controller('tenants')
|
||||
@UseGuards(CombinedAuthGuard, SuperAdminGuard)
|
||||
export class TenantController {
|
||||
constructor(private readonly tenantService: TenantService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query('page') page?: string, @Query('limit') limit?: string) {
|
||||
const p = page ? parseInt(page) : undefined;
|
||||
const l = limit ? parseInt(limit) : undefined;
|
||||
return this.tenantService.findAll(p, l);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.tenantService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: { name: string; domain?: string; parentId?: string }) {
|
||||
return this.tenantService.create(body.name, body.domain, body.parentId);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body()
|
||||
body: {
|
||||
name?: string;
|
||||
domain?: string;
|
||||
parentId?: string;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.tenantService.update(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.tenantService.remove(id);
|
||||
}
|
||||
|
||||
@Get(':id/settings')
|
||||
getSettings(@Param('id') id: string) {
|
||||
return this.tenantService.getSettings(id);
|
||||
}
|
||||
|
||||
@Put(':id/settings')
|
||||
updateSettings(@Param('id') id: string, @Body() body: any) {
|
||||
return this.tenantService.updateSettings(id, body);
|
||||
}
|
||||
|
||||
@Get(':id/members')
|
||||
getMembers(
|
||||
@Param('id') id: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const p = page ? parseInt(page) : undefined;
|
||||
const l = limit ? parseInt(limit) : undefined;
|
||||
return this.tenantService.getMembers(id, p, l);
|
||||
}
|
||||
|
||||
@Post(':id/members')
|
||||
addMember(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { userId: string; role?: string },
|
||||
) {
|
||||
return this.tenantService.addMember(id, body.userId, body.role);
|
||||
}
|
||||
|
||||
@Patch(':id/members/:userId')
|
||||
async updateMemberRole(
|
||||
@Param('id') id: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body() body: { role: string },
|
||||
) {
|
||||
return this.tenantService.updateMemberRole(id, userId, body.role);
|
||||
}
|
||||
|
||||
@Delete(':id/members/:userId')
|
||||
@HttpCode(204)
|
||||
async removeMember(@Param('id') id: string, @Param('userId') userId: string) {
|
||||
await this.tenantService.removeMember(id, userId);
|
||||
}
|
||||
|
||||
@Get(':id/members/ids')
|
||||
getMemberIds(@Param('id') id: string) {
|
||||
return this.tenantService.getMemberIds(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../user/user.entity';
|
||||
import { TenantMember } from './tenant-member.entity';
|
||||
import { JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
@Entity('tenants')
|
||||
export class Tenant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'text', unique: true })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', unique: true, nullable: true })
|
||||
domain: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isSystem: boolean;
|
||||
|
||||
@Column({ name: 'parent_id', type: 'text', nullable: true })
|
||||
parentId: string;
|
||||
|
||||
@ManyToOne(() => Tenant, (tenant) => tenant.children, {
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
@JoinColumn({ name: 'parent_id' })
|
||||
parent: Tenant;
|
||||
|
||||
@OneToMany(() => Tenant, (tenant) => tenant.parent)
|
||||
children: Tenant[];
|
||||
|
||||
@OneToMany(() => TenantMember, (member) => member.tenant)
|
||||
members: TenantMember[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { tenantStore } from './tenant.store';
|
||||
|
||||
@Injectable()
|
||||
export class TenantMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
// Wrap the execution in the tenant store context
|
||||
// Initial values might be empty or partial until the AuthGuard validates them
|
||||
tenantStore.run({ tenantId: tenantId || '' }, () => {
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Tenant } from './tenant.entity';
|
||||
import { TenantSetting } from './tenant-setting.entity';
|
||||
import { TenantMember } from './tenant-member.entity';
|
||||
import { TenantController } from './tenant.controller';
|
||||
import { TenantService } from './tenant.service';
|
||||
import { TenantEntitySubscriber } from './tenant-entity.subscriber';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Tenant, TenantSetting, TenantMember])],
|
||||
providers: [TenantService, TenantEntitySubscriber],
|
||||
controllers: [TenantController],
|
||||
exports: [TenantService],
|
||||
})
|
||||
export class TenantModule {}
|
||||
@@ -0,0 +1,321 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
export interface TenantContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export const tenantStore = new AsyncLocalStorage<TenantContext>();
|
||||
Reference in New Issue
Block a user