From 5037d331395c749df08b88e712a076811a326306 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 17 Oct 2026 09:32:42 +0000 Subject: [PATCH] fix(backend): scope document access and add admin user + API key endpoints Closes #1474 Closes #1472 Closes #1471 Closes #1470 --- backend/src/documents/documents.controller.ts | 35 ++-- backend/src/documents/documents.service.ts | 7 + .../dto/list-risk-assessments.dto.ts | 13 +- .../risk-assessment.controller.ts | 42 ++++- backend/src/users/api-keys.service.ts | 87 ++++++++++ .../src/users/dto/admin-update-user.dto.ts | 7 + backend/src/users/dto/create-api-key.dto.ts | 16 ++ backend/src/users/dto/list-users.dto.ts | 26 +++ backend/src/users/entities/api-key.entity.ts | 45 +++++ .../src/users/guards/api-key-auth.guard.ts | 44 +++++ backend/src/users/users.controller.ts | 156 +++++++++++++++++- backend/src/users/users.module.ts | 24 ++- backend/src/users/users.service.ts | 33 +++- 13 files changed, 505 insertions(+), 30 deletions(-) create mode 100644 backend/src/users/api-keys.service.ts create mode 100644 backend/src/users/dto/admin-update-user.dto.ts create mode 100644 backend/src/users/dto/create-api-key.dto.ts create mode 100644 backend/src/users/dto/list-users.dto.ts create mode 100644 backend/src/users/entities/api-key.entity.ts create mode 100644 backend/src/users/guards/api-key-auth.guard.ts diff --git a/backend/src/documents/documents.controller.ts b/backend/src/documents/documents.controller.ts index fb1d4eed..d1df9f2a 100644 --- a/backend/src/documents/documents.controller.ts +++ b/backend/src/documents/documents.controller.ts @@ -29,7 +29,7 @@ import * as multer from 'multer'; import { DocumentsService } from './documents.service'; import { DocumentStatus, Document } from './entities/document.entity'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { User } from '../users/entities/user.entity'; +import { User, UserRole } from '../users/entities/user.entity'; import { QueueService } from '../queue/queue.service'; import { VerificationService } from '../verification/verification.service'; import { AccessLogsService } from '../access-logs/access-logs.service'; @@ -159,7 +159,7 @@ export class DocumentsController { @Get(':id') @UseGuards(JwtAuthGuard) async getDocument( - @Param('id') id: string, + @Param('id', ParseUUIDPipe) id: string, @Req() req: Request & { user?: User }, ) { const user = req.user!; @@ -169,9 +169,7 @@ export class DocumentsController { throw new NotFoundException('Document not found'); } - if (document.ownerId !== user.id && user.role !== 'admin') { - throw new ForbiddenException('Access denied'); - } + this.assertOwnerOrAdmin(document, user); return toDocumentResponse(document); } @@ -179,8 +177,8 @@ export class DocumentsController { @Post(':id/verify') @UseGuards(JwtAuthGuard) async verifyDocument( - @Param('id') id: string, - @Req() req: Request, + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request & { user?: User }, @Res() res: Response, ) { const document = await this.documentsService.findById(id); @@ -188,6 +186,8 @@ export class DocumentsController { throw new NotFoundException('Document not found'); } + this.assertOwnerOrAdmin(document, req.user); + if (document.status === DocumentStatus.VERIFIED) { throw new ConflictException('Document has already been verified'); } @@ -202,12 +202,17 @@ export class DocumentsController { @Get(':id/verification') @UseGuards(JwtAuthGuard) - async getVerification(@Param('id') id: string) { + async getVerification( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request & { user?: User }, + ) { const document = await this.documentsService.findById(id); if (!document) { throw new NotFoundException('Document not found'); } + this.assertOwnerOrAdmin(document, req.user); + const record = await this.verificationService.findLatestByDocument(id); if (!record) { throw new NotFoundException( @@ -229,10 +234,7 @@ export class DocumentsController { throw new NotFoundException('Document not found'); } - const user = req.user!; - if (document.ownerId !== user.id && user.role !== 'admin') { - throw new ForbiddenException('Access denied'); - } + this.assertOwnerOrAdmin(document, req.user); const updated = await this.documentsService.revokeAccess(id); return { message: 'Access revoked', documentId: updated?.id, status: updated?.status }; @@ -280,6 +282,15 @@ export class DocumentsController { stream.pipe(res); } + private assertOwnerOrAdmin(document: Document, user?: User): void { + if (!user) { + throw new ForbiddenException('Access denied'); + } + if (document.ownerId !== user.id && user.role !== UserRole.ADMIN) { + throw new ForbiddenException('Access denied'); + } + } + private safeExtension(mimeType: string): string { switch (mimeType) { case 'application/pdf': diff --git a/backend/src/documents/documents.service.ts b/backend/src/documents/documents.service.ts index 6dfb694b..77433a71 100644 --- a/backend/src/documents/documents.service.ts +++ b/backend/src/documents/documents.service.ts @@ -108,6 +108,7 @@ export class DocumentsService { async findByRiskFilters(filters: { page: number; limit: number; + ownerId?: string; minScore?: number; maxScore?: number; startDate?: Date; @@ -118,6 +119,12 @@ export class DocumentsService { qb.where('document.risk_score IS NOT NULL'); + if (filters.ownerId) { + qb.andWhere('document.owner_id = :ownerId', { + ownerId: filters.ownerId, + }); + } + if (filters.minScore !== undefined) { qb.andWhere('document.risk_score >= :minScore', { minScore: filters.minScore, diff --git a/backend/src/risk-assessment/dto/list-risk-assessments.dto.ts b/backend/src/risk-assessment/dto/list-risk-assessments.dto.ts index e0def60b..c2d03996 100644 --- a/backend/src/risk-assessment/dto/list-risk-assessments.dto.ts +++ b/backend/src/risk-assessment/dto/list-risk-assessments.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; export class ListRiskAssessmentsDto { @ApiPropertyOptional({ default: 1 }) @@ -47,4 +47,13 @@ export class ListRiskAssessmentsDto { @IsOptional() @IsString() sortOrder?: 'ASC' | 'DESC' = 'DESC'; + + @ApiPropertyOptional({ + default: false, + description: 'Admins only: include documents from every user', + }) + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + all?: boolean = false; } diff --git a/backend/src/risk-assessment/risk-assessment.controller.ts b/backend/src/risk-assessment/risk-assessment.controller.ts index 6f203a5e..082829c0 100644 --- a/backend/src/risk-assessment/risk-assessment.controller.ts +++ b/backend/src/risk-assessment/risk-assessment.controller.ts @@ -1,24 +1,37 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Request } from 'express'; import { RiskAssessmentService } from './risk-assessment.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { ListRiskAssessmentsDto } from './dto/list-risk-assessments.dto'; import { DocumentsService } from '../documents/documents.service'; +import { User, UserRole } from '../users/entities/user.entity'; @ApiTags('documents') -@Controller('documents') +@Controller() export class RiskAssessmentController { constructor( private readonly riskService: RiskAssessmentService, private readonly documentsService: DocumentsService, ) {} - @Get(':id/risk') + @Get('documents/:id/risk') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Assess the risk score of a document' }) - async getRisk(@Param('id') id: string, @Query('lang') lang?: string) { + async getRisk( + @Param('id', ParseUUIDPipe) id: string, + @Query('lang') lang?: string, + ) { return this.riskService.assessDocument(id, lang); } @@ -26,10 +39,27 @@ export class RiskAssessmentController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'List documents by risk filters (paginated)' }) - async listRiskAssessments(@Query() query: ListRiskAssessmentsDto) { - const { page = 1, limit = 20, minScore, maxScore, startDate, endDate, sortOrder = 'DESC' } = query; + async listRiskAssessments( + @Query() query: ListRiskAssessmentsDto, + @Req() req: Request & { user?: User }, + ) { + const { + page = 1, + limit = 20, + minScore, + maxScore, + startDate, + endDate, + sortOrder = 'DESC', + all = false, + } = query; + + const user = req.user!; + // Only admins may explicitly ask for every user's documents. + const ownerId = user.role === UserRole.ADMIN && all ? undefined : user.id; const result = await this.documentsService.findByRiskFilters({ + ownerId, page, limit, minScore, diff --git a/backend/src/users/api-keys.service.ts b/backend/src/users/api-keys.service.ts new file mode 100644 index 00000000..f6969265 --- /dev/null +++ b/backend/src/users/api-keys.service.ts @@ -0,0 +1,87 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; +import { createHash, randomBytes } from 'crypto'; + +import { ApiKey } from './entities/api-key.entity'; +import { User } from './entities/user.entity'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import { UsersService } from './users.service'; + +function hashSecret(secret: string): string { + return createHash('sha256').update(secret).digest('hex'); +} + +@Injectable() +export class ApiKeysService { + constructor( + @InjectRepository(ApiKey) + private readonly apiKeyRepository: Repository, + private readonly usersService: UsersService, + ) {} + + async create(userId: string, dto: CreateApiKeyDto) { + const secret = `smalda_${randomBytes(24).toString('hex')}`; + const apiKey = await this.apiKeyRepository.save( + this.apiKeyRepository.create({ + userId, + name: dto.name, + prefix: secret.slice(0, 14), + keyHash: hashSecret(secret), + scopes: dto.scopes ?? [], + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + }), + ); + + // The plain-text secret is returned exactly once and never stored. + return { + id: apiKey.id, + name: apiKey.name, + prefix: apiKey.prefix, + scopes: apiKey.scopes, + expiresAt: apiKey.expiresAt, + createdAt: apiKey.createdAt, + key: secret, + }; + } + + list(userId: string): Promise { + return this.apiKeyRepository.find({ + where: { userId, revokedAt: IsNull() }, + order: { createdAt: 'DESC' }, + }); + } + + async revoke(userId: string, id: string): Promise<{ message: string }> { + const apiKey = await this.apiKeyRepository.findOne({ + where: { id, userId }, + }); + if (!apiKey) { + throw new NotFoundException('API key not found'); + } + + if (!apiKey.revokedAt) { + apiKey.revokedAt = new Date(); + await this.apiKeyRepository.save(apiKey); + } + + return { message: 'API key revoked' }; + } + + async validateKey(secret: string): Promise { + const apiKey = await this.apiKeyRepository.findOne({ + where: { keyHash: hashSecret(secret) }, + }); + if (!apiKey || apiKey.revokedAt) { + return null; + } + if (apiKey.expiresAt && apiKey.expiresAt.getTime() <= Date.now()) { + return null; + } + + apiKey.lastUsedAt = new Date(); + await this.apiKeyRepository.save(apiKey); + + return this.usersService.findById(apiKey.userId); + } +} diff --git a/backend/src/users/dto/admin-update-user.dto.ts b/backend/src/users/dto/admin-update-user.dto.ts new file mode 100644 index 00000000..79c90843 --- /dev/null +++ b/backend/src/users/dto/admin-update-user.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { UserRole } from '../entities/user.entity'; + +export class AdminUpdateUserDto { + @IsEnum(UserRole) + role: UserRole; +} diff --git a/backend/src/users/dto/create-api-key.dto.ts b/backend/src/users/dto/create-api-key.dto.ts new file mode 100644 index 00000000..2bb8d3b4 --- /dev/null +++ b/backend/src/users/dto/create-api-key.dto.ts @@ -0,0 +1,16 @@ +import { IsArray, IsISO8601, IsOptional, IsString, Length } from 'class-validator'; + +export class CreateApiKeyDto { + @IsString() + @Length(1, 64) + name: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + scopes?: string[]; + + @IsOptional() + @IsISO8601() + expiresAt?: string; +} diff --git a/backend/src/users/dto/list-users.dto.ts b/backend/src/users/dto/list-users.dto.ts new file mode 100644 index 00000000..fc70d230 --- /dev/null +++ b/backend/src/users/dto/list-users.dto.ts @@ -0,0 +1,26 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { UserRole } from '../entities/user.entity'; + +export class ListUsersDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; +} diff --git a/backend/src/users/entities/api-key.entity.ts b/backend/src/users/entities/api-key.entity.ts new file mode 100644 index 00000000..35c8a640 --- /dev/null +++ b/backend/src/users/entities/api-key.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity('api_keys') +export class ApiKey { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @Column() + name: string; + + /** Only the short visible prefix is stored; the secret itself is shown once. */ + @Column() + prefix: string; + + @Column({ name: 'key_hash', unique: true }) + keyHash: string; + + @Column({ type: 'simple-array', default: '' }) + scopes: string[]; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt?: Date | null; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt?: Date | null; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt?: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/users/guards/api-key-auth.guard.ts b/backend/src/users/guards/api-key-auth.guard.ts new file mode 100644 index 00000000..499c52d7 --- /dev/null +++ b/backend/src/users/guards/api-key-auth.guard.ts @@ -0,0 +1,44 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; + +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { ApiKeysService } from './api-keys.service'; +import { User } from './entities/user.entity'; + +/** + * Authenticates a request with an `x-api-key` header, falling back to the + * normal JWT flow when no key is supplied. Route handlers can therefore opt + * into API-key access without losing cookie/bearer authentication. + */ +@Injectable() +export class ApiKeyAuthGuard implements CanActivate { + constructor( + private readonly apiKeysService: ApiKeysService, + private readonly jwtAuthGuard: JwtAuthGuard, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context + .switchToHttp() + .getRequest(); + const header = request.headers['x-api-key']; + const key = Array.isArray(header) ? header[0] : header; + + if (!key) { + return this.jwtAuthGuard.canActivate(context); + } + + const user = await this.apiKeysService.validateKey(key); + if (!user) { + throw new UnauthorizedException('Invalid or expired API key'); + } + + request.user = user; + return true; + } +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index ce867896..f1ce8931 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,9 +1,47 @@ -import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { Request } from 'express'; import { UsersService } from './users.service'; +import { ApiKeysService } from './api-keys.service'; import { DocumentsService } from '../documents/documents.service'; +import { AccessLogsService } from '../access-logs/access-logs.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { User } from './entities/user.entity'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; +import { User, UserRole } from './entities/user.entity'; +import { ListUsersDto } from './dto/list-users.dto'; +import { AdminUpdateUserDto } from './dto/admin-update-user.dto'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; + +type AuthedRequest = Request & { user?: User }; + +function toPublicUser(user: User) { + return { + id: user.id, + email: user.email, + fullName: user.fullName, + role: user.role, + isVerified: user.isVerified, + preferredLanguage: user.preferredLanguage, + twoFactorEnabled: user.twoFactorEnabled, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} /** * Self-service data export for GDPR-style portability requests. @@ -16,11 +54,13 @@ export class UsersController { constructor( private readonly usersService: UsersService, private readonly documentsService: DocumentsService, + private readonly apiKeysService: ApiKeysService, + private readonly accessLogsService: AccessLogsService, ) {} @Get('me/export') - @UseGuards(JwtAuthGuard) - async exportMyData(@Req() req: Request & { user?: User }) { + @UseGuards(ApiKeyAuthGuard) + async exportMyData(@Req() req: AuthedRequest) { const userId = req.user!.id; const [user, documents] = await Promise.all([ this.usersService.findById(userId), @@ -53,4 +93,110 @@ export class UsersController { })), }; } -} \ No newline at end of file + + @Get('me/api-keys') + @UseGuards(JwtAuthGuard) + listApiKeys(@Req() req: AuthedRequest) { + return this.apiKeysService.list(req.user!.id); + } + + @Post('me/api-keys') + @UseGuards(JwtAuthGuard) + createApiKey(@Req() req: AuthedRequest, @Body() dto: CreateApiKeyDto) { + return this.apiKeysService.create(req.user!.id, dto); + } + + @Delete('me/api-keys/:id') + @UseGuards(JwtAuthGuard) + revokeApiKey( + @Req() req: AuthedRequest, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.apiKeysService.revoke(req.user!.id, id); + } + + @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async listUsers(@Query() query: ListUsersDto) { + const { page = 1, limit = 20, search, role } = query; + const result = await this.usersService.findAllPaginated( + page, + limit, + search, + role, + ); + + return { + data: result.data.map(toPublicUser), + total: result.total, + page: result.page, + limit: result.limit, + totalPages: Math.ceil(result.total / limit), + }; + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async updateUser( + @Req() req: AuthedRequest, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AdminUpdateUserDto, + ) { + const target = await this.usersService.findById(id); + if (!target) { + throw new NotFoundException('User not found'); + } + + if (target.id === req.user!.id && target.role !== dto.role) { + throw new BadRequestException('You cannot change your own role'); + } + + if (target.role === UserRole.ADMIN && dto.role !== UserRole.ADMIN) { + const adminCount = await this.usersService.countAdmins(); + if (adminCount <= 1) { + throw new BadRequestException('Cannot demote the last admin'); + } + } + + const updated = await this.usersService.updateRole(id, dto.role); + + await this.accessLogsService.create({ + userId: req.user!.id, + routePath: `admin/users/${id}/role`, + httpMethod: 'PATCH', + ipAddress: req.ip ?? 'unknown', + statusCode: 200, + }); + + return updated ? toPublicUser(updated) : null; + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async deleteUser( + @Req() req: AuthedRequest, + @Param('id', ParseUUIDPipe) id: string, + ) { + if (id === req.user!.id) { + throw new BadRequestException('You cannot delete your own account'); + } + + const target = await this.usersService.findById(id); + if (!target) { + throw new NotFoundException('User not found'); + } + + if (target.role === UserRole.ADMIN) { + const adminCount = await this.usersService.countAdmins(); + if (adminCount <= 1) { + throw new BadRequestException('Cannot delete the last admin'); + } + } + + await this.usersService.softDelete(id); + return { message: 'User deleted' }; + } +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 6b041a5b..f1571c82 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -1,14 +1,32 @@ import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersService } from './users.service'; +import { ApiKeysService } from './api-keys.service'; import { UsersController } from './users.controller'; import { User } from './entities/user.entity'; +import { ApiKey } from './entities/api-key.entity'; +import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { DocumentsModule } from '../documents/documents.module'; +import { AccessLogsModule } from '../access-logs/access-logs.module'; @Module({ - imports: [TypeOrmModule.forFeature([User]), DocumentsModule], + imports: [ + TypeOrmModule.forFeature([User, ApiKey]), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET'), + }), + }), + DocumentsModule, + AccessLogsModule, + ], controllers: [UsersController], - providers: [UsersService], - exports: [UsersService], + providers: [UsersService, ApiKeysService, ApiKeyAuthGuard, RolesGuard], + exports: [UsersService, ApiKeysService], }) export class UsersModule {} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 90db45bf..e7d12dc7 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User } from './entities/user.entity'; +import { FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { User, UserRole } from './entities/user.entity'; interface RateLimitEntry { count: number; @@ -72,4 +72,33 @@ export class UsersService { async softDelete(id: string): Promise { await this.userRepository.softDelete(id); } + + async findAllPaginated( + page = 1, + limit = 20, + search?: string, + role?: UserRole, + ): Promise<{ data: User[]; total: number; page: number; limit: number }> { + const where: FindOptionsWhere = {}; + if (role) where.role = role; + if (search?.trim()) where.fullName = ILike(`%${search.trim()}%`); + + const [data, total] = await this.userRepository.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total, page, limit }; + } + + countAdmins(): Promise { + return this.userRepository.count({ where: { role: UserRole.ADMIN } }); + } + + async updateRole(id: string, role: UserRole): Promise { + await this.userRepository.update(id, { role }); + return this.findById(id); + } }