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
+85
View File
@@ -0,0 +1,85 @@
import {
Controller,
Get,
Post,
Put,
Body,
UseGuards,
Request,
Query,
UseInterceptors,
UploadedFile,
Res,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { AdminService } from './admin.service';
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';
@Controller('v1/admin')
@UseGuards(CombinedAuthGuard, RolesGuard)
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get('users')
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async getUsers(
@Request() req: any,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
return this.adminService.getTenantUsers(
isSuperAdmin ? undefined : req.user.tenantId,
page ? parseInt(page) : undefined,
limit ? parseInt(limit) : undefined,
);
}
@Get('users/export')
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async getUsersExport(@Request() req: any, @Res() res: Response) {
const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
const buffer = await this.adminService.exportUsers(
isSuperAdmin ? undefined : req.user.tenantId,
);
res.set({
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="users_export.xlsx"',
'Content-Length': buffer.length,
});
res.end(buffer);
}
@Post('users/import')
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
@UseInterceptors(FileInterceptor('file'))
async importUsers(@Request() req: any, @UploadedFile() file: any) {
const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
return this.adminService.importUsers(
isSuperAdmin ? undefined : req.user.tenantId,
file,
);
}
@Get('settings')
async getSettings(@Request() req: any) {
return this.adminService.getTenantSettings(req.user.tenantId);
}
@Put('settings')
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async updateSettings(@Request() req: any, @Body() body: any) {
return this.adminService.updateTenantSettings(req.user.tenantId, body);
}
@Get('pending-shares')
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async getPendingShares(@Request() req: any) {
return this.adminService.getPendingShares(req.user.tenantId);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { UserModule } from '../user/user.module';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [UserModule, TenantModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}
+146
View File
@@ -0,0 +1,146 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import * as XLSX from 'xlsx';
import { UserService } from '../user/user.service';
import { TenantService } from '../tenant/tenant.service';
import { I18nService } from '../i18n/i18n.service';
interface UserImportRow {
Username?: string | number;
username?: string | number;
DisplayName?: string | number;
displayName?: string | number;
Name?: string | number;
name?: string | number;
Password?: string | number;
password?: string | number;
IsAdmin?: string | number | boolean;
isAdmin?: string | number | boolean;
}
@Injectable()
export class AdminService {
constructor(
private readonly userService: UserService,
private readonly tenantService: TenantService,
private readonly i18nService: I18nService,
) {}
async getTenantUsers(tenantId?: string, page?: number, limit?: number) {
if (!tenantId) {
return this.userService.findAll(page, limit);
}
return this.userService.findByTenantId(tenantId, page, limit);
}
async exportUsers(tenantId?: string): Promise<Buffer> {
const { data: users } = tenantId
? await this.userService.findByTenantId(tenantId)
: await this.userService.findAll();
const worksheet = XLSX.utils.json_to_sheet(
users.map((u) => ({
Username: u.username,
DisplayName: u.displayName || '',
IsAdmin: u.isAdmin ? 'Yes' : 'No',
CreatedAt: u.createdAt,
Password: '', // Placeholder for new users
})),
);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Users');
return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
async importUsers(tenantId?: string, file?: any) {
if (!file)
throw new BadRequestException(
this.i18nService.getMessage('uploadNoFile'),
);
const workbook = XLSX.read(file.buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json<UserImportRow>(worksheet);
const results = {
success: 0,
failed: 0,
errors: [] as string[],
};
for (const row of data) {
try {
const username = (row.Username || row.username)?.toString();
const displayName = (
row.DisplayName ||
row.displayName ||
row.Name ||
row.name
)?.toString();
const password = (row.Password || row.password)?.toString();
const isAdminStr = (row.IsAdmin || row.isAdmin || 'No').toString();
const isAdmin =
isAdminStr.toLowerCase() === 'yes' ||
isAdminStr === 'true' ||
isAdminStr === '1';
if (!username) {
throw new Error(this.i18nService.getMessage('usernameRequired'));
}
const existingUser = await this.userService.findOneByUsername(username);
if (existingUser) {
await this.userService.updateUser(existingUser.id, {
displayName: displayName || existingUser.displayName,
password: password || undefined,
// We avoid changing isAdmin status via import for security unless explicitly required
});
} else {
if (!password) {
throw new Error(
this.i18nService.formatMessage('passwordRequiredForNewUser', {
username,
}),
);
}
await this.userService.createUser(
username,
password,
isAdmin,
tenantId,
displayName,
);
}
results.success++;
} catch (e: any) {
results.failed++;
results.errors.push(`${row.Username || 'Unknown'}: ${e.message}`);
}
}
return results;
}
async getTenantSettings(tenantId: string) {
return this.tenantService.getSettings(tenantId);
}
async updateTenantSettings(tenantId: string, data: any) {
return this.tenantService.updateSettings(tenantId, data);
}
// Notebook sharing approval and model assignments would go here
async getPendingShares(tenantId: string) {
// Mock implementation for pending shares to satisfy UI.
// Needs proper schema/entity support in the future.
return [];
}
}