import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; 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, private readonly config: ConfigService, ) {} 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'); // Restrict to one IAM organization when configured. The org key differs per // environment (dev seeds `edr_freight`, production uses the registered // company key), so this is config rather than a constant. An unset key // means no restriction; a key matching no organization matches no user — // failing closed rather than silently widening to every org. const orgKey = this.config.get('FREIGHT_ORG_KEY'); if (orgKey) { // EXISTS, not a join: a user with several employee rows would otherwise // be returned once per row, duplicating them in the list and inflating // `getManyAndCount`'s total. qb.andWhere( `EXISTS ( SELECT 1 FROM iam.employees emp JOIN iam.organizations org ON org.id = emp.organization_id WHERE emp.user_id = "user".id AND org.key = :orgKey AND org.deleted_at IS NULL )`, { orgKey }, ); } 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); } }