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
+24
View File
@@ -0,0 +1,24 @@
import {
IsNotEmpty,
IsString,
MinLength,
IsOptional,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { UserRole } from '../user-role.enum';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
username: string;
@IsString()
@IsNotEmpty()
password: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
displayName?: string;
}
+21
View File
@@ -0,0 +1,21 @@
import { IsBoolean, IsOptional, IsString, IsEnum } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { UserRole } from '../user-role.enum';
export class UpdateUserDto {
@IsOptional()
@IsString()
displayName?: string;
@IsOptional()
@IsBoolean()
isAdmin?: boolean;
@IsOptional()
@IsString()
tenantId?: string;
@IsOptional()
@IsString()
password?: string;
}
+14
View File
@@ -0,0 +1,14 @@
// server/src/user/dto/user-safe.dto.ts
import { UserRole } from '../user-role.enum';
export type SafeUser = {
id: string;
username: string;
displayName?: string;
isAdmin: boolean;
role: UserRole; // Computed property
tenantId: string;
createdAt: Date;
updatedAt: Date;
};
+5
View File
@@ -0,0 +1,5 @@
export enum UserRole {
SUPER_ADMIN = 'SUPER_ADMIN',
TENANT_ADMIN = 'TENANT_ADMIN',
USER = 'USER',
}
+32
View File
@@ -0,0 +1,32 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity('user_settings')
export class UserSetting {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'text' })
userId: string;
@OneToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'text', default: 'zh' })
language: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
+29
View File
@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserSetting } from './user-setting.entity';
@Injectable()
export class UserSettingService {
constructor(
@InjectRepository(UserSetting)
private userSettingRepository: Repository<UserSetting>,
) {}
async getByUser(userId: string): Promise<UserSetting> {
let setting = await this.userSettingRepository.findOne({
where: { userId },
});
if (!setting) {
setting = this.userSettingRepository.create({ userId, language: 'zh' });
await this.userSettingRepository.save(setting);
}
return setting;
}
async update(userId: string, language: string): Promise<UserSetting> {
const setting = await this.getByUser(userId);
setting.language = language;
return this.userSettingRepository.save(setting);
}
}
+293
View File
@@ -0,0 +1,293 @@
import {
BadRequestException,
Body,
Controller,
Delete,
ForbiddenException,
NotFoundException,
Get,
Param,
Post,
Put,
Request,
UseGuards,
Query,
} from '@nestjs/common';
import { UserService } from './user.service';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { I18nService } from '../i18n/i18n.service';
import { UserRole } from './user-role.enum';
import { UserSettingService } from './user-setting.service';
@Controller('users')
@UseGuards(CombinedAuthGuard)
export class UserController {
constructor(
private readonly userService: UserService,
private readonly i18nService: I18nService,
private readonly userSettingService: UserSettingService,
) {}
// --- API Key Management ---
@Get('api-key')
async getApiKey(@Request() req) {
const apiKey = await this.userService.getOrCreateApiKey(req.user.id);
return { apiKey };
}
@Post('api-key/rotate')
async rotateApiKey(@Request() req) {
const apiKey = await this.userService.regenerateApiKey(req.user.id);
return { apiKey };
}
// --- Personal Settings ---
@Get('settings')
async getSettings(@Request() req) {
return this.userSettingService.getByUser(req.user.id);
}
@Put('settings/language')
async updateLanguage(@Request() req, @Body() body: { language: string }) {
if (!body.language) throw new BadRequestException('language is required');
return this.userSettingService.update(req.user.id, body.language);
}
// --- Profile ---
@Get('profile')
async getProfile(@Request() req: any) {
return this.userService.findOneById(req.user.id);
}
@Get('tenants')
async getMyTenants(@Request() req: any) {
return this.userService.getUserTenants(req.user.id);
}
@Get('me')
async getMe(@Request() req) {
const user = await this.userService.findOneById(req.user.id);
if (!user) throw new NotFoundException();
let isNotebookEnabled = true;
if (user.tenantId) {
const settings = await this.userService.getTenantSettings(user.tenantId);
isNotebookEnabled = settings?.isNotebookEnabled ?? true;
}
const tenantName = user.tenantMembers?.[0]?.tenant?.name || 'Default';
return {
id: user.id,
username: user.username,
displayName: user.displayName,
role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER,
tenantId: user.tenantId,
tenantName,
isAdmin: user.isAdmin,
isNotebookEnabled,
};
}
@Get()
async findAll(
@Request() req,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const callerRole = req.user.role;
if (
callerRole !== UserRole.SUPER_ADMIN &&
callerRole !== UserRole.TENANT_ADMIN
) {
throw new ForbiddenException(
this.i18nService.getErrorMessage('adminOnlyViewList'),
);
}
const p = page ? parseInt(page) : undefined;
const l = limit ? parseInt(limit) : undefined;
if (callerRole === UserRole.SUPER_ADMIN) {
return this.userService.findAll(p, l);
} else {
return this.userService.findByTenantId(req.user.tenantId, p, l);
}
}
@Put('password')
async changePassword(
@Request() req,
@Body() body: { currentPassword: string; newPassword: string },
) {
const { currentPassword, newPassword } = body;
if (!currentPassword || !newPassword) {
throw new BadRequestException(
this.i18nService.getErrorMessage('passwordsRequired'),
);
}
if (newPassword.length < 6) {
throw new BadRequestException(
this.i18nService.getErrorMessage('newPasswordMinLength'),
);
}
return this.userService.changePassword(
req.user.id,
currentPassword,
newPassword,
);
}
@Post()
async createUser(@Request() req, @Body() body: CreateUserDto) {
const callerRole = req.user.role;
if (
callerRole !== UserRole.SUPER_ADMIN &&
callerRole !== UserRole.TENANT_ADMIN
) {
throw new ForbiddenException(
this.i18nService.getErrorMessage('adminOnlyCreateUser'),
);
}
const { username, password } = body;
if (!username || !password) {
throw new BadRequestException(
this.i18nService.getErrorMessage('usernamePasswordRequired'),
);
}
if (password.length < 6) {
throw new BadRequestException(
this.i18nService.getErrorMessage('passwordMinLength'),
);
}
// All new global users default to non-admin.
// Elevation to Super Admin status is handled separately.
let isAdmin = false;
if (callerRole === UserRole.SUPER_ADMIN) {
isAdmin = false;
} else if (callerRole === UserRole.TENANT_ADMIN) {
isAdmin = false;
}
// Pass the calculated params to the service
return this.userService.createUser(
username,
password,
isAdmin,
req.user.tenantId,
body.displayName,
);
}
@Put(':id')
async updateUser(
@Request() req,
@Body() body: UpdateUserDto,
@Param('id') id: string,
) {
const callerRole = req.user.role;
if (
callerRole !== UserRole.SUPER_ADMIN &&
callerRole !== UserRole.TENANT_ADMIN
) {
throw new ForbiddenException(
this.i18nService.getErrorMessage('adminOnlyUpdateUser'),
);
}
// Get user info to update
const userToUpdate = await this.userService.findOneById(id);
if (!userToUpdate) {
throw new NotFoundException(
this.i18nService.getErrorMessage('userNotFound'),
);
}
if (
callerRole === 'TENANT_ADMIN' &&
userToUpdate.tenantId !== req.user.tenantId
) {
throw new ForbiddenException('Cannot modify users outside your tenant');
}
// Prevent modifying the builtin admin account
if (userToUpdate.username === 'admin') {
throw new ForbiddenException(
this.i18nService.getErrorMessage('cannotModifyBuiltinAdmin'),
);
}
// Role modification is now obsolete on global level.
// If Admin wants to elevate, they set isAdmin property directly.
if (body.isAdmin !== undefined && userToUpdate.isAdmin !== body.isAdmin) {
if (callerRole !== UserRole.SUPER_ADMIN) {
throw new ForbiddenException(
'Only Super Admins can change user admin status.',
);
}
}
// Validate password length if provided
if (body.password && body.password.length < 6) {
throw new BadRequestException(
this.i18nService.getErrorMessage('passwordMinLength'),
);
}
return this.userService.updateUser(id, body);
}
@Delete(':id')
async deleteUser(@Request() req, @Param('id') id: string) {
const callerRole = req.user.role;
if (
callerRole !== UserRole.SUPER_ADMIN &&
callerRole !== UserRole.TENANT_ADMIN
) {
throw new ForbiddenException(
this.i18nService.getErrorMessage('adminOnlyDeleteUser'),
);
}
// Prevent admin from deleting themselves
if (req.user.id === id) {
throw new BadRequestException(
this.i18nService.getErrorMessage('cannotDeleteSelf'),
);
}
// Get user info to delete
const userToDelete = await this.userService.findOneById(id);
if (!userToDelete) {
throw new NotFoundException(
this.i18nService.getErrorMessage('userNotFound'),
);
}
if (
callerRole === 'TENANT_ADMIN' &&
userToDelete.tenantId !== req.user.tenantId
) {
throw new ForbiddenException('Cannot delete users outside your tenant');
}
// Block deletion of built-in admin account
if (userToDelete.username === 'admin') {
throw new ForbiddenException(
this.i18nService.getErrorMessage('cannotDeleteBuiltinAdmin'),
);
}
return this.userService.deleteUser(id);
}
}
+84
View File
@@ -0,0 +1,84 @@
import {
BeforeInsert,
BeforeUpdate,
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import * as bcrypt from 'bcrypt';
import { ModelConfig } from '../model-config/model-config.entity';
import { Tenant } from '../tenant/tenant.entity';
import { TenantMember } from '../tenant/tenant-member.entity';
import { ApiKey } from '../auth/entities/api-key.entity';
import { UserRole } from './user-role.enum';
import { UserSetting } from './user-setting.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'text', unique: true })
username: string;
@Column({ type: 'text' })
password: string;
// Legacy field - kept for backward compatibility, use `role` for new logic
@Column({ type: 'boolean', default: false })
isAdmin: boolean;
@Column({ type: 'text', nullable: false })
displayName: string;
// Multi-tenancy: A user can belong to multiple tenants via TenantMember
@OneToMany(() => TenantMember, (member) => member.user)
tenantMembers: TenantMember[];
// Legacy field - kept for backward compatibility if needed, but primary tenant is now determined by context
@Column({ type: 'text', nullable: true, name: 'tenant_id' })
tenantId: string;
// API Keys for external API access
@OneToMany(() => ApiKey, (apiKey) => apiKey.user)
apiKeys: ApiKey[];
// Quota management field
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
monthlyCost: number;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 100 })
maxCost: number;
@Column({ type: 'datetime', nullable: true })
lastQuotaReset: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@OneToOne(() => UserSetting, (setting) => setting.user)
userSetting: UserSetting;
@BeforeInsert()
@BeforeUpdate()
async hashPassword() {
// Only hash if the password is not yet hashed (BCrypt hash length is 60)
if (this.password && this.password.length < 60) {
this.password = await bcrypt.hash(this.password, 10);
}
}
async validatePassword(password: string): Promise<boolean> {
return bcrypt.compare(password, this.password);
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Module, Global } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UserSetting } from './user-setting.entity';
import { UserSettingService } from './user-setting.service';
import { TenantMember } from '../tenant/tenant-member.entity';
import { ApiKey } from '../auth/entities/api-key.entity';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TenantModule } from '../tenant/tenant.module';
@Global()
@Module({
imports: [
TypeOrmModule.forFeature([User, ApiKey, TenantMember, UserSetting]),
TenantModule,
],
controllers: [UserController],
providers: [UserService, UserSettingService],
exports: [UserService, UserSettingService],
})
export class UserModule {}
+412
View File
@@ -0,0 +1,412 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
OnModuleInit,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { UserRole } from './user-role.enum';
import { TenantMember } from '../tenant/tenant-member.entity';
import { ApiKey } from '../auth/entities/api-key.entity';
import * as bcrypt from 'bcrypt';
import { CreateUserDto } from './dto/create-user.dto';
import * as crypto from 'crypto';
import { I18nService } from '../i18n/i18n.service';
import { TenantService } from '../tenant/tenant.service';
@Injectable()
export class UserService implements OnModuleInit {
private readonly logger = new Logger(UserService.name);
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
@InjectRepository(ApiKey)
private apiKeyRepository: Repository<ApiKey>,
@InjectRepository(TenantMember)
private tenantMemberRepository: Repository<TenantMember>,
private i18nService: I18nService,
private tenantService: TenantService,
) {}
async findOneByUsername(username: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { username } });
}
async create(createUserDto: CreateUserDto): Promise<User> {
const user = this.usersRepository.create(createUserDto as any);
return this.usersRepository.save(user as any);
}
async onModuleInit() {
await this.createAdminIfNotExists();
}
async findAll(
page?: number,
limit?: number,
): Promise<{ data: User[]; total: number }> {
const queryBuilder = this.usersRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.tenantMembers', 'tenantMember')
.leftJoinAndSelect('tenantMember.tenant', 'tenant')
.select([
'user.id',
'user.username',
'user.displayName',
'user.isAdmin',
'user.createdAt',
'user.tenantId',
'tenantMember',
'tenant',
])
.orderBy('user.createdAt', 'DESC');
if (page && limit) {
const [data, total] = await queryBuilder
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return { data, total };
}
const [data, total] = await queryBuilder.getManyAndCount();
return { data, total };
}
async findByTenantId(
tenantId: string,
page?: number,
limit?: number,
): Promise<{ data: User[]; total: number }> {
const queryBuilder = this.usersRepository
.createQueryBuilder('user')
.innerJoin(
'user.tenantMembers',
'member',
'member.tenantId = :tenantId',
{ tenantId },
)
.select([
'user.id',
'user.username',
'user.displayName',
'user.isAdmin',
'user.createdAt',
'user.tenantId',
])
.orderBy('user.createdAt', 'DESC');
if (page && limit) {
const [data, total] = await queryBuilder
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return { data, total };
}
const [data, total] = await queryBuilder.getManyAndCount();
return { data, total };
}
async isAdmin(userId: string): Promise<boolean> {
const user = await this.usersRepository.findOne({
where: { id: userId },
select: ['isAdmin'],
});
return user?.isAdmin || false;
}
async changePassword(
userId: string,
currentPassword: string,
newPassword: string,
): Promise<{ message: string }> {
const user = await this.usersRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
}
const isCurrentPasswordValid = await bcrypt.compare(
currentPassword,
user.password,
);
if (!isCurrentPasswordValid) {
throw new BadRequestException(
this.i18nService.getMessage('incorrectCurrentPassword'),
);
}
const hashedNewPassword = await bcrypt.hash(newPassword, 10);
await this.usersRepository.update(userId, { password: hashedNewPassword });
return { message: this.i18nService.getMessage('passwordChanged') };
}
async createUser(
username: string,
password: string,
isAdmin: boolean = false,
tenantId?: string,
displayName?: string,
): Promise<{
message: string;
user: {
id: string;
username: string;
displayName: string;
isAdmin: boolean;
};
}> {
const existingUser = await this.findOneByUsername(username);
if (existingUser) {
throw new ConflictException(
this.i18nService.getMessage('usernameExists'),
);
}
const hashedPassword = await bcrypt.hash(password, 10);
console.log(
`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`,
);
const user = await this.usersRepository.save({
username,
password: hashedPassword,
displayName: displayName || username,
isAdmin,
tenantId: tenantId ?? undefined,
} as any);
return {
message: this.i18nService.getMessage('userCreated'),
user: {
id: user.id,
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin,
},
};
}
async findOneById(userId: string): Promise<User | null> {
return this.usersRepository.findOne({
where: { id: userId },
relations: ['tenantMembers', 'tenantMembers.tenant'],
});
}
async findByApiKey(apiKeyValue: string): Promise<User | null> {
const apiKey = await this.apiKeyRepository.findOne({
where: { key: apiKeyValue },
relations: ['user'],
});
return apiKey ? apiKey.user : null;
}
async getUserTenants(
userId: string,
): Promise<(TenantMember & { features?: { isNotebookEnabled: boolean } })[]> {
const user = await this.usersRepository.findOne({
where: { id: userId },
select: ['isAdmin'],
});
if (user?.isAdmin) {
const tenantsData = await this.tenantService.findAll();
const allTenants = Array.isArray(tenantsData)
? tenantsData
: tenantsData.data;
const results = await Promise.all(
allTenants.map(async (t) => {
const settings = await this.tenantService.getSettings(t.id);
return {
tenantId: t.id,
tenant: t,
role: UserRole.SUPER_ADMIN,
userId: userId,
features: {
isNotebookEnabled: settings?.isNotebookEnabled ?? true,
},
} as TenantMember & { features: { isNotebookEnabled: boolean } };
}),
);
return results;
}
const members = await this.tenantMemberRepository.find({
where: { userId },
relations: ['tenant'],
});
// Filter out the "Default" tenant for non-super admins
const filtered = members.filter(
(m) => m.tenant?.name !== TenantService.DEFAULT_TENANT_NAME,
);
// Attach per-tenant feature flags
return Promise.all(
filtered.map(async (m) => {
const settings = await this.tenantService.getSettings(m.tenantId);
return {
...m,
features: {
isNotebookEnabled: settings?.isNotebookEnabled ?? true,
},
};
}),
);
}
/**
* Generates a new API key for the user, or returns the existing one (first one).
*/
async getOrCreateApiKey(userId: string): Promise<string> {
const user = await this.usersRepository.findOne({
where: { id: userId },
relations: ['apiKeys'],
});
if (!user)
throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
if (user.apiKeys && user.apiKeys.length > 0) {
return user.apiKeys[0].key;
}
const keyString = 'kb_' + crypto.randomBytes(32).toString('hex');
const newApiKey = this.apiKeyRepository.create({
userId: user.id,
key: keyString,
});
await this.apiKeyRepository.save(newApiKey);
return keyString;
}
/**
* Regenerates (rotates) the API key for the user.
* This clears existing keys and creates a new one.
*/
async regenerateApiKey(userId: string): Promise<string> {
const user = await this.usersRepository.findOne({ where: { id: userId } });
if (!user)
throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
// Delete existing keys
await this.apiKeyRepository.delete({ userId: user.id });
// Create new key
const keyString = 'kb_' + crypto.randomBytes(32).toString('hex');
const newApiKey = this.apiKeyRepository.create({
userId: user.id,
key: keyString,
});
await this.apiKeyRepository.save(newApiKey);
return keyString;
}
async updateUser(
userId: string,
updateData: {
username?: string;
isAdmin?: boolean;
password?: string;
tenantId?: string;
displayName?: string;
},
): Promise<{
message: string;
user: {
id: string;
username: string;
displayName: string;
isAdmin: boolean;
};
}> {
const user = await this.usersRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
}
// Hash password first if update needed
if (updateData.password) {
const hashedPassword = await bcrypt.hash(updateData.password, 10);
updateData.password = hashedPassword;
}
// Block any changes to user "admin"
if (user.username === 'admin') {
throw new ForbiddenException(
this.i18nService.getMessage('cannotModifyBuiltinAdmin'),
);
}
await this.usersRepository.update(userId, updateData as any);
const updatedUser = await this.usersRepository.findOne({
where: { id: userId },
select: ['id', 'username', 'displayName', 'isAdmin'],
});
return {
message: this.i18nService.getMessage('userInfoUpdated'),
user: {
id: updatedUser!.id,
username: updatedUser!.username,
displayName: updatedUser!.displayName,
isAdmin: updatedUser!.isAdmin,
},
};
}
async deleteUser(userId: string): Promise<{ message: string }> {
const user = await this.usersRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
}
// Block deletion of user "admin"
if (user.username === 'admin') {
throw new ForbiddenException(
this.i18nService.getMessage('cannotDeleteBuiltinAdmin'),
);
}
await this.usersRepository.delete(userId);
return {
message: this.i18nService.getMessage('userDeleted'),
};
}
async getTenantSettings(tenantId: string) {
return this.tenantService.getSettings(tenantId);
}
private async createAdminIfNotExists() {
const adminUser = await this.findOneByUsername('admin');
if (!adminUser) {
const randomPassword = Math.random().toString(36).slice(-8);
const hashedPassword = await bcrypt.hash(randomPassword, 10);
await this.usersRepository.save({
username: 'admin',
password: hashedPassword,
displayName: 'Admin',
isAdmin: true,
role: UserRole.SUPER_ADMIN,
});
console.log('\n=== Admin account created ===');
console.log('Username: admin');
console.log('Password:', randomPassword);
console.log('========================================\n');
}
}
}