From 106b07a02be58c09d960310bd65742ae1f10d164 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 12:32:10 +0000 Subject: [PATCH] add staff user list endpoint --- .../modules/auth/dto/list-users-query.dto.ts | 38 ++++++++++ .../src/modules/auth/freight-auth.module.ts | 4 ++ .../src/modules/auth/list-users.controller.ts | 22 ++++++ .../src/modules/auth/list-users.service.ts | 69 +++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/auth/list-users.controller.ts create mode 100644 apps/edr-freight-api/src/modules/auth/list-users.service.ts diff --git a/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts new file mode 100644 index 000000000..2325568e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts @@ -0,0 +1,38 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { EUserStatus, EUserType } from '@tria-plc/api-common/utils/enums/user.enum'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsBoolean, IsEnum, IsIn, IsOptional } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +/** Query-string booleans arrive as strings; implicit conversion is off app-wide. */ +const toOptionalBoolean = ({ value }: TransformFnParams): boolean | undefined => + value === undefined || value === null || value === '' + ? undefined + : value === true || value === 'true'; + +export class ListUsersQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: EUserType }) + @IsOptional() + @IsEnum(EUserType) + userType?: EUserType; + + @ApiPropertyOptional({ enum: EUserStatus }) + @IsOptional() + @IsEnum(EUserStatus) + userStatus?: EUserStatus; + + @ApiPropertyOptional({ description: 'Filter by active flag.' }) + @IsOptional() + @Transform(toOptionalBoolean) + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ + enum: ['username', 'email', 'createdAt'], + default: 'username', + }) + @IsOptional() + @IsIn(['username', 'email', 'createdAt']) + sortBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 10dbd0b37..ff8f803b9 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -19,6 +19,8 @@ import { ForgotPasswordController } from './forgot-password.controller'; import { ForgotPasswordService } from './forgot-password.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; +import { ListUsersController } from './list-users.controller'; +import { ListUsersService } from './list-users.service'; @Module({ imports: [ @@ -39,8 +41,10 @@ import { FreightMeService } from './freight-me.service'; CheckAvailabilityController, ForgotPasswordController, CustomerResetController, + ListUsersController, ], providers: [ + ListUsersService, FreightMeService, AccountService, CheckAvailabilityService, diff --git a/apps/edr-freight-api/src/modules/auth/list-users.controller.ts b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts new file mode 100644 index 000000000..e7fbfd771 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import { ListUsersService } from './list-users.service'; +import { StaffReference } from '../../common/booking-guards'; + +@ApiTags('auth') +@Controller('staff/users') +@ApiBearerAuth() +export class ListUsersController { + constructor(private readonly service: ListUsersService) {} + + @Get() + @StaffReference() + @ApiOperation({ + summary: 'List IAM users (paginated) for backoffice pickers', + }) + findAll(@Query() query: ListUsersQueryDto) { + return this.service.findAll(query); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/list-users.service.ts b/apps/edr-freight-api/src/modules/auth/list-users.service.ts new file mode 100644 index 000000000..cf7e22be2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/list-users.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { PaginatedResponse } from '@edr/types'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { Repository } from 'typeorm'; + +import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import { paginateQuery } from '../../common/utils/pagination.util'; + +/** + * Read-only listing of `iam.users` for backoffice pickers. + * + * Exists because `@tria-plc/iamapi-common@1.0.0`'s `GET /users/filter` pairs a + * `@QueryParams()` pagination DTO with a plain `@Query()` DTO that does not + * declare `skip`/`take`/`orderBy`; the global whitelist pipe then 400s on the + * very params the route's own paginator reads. Drop this once IAM ships a fix. + */ +@Injectable() +export class ListUsersService { + constructor( + @InjectRepository(User) private readonly users: Repository, + ) {} + + findAll(query: ListUsersQueryDto): Promise> { + const sortBy = query.sortBy ?? 'username'; + const qb = this.users + .createQueryBuilder('user') + // Explicit select: never widen this to `user` — the entity's lazy + // relations include credentials and sessions. + .select([ + 'user.id', + 'user.name', + 'user.username', + 'user.email', + 'user.phoneNumber', + 'user.userType', + 'user.status', + 'user.isActive', + 'user.createdAt', + ]) + .orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC'); + + if (query.userType) { + qb.andWhere('user.userType = :userType', { userType: query.userType }); + } + if (query.userStatus) { + qb.andWhere('user.status = :userStatus', { userStatus: query.userStatus }); + } + if (query.isActive !== undefined) { + qb.andWhere('user.isActive = :isActive', { isActive: query.isActive }); + } + if (query.search) { + // `name` is localized jsonb ({ en, am, … }), not a string — match its + // values rather than casting the whole object to text. + qb.andWhere( + `(user.username ILIKE :search + OR user.email ILIKE :search + OR user.phone_number ILIKE :search + OR EXISTS ( + SELECT 1 FROM jsonb_each_text(user.name) AS n(k, v) + WHERE n.v ILIKE :search + ))`, + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } +}