Files
edr-platform/apps/edr-freight-web/backoffice/src/services/customers.service.ts
Nathnael 3af3017b86 feat(backoffice): add an Account tab with the customer's portal logins
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.
2026-08-27 09:30:24 +00:00

219 lines
6.8 KiB
TypeScript

import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
Company,
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyRevision,
CompanyStats,
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerAccount,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
ResetChannel,
ResetPasswordResult,
} from "@/types/customer";
const cleanParams = (params: object) =>
Object.fromEntries(
Object.entries(params).filter(
([, value]) => value !== undefined && value !== "" && value !== null,
),
);
/** Lift attributes JSONB into the flat contact/owner fields the UI reads. */
function mapCompany(dto: Record<string, unknown>): Company {
const attrs = (dto.attributes as Record<string, unknown> | null) ?? {};
return {
...(dto as unknown as Company),
companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [],
contactPersonName: (attrs.contactPersonName as string | null) ?? null,
contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null,
ownerName: (attrs.ownerName as string | null) ?? null,
ownerEmail: (attrs.ownerEmail as string | null) ?? null,
ownerPhone: (attrs.ownerPhone as string | null) ?? null,
poaName: (attrs.poaName as string | null) ?? null,
poaEmail: (attrs.poaEmail as string | null) ?? null,
poaPhone: (attrs.poaPhone as string | null) ?? null,
poaLocation: (attrs.poaLocation as string | null) ?? null,
poaAddress: (attrs.poaAddress as string | null) ?? null,
};
}
export const customersService = {
stats(): Promise<CompanyStats> {
return apiClient
.get<CompanyStats>(URL_CONSTANTS.COMPANIES.STATS)
.then((r) => r.data);
},
list(filter: CompanyListFilter): Promise<PaginatedCompanies> {
return apiClient
.get<{ items: Record<string, unknown>[]; total: number }>(
URL_CONSTANTS.COMPANIES.BASE,
{ params: cleanParams(filter) },
)
.then((r) => ({
items: r.data.items.map(mapCompany),
total: r.data.total,
}));
},
getById(id: string): Promise<Company | undefined> {
return apiClient
.get<Record<string, unknown>>(URL_CONSTANTS.COMPANIES.BY_ID(id))
.then((r) => mapCompany(r.data));
},
bookingsFor(companyId: string): Promise<CustomerBooking[]> {
return apiClient
.get<CustomerBooking[]>(
URL_CONSTANTS.COMPANIES.BOOKINGS_CUSTOMER_VIEW(companyId),
)
.then((r) => r.data);
},
documentsFor(companyId: string): Promise<CustomerDocument[]> {
return apiClient
.get<CustomerDocument[]>(URL_CONSTANTS.COMPANIES.DOCUMENTS(companyId))
.then((r) => r.data);
},
paymentsFor(companyId: string): Promise<CustomerPayment[]> {
return apiClient
.get<CustomerPayment[]>(
URL_CONSTANTS.COMPANIES.PAYMENTS_CUSTOMER_VIEW(companyId),
)
.then((r) => r.data);
},
/**
* Every portal login belonging to this customer, primary contact first.
*
* Not filtered to active accounts — a suspended or never-activated login is
* exactly what staff are checking when a customer says they cannot sign in.
*/
accounts(companyId: string): Promise<CustomerAccount[]> {
return apiClient
.get<CustomerAccount[]>(URL_CONSTANTS.COMPANIES.ACCOUNTS(companyId))
.then((r) => r.data);
},
/**
* The IAM account a reset link would go to. Read before offering the action
* so staff see the credentials the link actually reaches, not the company's
* business contact details.
*/
resetTarget(companyId: string): Promise<CustomerResetTarget> {
return apiClient
.get<CustomerResetTarget>(URL_CONSTANTS.COMPANIES.RESET_TARGET(companyId))
.then((r) => r.data);
},
/**
* Send a password-reset link to the company's primary contact. Staff never
* receive a credential — the customer opens the link and sets their own
* password.
*/
resetPassword(
companyId: string,
channel: ResetChannel,
): Promise<ResetPasswordResult> {
return apiClient
.post<ResetPasswordResult>(
URL_CONSTANTS.COMPANIES.RESET_PASSWORD(companyId),
{ channel },
)
.then((r) => r.data);
},
setProfileStatus(
profileId: string,
status: ProfileStatus,
note?: string,
): Promise<CompanyProfile> {
return apiClient
.patch<CompanyProfile>(
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
{ status, note },
)
.then((r) => r.data);
},
/** Approve / change a company's status (e.g. pending → active). */
setCompanyStatus(companyId: string, status: string): Promise<unknown> {
return apiClient
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
.then((r) => r.data);
},
/** List a company's profile-edit change requests (newest first). */
changeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
return apiClient
.get<CompanyChangeRequest[]>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUESTS(companyId),
)
.then((r) => r.data);
},
/** Onboarding-phase edit history for a company (version history), newest first. */
revisions(companyId: string): Promise<CompanyRevision[]> {
return apiClient
.get<CompanyRevision[]>(URL_CONSTANTS.COMPANIES.REVISIONS(companyId))
.then((r) => r.data);
},
/** Approve a pending change request — applies the proposed changes. */
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_APPROVE(id),
)
.then((r) => r.data);
},
/** Reject a pending change request with a note. */
rejectChangeRequest(id: string, note: string): Promise<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REJECT(id),
{ note },
)
.then((r) => r.data);
},
/** Ask for specific changes without rejecting — the request stays open for the customer's next edit to append to. */
requestChangeRequestChanges(
id: string,
note: string,
): Promise<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REQUEST_CHANGES(id),
{ note },
)
.then((r) => r.data);
},
/**
* Ask the customer to correct one uploaded document. Narrower than rejecting
* the whole role: the customer keeps their other documents and only re-uploads
* this one, but the role cannot be approved until they do.
*/
requestDocumentChange(
fileId: string,
note: string,
): Promise<CustomerDocument> {
return apiClient
.post<CustomerDocument>(
URL_CONSTANTS.COMPANIES.DOCUMENT_REQUEST_CHANGE(fileId),
{ note },
)
.then((r) => r.data);
},
};