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
+18
View File
@@ -0,0 +1,18 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { UserRole } from '../user/user-role.enum';
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
// Check if user exists and has admin privileges (Super Admin or Tenant Admin)
return !!(
user &&
(user.role === UserRole.SUPER_ADMIN ||
user.role === UserRole.TENANT_ADMIN ||
user.isAdmin === true)
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserService } from '../user/user.service';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from './public.decorator';
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(
private reflector: Reflector,
private userService: UserService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context
.switchToHttp()
.getRequest<Request & { user?: any; tenantId?: string }>();
const apiKey = this.extractApiKeyFromHeader(request);
if (apiKey) {
const user = await this.userService.findByApiKey(apiKey);
if (user) {
request.user = user;
request.tenantId = user.tenantId;
return true;
}
throw new UnauthorizedException('Invalid API key');
}
throw new UnauthorizedException('Missing API key');
}
private extractApiKeyFromHeader(request: Request): string | undefined {
const authHeader = request.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer kb_')) {
return authHeader.substring(7, authHeader.length);
}
const headerKey = request.headers['x-api-key'] as string;
if (headerKey) return headerKey;
return undefined;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Controller, Get, Post, Request, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './local-auth.guard';
import { CombinedAuthGuard } from './combined-auth.guard';
import { Public } from './public.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@UseGuards(LocalAuthGuard)
@Post('login')
async login(@Request() req) {
return this.authService.login(req.user);
}
@UseGuards(CombinedAuthGuard)
@Get('profile')
getProfile(@Request() req) {
return req.user;
}
@UseGuards(CombinedAuthGuard)
@Get('api-key')
async getApiKey(@Request() req) {
const apiKey = await this.authService.getOrCreateApiKey(req.user.id);
return { apiKey };
}
@UseGuards(CombinedAuthGuard)
@Post('api-key/regenerate')
async regenerateApiKey(@Request() req) {
const apiKey = await this.authService.regenerateApiKey(req.user.id);
return { apiKey };
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [
UserModule,
TenantModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '1d' }, // Token expires in 1 day
}),
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
controllers: [AuthController],
})
export class AuthModule {}
+48
View File
@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import { User } from '../user/user.entity';
import { SafeUser } from '../user/dto/user-safe.dto';
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
async validateUser(username: string, pass: string): Promise<User | null> {
const user = await this.userService.findOneByUsername(username);
if (user && (await user.validatePassword(pass))) {
return user;
}
return null;
}
async login(user: SafeUser) {
const payload = {
username: user.username,
sub: user.id,
role: user.role,
tenantId: user.tenantId,
};
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
username: user.username,
role: user.role,
tenantId: user.tenantId,
displayName: user.displayName,
},
};
}
async getOrCreateApiKey(userId: string) {
return this.userService.getOrCreateApiKey(userId);
}
async regenerateApiKey(userId: string) {
return this.userService.regenerateApiKey(userId);
}
}
+181
View File
@@ -0,0 +1,181 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { UserService } from '../user/user.service';
import { Request } from 'express';
import { lastValueFrom, Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from './public.decorator';
import { tenantStore } from '../tenant/tenant.store';
import { UserRole } from '../user/user-role.enum';
import { TenantService } from '../tenant/tenant.service';
import * as fs from 'fs';
import * as path from 'path';
/**
* A combined authentication guard that accepts either:
* 1. An API key via the `x-api-key` header (or `Authorization: Bearer kb_...`)
* 2. A standard JWT Bearer token
*
* This replaces JwtAuthGuard on routes that should support both auth methods.
*/
@Injectable()
export class CombinedAuthGuard implements CanActivate {
// We extend AuthGuard('jwt') functionality by composition
private jwtGuard: ReturnType<typeof AuthGuard>;
constructor(
private reflector: Reflector,
private userService: UserService,
private tenantService: TenantService,
) {
// Create a JWT guard instance
const JwtGuardClass = AuthGuard('jwt');
this.jwtGuard = new JwtGuardClass() as any;
}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Allow @Public() decorated routes
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
const request = context
.switchToHttp()
.getRequest<Request & { user?: any; tenantId?: string }>();
const logMsg = `\n[${new Date().toISOString()}] AuthGuard: ${request.method} ${request.url} (isPublic: ${isPublic})\n`;
fs.appendFileSync('auth_debug.log', logMsg);
if (isPublic) {
return true;
}
console.log(
`[CombinedAuthGuard] Checking auth for route: ${request.method} ${request.url}`,
);
// --- Try API Key first ---
const apiKey = this.extractApiKey(request);
if (apiKey) {
const user = await this.userService.findByApiKey(apiKey);
if (user) {
// If x-tenant-id is provided, verify membership
const requestedTenantId = request.headers['x-tenant-id'] as string;
let activeTenantId = user.tenantId;
if (requestedTenantId) {
const memberships = await this.userService.getUserTenants(user.id);
const hasAccess = memberships.some(
(m) => m.tenantId === requestedTenantId,
);
if (hasAccess || user.isAdmin) {
activeTenantId = requestedTenantId;
} else {
throw new UnauthorizedException(
'User does not belong to the requested tenant',
);
}
}
const role = await this.tenantService.getUserRole(
user.id,
activeTenantId,
);
request.user = {
id: user.id,
username: user.username,
role,
tenantId: activeTenantId,
};
request.tenantId = activeTenantId;
// Update tenant context store
const store = tenantStore.getStore();
if (store) {
store.tenantId = activeTenantId;
store.userId = user.id;
}
return true;
}
throw new UnauthorizedException('Invalid API key');
}
// --- Fall back to JWT ---
try {
const result = await (this.jwtGuard as any).canActivate(context);
let hasJwtSession = false;
if (result instanceof Observable) {
hasJwtSession = await lastValueFrom(result);
} else {
hasJwtSession = result;
}
if (hasJwtSession) {
const user = request.user;
if (!user) return false;
const requestedTenantId = request.headers['x-tenant-id'] as string;
if (requestedTenantId && user.tenantId !== requestedTenantId) {
const memberships = await this.userService.getUserTenants(user.id);
const hasAccess = memberships.some(
(m) => m.tenantId === requestedTenantId,
);
if (hasAccess || user.isAdmin) {
user.tenantId = requestedTenantId;
} else {
throw new UnauthorizedException(
'User does not belong to the requested tenant',
);
}
}
// Fetch the role for the active tenant
const role = await this.tenantService.getUserRole(
user.id,
user.tenantId,
);
user.role = role;
request.tenantId = user.tenantId;
// Update tenant context store
const store = tenantStore.getStore();
if (store) {
store.tenantId = user.tenantId;
store.userId = user.id;
}
return true;
}
return false;
} catch (e) {
console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
throw e instanceof UnauthorizedException
? e
: new UnauthorizedException('Authentication required');
}
}
private extractApiKey(request: Request): string | undefined {
// Allow `Authorization: Bearer kb_...` form
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer kb_')) {
return authHeader.substring(7);
}
// Or a plain `x-api-key` header
const headerKey = request.headers['x-api-key'] as string;
if (headerKey) return headerKey;
return undefined;
}
}
@@ -0,0 +1,28 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../../user/user.entity';
@Entity('api_keys')
export class ApiKey {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@ManyToOne(() => User, (user) => user.apiKeys, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ type: 'text', unique: true })
key: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
+45
View File
@@ -0,0 +1,45 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { lastValueFrom, Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from './public.decorator';
import { tenantStore } from '../tenant/tenant.store';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
constructor(private reflector: Reflector) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const result = await super.canActivate(context);
let canActivate = false;
if (result instanceof Observable) {
canActivate = await lastValueFrom(result);
} else {
canActivate = result;
}
if (canActivate) {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (user) {
const store = tenantStore.getStore();
if (store) {
store.tenantId = user.tenantId;
store.userId = user.id;
}
}
}
return canActivate;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { UserService } from '../user/user.service';
import { SafeUser } from '../user/dto/user-safe.dto';
import { UserRole } from '../user/user-role.enum';
import { TenantService } from '../tenant/tenant.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private userService: UserService,
private tenantService: TenantService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET')!,
});
}
// Passport first verifies the JWT's signature and expiration, then calls this method.
async validate(payload: {
sub: string;
username: string;
role?: string;
tenantId?: string;
}): Promise<SafeUser | null> {
// 1. ALWAYS lookup by ID (sub) for identity stability
const user = await this.userService.findOneById(payload.sub);
if (user) {
const { password, ...result } = user;
// In a multi-tenant setup, the tenantId in the payload is the "default" or "last active" one.
// But it can be overridden by the x-tenant-id header in the guard.
// Map the backend isAdmin flag to the global UserRole
const activeTenantId = payload.tenantId || result.tenantId;
// Fetch the actual role for this tenant from the database
const role = await this.tenantService.getUserRole(
result.id,
activeTenantId,
);
return {
...result,
role,
tenantId: activeTenantId,
} as SafeUser;
}
return null;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
+37
View File
@@ -0,0 +1,37 @@
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { SafeUser } from '../user/dto/user-safe.dto'; // Import SafeUser
import { I18nService } from '../i18n/i18n.service';
import { UserRole } from '../user/user-role.enum';
import { TenantService } from '../tenant/tenant.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(
private authService: AuthService,
private i18nService: I18nService,
private tenantService: TenantService,
) {
super({ usernameField: 'username' });
}
async validate(username: string, password: string): Promise<SafeUser> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException(
this.i18nService.getMessage('incorrectCredentials'),
);
}
const { password: userPassword, ...result } = user;
// Fetch the actual role for the user's primary tenant
const role = await this.tenantService.getUserRole(user.id, user.tenantId);
return {
...result,
role,
} as SafeUser;
}
}
+4
View File
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
+5
View File
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../user/user-role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+28
View File
@@ -0,0 +1,28 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
import { UserRole } from '../user/user-role.enum';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
// User might not be injected yet if auth guard fails, but auth guard runs first usually.
if (!user) {
return false;
}
return requiredRoles.includes(user.role);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { UserRole } from '../user/user-role.enum';
@Injectable()
export class SuperAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
return (
user && (user.role === UserRole.SUPER_ADMIN || user.isAdmin === true)
);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { UserRole } from '../user/user-role.enum';
@Injectable()
export class TenantAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
return (
user &&
(user.role === UserRole.SUPER_ADMIN ||
user.role === UserRole.TENANT_ADMIN ||
user.isAdmin === true)
);
}
}