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:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
@@ -0,0 +1,92 @@
// server/src/model-config/dto/create-model-config.dto.ts
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUrl,
Max,
Min,
MinLength,
} from 'class-validator';
import { ModelType } from '../../types';
export class CreateModelConfigDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
modelId: string;
@IsUrl({ require_tld: false }, { message: 'Base URL must be a valid URL' })
@IsOptional()
baseUrl?: string;
@IsString()
@IsOptional()
apiKey?: string; // API key is optional - allows local models
@IsEnum(ModelType)
@IsNotEmpty()
type: ModelType;
@IsNumber()
@Min(1, { message: 'Minimum vector dimension is 1' })
@Max(4096, {
message: 'Maximum vector dimension is 4096 (Elasticsearch limit)',
})
@IsOptional()
dimensions?: number;
// ==================== Additional Fields ====================
/**
* Model input token limit (only valid for embedding/rerank)
*/
@IsNumber()
@Min(1)
@Max(100000)
@IsOptional()
maxInputTokens?: number;
/**
* Batch processing limit (only valid for embedding/rerank)
*/
@IsNumber()
@Min(1)
@Max(10000)
@IsOptional()
maxBatchSize?: number;
/**
* Whether this is a vector model
*/
@IsBoolean()
@IsOptional()
isVectorModel?: boolean;
/**
* Model provider name
*/
@IsString()
@IsOptional()
providerName?: string;
/**
* Whether to enable this model
*/
@IsBoolean()
@IsOptional()
isEnabled?: boolean;
/**
* Whether to use this model as default
*/
@IsBoolean()
@IsOptional()
isDefault?: boolean;
}
@@ -0,0 +1,24 @@
// server/src/model-config/dto/model-config-response.dto.ts
import { Exclude, Expose, Transform } from 'class-transformer';
import { ModelConfig } from '../model-config.entity';
export class ModelConfigResponseDto {
id: string;
name: string;
provider: string;
modelId: string;
baseUrl?: string;
@Transform(({ value }) => (value ? '********' : undefined))
apiKey?: string;
type: string;
isEnabled?: boolean;
isDefault?: boolean;
createdAt: Date;
updatedAt: Date;
constructor(partial: Partial<ModelConfig>) {
Object.assign(this, partial);
}
}
@@ -0,0 +1,5 @@
// server/src/model-config/dto/update-model-config.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateModelConfigDto } from './create-model-config.dto';
export class UpdateModelConfigDto extends PartialType(CreateModelConfigDto) {}
@@ -0,0 +1,80 @@
// server/src/model-config/model-config.controller.ts
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { ModelConfigService } from './model-config.service';
import { CreateModelConfigDto } from './dto/create-model-config.dto';
import { UpdateModelConfigDto } from './dto/update-model-config.dto';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { UserRole } from '../user/user-role.enum';
import { ModelConfigResponseDto } from './dto/model-config-response.dto';
import { plainToClass } from 'class-transformer';
@UseGuards(CombinedAuthGuard)
@Controller('models') // Global prefix /api/models
export class ModelConfigController {
constructor(private readonly modelConfigService: ModelConfigService) {}
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
@Post()
@HttpCode(HttpStatus.CREATED)
async create(
@Body() createModelConfigDto: CreateModelConfigDto,
): Promise<ModelConfigResponseDto> {
const modelConfig =
await this.modelConfigService.create(createModelConfigDto);
return plainToClass(ModelConfigResponseDto, modelConfig);
}
@Get()
async findAll(): Promise<ModelConfigResponseDto[]> {
const modelConfigs = await this.modelConfigService.findAll();
return modelConfigs.map((mc) => plainToClass(ModelConfigResponseDto, mc));
}
@Get(':id')
async findOne(@Param('id') id: string): Promise<ModelConfigResponseDto> {
const modelConfig = await this.modelConfigService.findOne(id);
return plainToClass(ModelConfigResponseDto, modelConfig);
}
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
@Put(':id')
async update(
@Param('id') id: string,
@Body() updateModelConfigDto: UpdateModelConfigDto,
): Promise<ModelConfigResponseDto> {
const modelConfig = await this.modelConfigService.update(
id,
updateModelConfigDto,
);
return plainToClass(ModelConfigResponseDto, modelConfig);
}
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string): Promise<void> {
await this.modelConfigService.remove(id);
}
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
@Patch(':id/set-default')
async setDefault(@Param('id') id: string): Promise<ModelConfigResponseDto> {
const modelConfig = await this.modelConfigService.setDefault(id);
return plainToClass(ModelConfigResponseDto, modelConfig);
}
}
@@ -0,0 +1,82 @@
// server/src/model-config/model-config.entity.ts
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('model_configs')
export class ModelConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'text' })
name: string;
@Column({ type: 'text' })
modelId: string;
@Column({ type: 'text', nullable: true })
baseUrl?: string;
@Column({ type: 'text', nullable: true })
apiKey?: string; // Should be encrypted in production
@Column({ type: 'text' })
type: string; // ModelType enum values
@Column({ type: 'integer', nullable: true })
dimensions?: number; // Embedding model dimensions, auto-detected and saved by system
// ==================== Additional Fields ====================
// The following fields are only meaningful for embedding/rerank models
/**
* Model input token limit
* Example: OpenAI=8191, Gemini=2048
*/
@Column({ type: 'integer', nullable: true, default: 8191 })
maxInputTokens?: number;
/**
* Batch processing limit (max inputs per request)
* Example: OpenAI=2048, Gemini=100
*/
@Column({ type: 'integer', nullable: true, default: 2048 })
maxBatchSize?: number;
/**
* Whether this is a vector model (for system identification)
*/
@Column({ type: 'boolean', default: false })
isVectorModel?: boolean;
/**
* Whether to enable this model
* Users can disable models they don't use to prevent accidental selection
*/
@Column({ type: 'boolean', default: true })
isEnabled?: boolean;
/**
* Whether to use this model as default
* Only one default allowed per type (llm, embedding, rerank)
*/
@Column({ type: 'boolean', default: false })
isDefault?: boolean;
/**
* Model provider name (for display and identification)
* Example: "OpenAI", "Google Gemini", "Custom"
*/
@Column({ type: 'text', nullable: true })
providerName?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -0,0 +1,17 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ModelConfig } from './model-config.entity';
import { ModelConfigService } from './model-config.service';
import { ModelConfigController } from './model-config.controller';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [
TypeOrmModule.forFeature([ModelConfig]),
forwardRef(() => TenantModule),
],
providers: [ModelConfigService],
controllers: [ModelConfigController],
exports: [ModelConfigService],
})
export class ModelConfigModule {}
@@ -0,0 +1,142 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
forwardRef,
Inject,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ModelConfig } from './model-config.entity';
import { CreateModelConfigDto } from './dto/create-model-config.dto';
import { UpdateModelConfigDto } from './dto/update-model-config.dto';
import { GLOBAL_TENANT_ID } from '../common/constants';
import { TenantService } from '../tenant/tenant.service';
import { ModelType } from '../types';
import { I18nService } from '../i18n/i18n.service';
@Injectable()
export class ModelConfigService {
constructor(
@InjectRepository(ModelConfig)
private modelConfigRepository: Repository<ModelConfig>,
@Inject(forwardRef(() => TenantService))
private readonly tenantService: TenantService,
private i18nService: I18nService,
) {}
async create(
createModelConfigDto: CreateModelConfigDto,
): Promise<ModelConfig> {
const modelConfig = this.modelConfigRepository.create({
...createModelConfigDto,
});
return this.modelConfigRepository.save(modelConfig);
}
async findAll(): Promise<ModelConfig[]> {
return this.modelConfigRepository.find();
}
async findOne(id: string): Promise<ModelConfig> {
const modelConfig = await this.modelConfigRepository.findOne({
where: { id },
});
if (!modelConfig) {
throw new NotFoundException(
this.i18nService.formatMessage('modelConfigNotFound', { id }),
);
}
return modelConfig;
}
async findByType(type: string): Promise<ModelConfig[]> {
return this.modelConfigRepository.find({ where: { type } });
}
async update(
id: string,
updateModelConfigDto: UpdateModelConfigDto,
): Promise<ModelConfig> {
const modelConfig = await this.findOne(id);
// Update the model
const updated = this.modelConfigRepository.merge(
modelConfig,
updateModelConfigDto,
);
return this.modelConfigRepository.save(updated);
}
async remove(id: string): Promise<void> {
const result = await this.modelConfigRepository.delete({ id });
if (result.affected === 0) {
throw new NotFoundException(
this.i18nService.formatMessage('modelConfigNotFound', { id }),
);
}
}
/**
* Set the specified model as default
*/
async setDefault(id: string): Promise<ModelConfig> {
const modelConfig = await this.findOne(id);
// Clear default flag for other models of the same type
await this.modelConfigRepository
.createQueryBuilder()
.update(ModelConfig)
.set({ isDefault: false })
.where('type = :type', { type: modelConfig.type })
.execute();
modelConfig.isDefault = true;
return this.modelConfigRepository.save(modelConfig);
}
/**
* Get default model for specified type
* Strict rule: Only return models specified in Index Chat Config, throw error if not found
*/
async findDefaultByType(
tenantId: string,
type: ModelType,
): Promise<ModelConfig> {
const settings = await this.tenantService.getSettings(tenantId);
if (!settings) {
throw new BadRequestException(
`Organization settings not found for tenant: ${tenantId}`,
);
}
let modelId: string | undefined;
if (type === ModelType.LLM) {
modelId = settings.selectedLLMId;
} else if (type === ModelType.EMBEDDING) {
modelId = settings.selectedEmbeddingId;
} else if (type === ModelType.RERANK) {
modelId = settings.selectedRerankId;
}
if (!modelId) {
throw new BadRequestException(
`Model of type "${type}" is not configured in Index Chat Config for this organization.`,
);
}
const model = await this.modelConfigRepository.findOne({
where: { id: modelId, isEnabled: true },
});
if (!model) {
throw new BadRequestException(
`The configured model for "${type}" (ID: ${modelId}) is either missing or disabled in model management.`,
);
}
return model;
}
}