mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 18:20:57 +00:00
The detail page showed the company's business contact details but not the credentials anyone actually signs in with, and the two drift apart routinely — so "the customer says they can't log in" was unanswerable from this screen. Adds `GET /backoffice/customers/:companyId/accounts`, joining each external profile to its IAM account, primary contact first. Deliberately not filtered to active accounts: a suspended or never-activated login is exactly the case being looked into. The user query selects columns explicitly — the entity's relations include credentials and sessions, and this response reaches a browser. Rendered as cards rather than a table: it is a handful of rows of mostly-optional fields, which a table renders as a field of dashes. "Password never set" is called out on its own, being the usual answer to "they never got in", and a profile whose IAM user is gone reads as a red fault rather than an inactive status.
113 lines
3.9 KiB
TypeScript
113 lines
3.9 KiB
TypeScript
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<ExternalProfile>,
|
|
@InjectRepository(User)
|
|
private readonly users: Repository<User>,
|
|
) {}
|
|
|
|
/**
|
|
* 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<CustomerAccount[]> {
|
|
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();
|
|
});
|
|
}
|
|
}
|