import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { In, Repository } from "typeorm"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { ExternalProfile } from "../companies/entities/external-profile.entity"; /** * One portal login belonging to a customer company: the company-side profile * joined to the IAM account that actually signs in. * * The two halves drift apart routinely — `company.email` is business contact * detail, while `email` here is the credential a reset link goes to — which is * exactly why staff need to see the IAM side rather than the company row. */ export interface CustomerAccount { /** external_profiles.id */ profileId: string; userId: string; firstName: string; lastName: string; jobTitle: string | null; isPrimaryContact: boolean; onboardingStep: string | null; onboardingCompleted: boolean; /** Null when the profile points at a user row that no longer exists. */ username: string | null; email: string | null; phoneNumber: string | null; phoneVerified: boolean | null; /** IAM account status (`EUserStatus`), surfaced as-is. */ status: string | null; isActive: boolean | null; /** False means the account was created but never activated by its owner. */ hasSetPassword: boolean | null; createdAt: Date; } @Injectable() export class CustomerAccountsService { constructor( @InjectRepository(ExternalProfile) private readonly profiles: Repository, @InjectRepository(User) private readonly users: Repository, ) {} /** * Every portal account for a company, primary contact first. * * Deliberately NOT filtered to active accounts: a suspended or never-activated * login is the case staff are usually looking into, and hiding it would leave * "the customer says they can't log in" unanswerable from this screen. */ async listForCompany(companyId: string): Promise { const profiles = await this.profiles.find({ where: { companyId } }); if (profiles.length === 0) return []; const userIds = profiles.map((p) => p.userId).filter(Boolean); // Explicit select: the User entity's relations include credentials and // sessions, and this response goes to a browser. const users = userIds.length ? await this.users .createQueryBuilder("user") .select([ "user.id", "user.username", "user.email", "user.phoneNumber", "user.isPhoneNumberVerified", "user.status", "user.isActive", "user.hasSetPassword", ]) .where({ id: In(userIds) }) .getMany() : []; const byId = new Map(users.map((u) => [u.id, u])); return profiles .map((p) => { const user = byId.get(p.userId); return { profileId: p.id, userId: p.userId, firstName: p.firstName, lastName: p.lastName, jobTitle: p.jobTitle ?? null, isPrimaryContact: p.isPrimaryContact, onboardingStep: p.onboardingStep ?? null, onboardingCompleted: p.onboardingCompleted ?? false, username: user?.username ?? null, email: user?.email ?? null, phoneNumber: user?.phoneNumber ?? null, phoneVerified: user?.isPhoneNumberVerified ?? null, status: user?.status ?? null, isActive: user?.isActive ?? null, hasSetPassword: user?.hasSetPassword ?? null, createdAt: p.createdAt, }; }) .sort((a, b) => { // Primary contact first — it is the account every staff action // (password reset, notifications) actually targets. if (a.isPrimaryContact !== b.isPrimaryContact) { return a.isPrimaryContact ? -1 : 1; } return a.createdAt.getTime() - b.createdAt.getTime(); }); } }