Merge pull request #1207 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-10 09:56:59 +03:00
committed by GitHub
18 changed files with 565 additions and 93 deletions

View File

@@ -10,7 +10,7 @@ import {
import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
@@ -55,10 +55,13 @@ export class BookingLifecycleNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.companyId
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
: null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
// Both channels come from the same resolver: the company row's own columns
// are only half the story (see companyNotifyEmailExpr), and reading them off
// the loaded entity silently dropped every mail to a company whose address
// lives in `attributes`.
const { phone, email } = b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
if (phone) {
try {

View File

@@ -147,6 +147,10 @@ function makeService(overrides: Partial<Ctx> = {}) {
verifayda: {
completeVerification: jest.fn(async () => ctx.verification),
},
// Onboarding requirements resolve the nationality's document set through
// this; no setting means no company documents, which keeps those cases
// focused on the identity/PoA half of the list.
fileUploadSettings: { getByCode: jest.fn(async () => null) },
};
const service = new CompaniesService(
@@ -157,7 +161,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
{} as never,
deps.fileUploadSettings as never,
{} as never,
deps.companyNotifier as never,
{} as never,
@@ -763,6 +767,73 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
expect(state.gm.email).toBe("legacy@example.com");
});
// The mirror image, and the reason the fallback above is gated on the GM
// being unverified: a manager Fayda proved but supplied no email for types
// one instead, and the portal decides whether to render that input by asking
// whether the identity holds one. Reading the typed column back as part of
// the verified identity would answer "yes" the moment it was saved — the
// input would vanish and a typo could never be corrected.
it("keeps a verified GM's typed email out of the verified identity", async () => {
const { service, company } = makeService({
attributes: {
...OWNER_VERIFIED,
gmFaydaSub: "gm-sub",
gmFaydaVerifiedAt: "2026-07-03T00:00:00.000Z",
gmName: "Derartu Tulu",
generalManagerName: "Derartu Tulu",
generalManagerEmail: "typed@example.com",
},
});
const state = service.getCompanyIdentityState(company() as never);
expect(state.gm.verified).toBe(true);
expect(state.gm.name).toBe("Derartu Tulu");
expect(state.gm.email).toBeNull();
});
it("never stands the account in for a GM Fayda gave no email", async () => {
// Deliberate: the account is the person onboarding, not necessarily the
// manager. The portal asks for the email instead.
const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED },
verification: {
purpose: "VERIFY",
verified: true,
sub: "gm-sub",
fullName: "Derartu Tulu",
phoneNumber: "+251911222333",
},
});
const state = await service.completeIdentityVerification(
"user-1",
{ subject: "gm", code: "c", state: "s" },
{ email: "account@example.com", phoneNumber: "+251911777777" },
);
expect(ctx.attributes.gmEmail).toBeUndefined();
expect(state.gm.verified).toBe(true);
expect(state.gm.email).toBeNull();
});
it("accepts the email typed for a GM whose verification carried none", async () => {
const { service, ctx } = makeService({
attributes: {
...OWNER_VERIFIED,
gmFaydaSub: "gm-sub",
gmName: "Derartu Tulu",
generalManagerName: "Derartu Tulu",
},
});
await service.updateProfile("user-1", {
generalManagerEmail: "gm@example.com",
} as never);
expect(ctx.attributes.generalManagerEmail).toBe("gm@example.com");
});
it("does not let an unproven GM block the company from trading", async () => {
// The GM names who to talk to, not what the company may do. Capturing it
// through Fayda changed how it is collected, not whether it gates.
@@ -775,3 +846,94 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
).resolves.toBeDefined();
});
});
/**
* Fayda's email and phone claims are optional, so a *verified* representative
* can still be missing the details `REQUIRED_POA_FIELDS` demands. The PoA step
* renders an input for whatever the verification did not supply — so onboarding
* has to report them outstanding, rather than letting a freight forwarder
* submit an incomplete representative and be refused its next PoA edit for it.
*/
describe("onboarding requirements name the PoA details Fayda did not supply", () => {
const POA_VERIFIED_NO_CONTACTS = {
poaFaydaSub: "poa-sub",
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
poaName: "Tirunesh Dibaba",
};
it("reports the missing email and phone for a freight forwarder", async () => {
const { service } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED_NO_CONTACTS },
profileTypes: [ProfileType.freightForwarder],
files: [paper()],
});
const req = await service.getOnboardingRequirements("user-1");
expect(req.poa.missingFields.map((f) => f.key)).toEqual([
"poaEmail",
"poaPhone",
]);
expect(req.poa.complete).toBe(false);
expect(req.outstanding).toEqual(
expect.arrayContaining(["Add your poa email", "Add your poa phone"]),
);
});
it("clears once they are typed", async () => {
const { service } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
profileTypes: [ProfileType.freightForwarder],
files: [paper()],
});
const req = await service.getOnboardingRequirements("user-1");
expect(req.poa.missingFields).toEqual([]);
expect(req.poa.complete).toBe(true);
expect(req.outstanding).not.toContain("Add your poa email");
});
// Fayda's email claim is optional and the GM's verification has no account to
// fall back on, so demanding one blocked a manager the government had already
// proved. `companyNotifyEmailExpr` resolves the address from the contact
// person or the registering account instead, so nothing needs this filled.
it("does not hold a company back for a general manager with no email", async () => {
const { service } = makeService({
attributes: {
...OWNER_VERIFIED,
gmFaydaSub: "gm-sub",
generalManagerName: "Derartu Tulu",
generalManagerPhone: "+251911222333",
},
});
const req = await service.getOnboardingRequirements("user-1");
expect(req.companyInfo.missingFields.map((f) => f.key)).not.toContain(
"generalManagerEmail",
);
expect(req.outstanding).not.toContain("Add your general manager email");
});
it("still holds it back for the manager's name and phone", async () => {
const { service } = makeService({ attributes: { ...OWNER_VERIFIED } });
const req = await service.getOnboardingRequirements("user-1");
expect(req.companyInfo.missingFields.map((f) => f.key)).toEqual(
expect.arrayContaining(["generalManagerName", "generalManagerPhone"]),
);
});
// An importer that never named a representative owes nothing here — the step
// is one it may walk straight past.
it("asks nothing of a company with no PoA at all", async () => {
const { service } = makeService({ attributes: { ...OWNER_VERIFIED } });
const req = await service.getOnboardingRequirements("user-1");
expect(req.poa.missingFields).toEqual([]);
expect(req.poa.complete).toBe(true);
});
});

View File

@@ -253,11 +253,14 @@ export class CompaniesService {
label: "General manager name",
get: (c) => c.attributes?.generalManagerName,
},
{
key: "generalManagerEmail",
label: "General manager email",
get: (c) => c.attributes?.generalManagerEmail,
},
// The manager's EMAIL is deliberately absent. It was demanded because the
// notifiers were believed to mail it, and Fayda's email claim is optional
// — so a manager the government proved without one blocked the whole
// submission over an address nothing could produce. `companyNotifyEmailExpr`
// now resolves the address itself and falls through to the contact
// person's, then to the registering account's (which signup guarantees),
// so nothing depends on this being filled. It is still collected and still
// preferred when present; it just no longer holds the company hostage.
{
key: "generalManagerPhone",
label: "General manager phone",
@@ -2042,13 +2045,21 @@ export class CompaniesService {
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
// No company types its PoA details — they arrive from the Fayda
// verification whatever the nationality — so reporting them as missing
// fields would ask for something no form offers. The identity block below
// reports "verify your PoA" instead.
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided;
// The representative's details normally arrive from their Fayda
// verification — but Fayda's email and phone claims are optional and
// routinely come back empty, and the PoA step renders an input for whatever
// the verification did not supply. So these are askable after all, and are
// reported outstanding once a PoA is required or provided; reporting
// nothing here let a freight forwarder finish onboarding with a
// representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then
// 400'd their next PoA edit for it.
const missingPoaFields = delegationDue
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
const missingDelegation = delegationDue && !delegation.onFile;
// A paper the reviewer sent back is not evidence — the customer has to
// replace it before the application counts as complete.
@@ -2101,7 +2112,10 @@ export class CompaniesService {
// fields, required documents, one license per operational profile, and the
// PoA details/paper whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount = delegationDue ? 1 : 0;
// The delegation paper plus the representative's own required details —
// `completed` below subtracts every one of those it is still missing, so
// leaving them out of the total would make the bar understate progress.
const poaItemCount = delegationDue ? 1 + REQUIRED_POA_FIELDS.length : 0;
// One item per identity credential the company has to prove: the owner
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// there is one — Fayda for an Ethiopian company, a named representative

View File

@@ -10,7 +10,7 @@ import {
import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
import { resolveCompanyNotifyContact } from "../notifications/resolve-company-phone.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
/** Account statuses that lock the customer out and therefore must be told to them. */
@@ -38,8 +38,13 @@ export class CompanyNotifierService {
/** Send SMS + email to the company contact; log-only on failure. */
private async notifyContact(company: Company, message: string): Promise<void> {
const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id);
const email = company.email ?? company.generalManagerEmail ?? null;
// One resolver for both channels — the company row's own email column is
// only set for a Fayda-verified owner (see companyNotifyEmailExpr), which
// left an approval/suspension notice unsent to everyone else.
const { phone, email } = await resolveCompanyNotifyContact(
this.dataSource,
company.id,
);
if (phone) {
try {

View File

@@ -143,12 +143,18 @@ function stateFor(
birthdate: read(`${p}Birthdate`),
gender: read(`${p}Gender`),
};
if (subject !== "gm") return state;
if (subject !== "gm" || state.verified) return state;
// Companies onboarded before the GM was verifiable have typed details and no
// `gm*` attributes at all. Report those rather than a blank card — they are
// still what the notifiers mail — leaving `verified` false so the portal
// offers the upgrade instead of pretending the identity is proven.
//
// Only for such an unverified GM, which is the whole population this exists
// for. Merging the typed columns into a *verified* manager's state would read
// back the email the portal asked them to type when Fayda supplied none, and
// the input offering it — keyed on that value being absent — would vanish the
// moment it was saved, leaving a typo uncorrectable.
return {
...state,
name: state.name ?? read(GM_TYPED_KEYS.name),

View File

@@ -10,7 +10,7 @@ import {
import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
@@ -52,10 +52,11 @@ export class ContractNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(c)}`);
const phone = c.companyId
? await resolveCompanyNotifyPhone(this.dataSource, c.companyId)
: null;
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
// One resolver for both channels — the company row's own email column is
// only set for a Fayda-verified owner (see companyNotifyEmailExpr).
const { phone, email } = c.companyId
? await resolveCompanyNotifyContact(this.dataSource, c.companyId)
: { phone: null, email: null };
if (phone) {
try {

View File

@@ -51,8 +51,11 @@ export class EmailClientService implements OnApplicationBootstrap {
this.logger.log(
`EMAIL publish to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' confirmed=${queued}`,
);
// Recipient + content are PII — debug only.
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
// Full content logged on purpose (audit of what actually left the API).
// Note: recipient + body are PII and land in plain application logs.
this.logger.log(
`EMAIL payload to=${dto.to} subject="${dto.subject}" text="${dto.text ?? ""}" html="${dto.html ?? ""}"`,
);
return { queued };
}

View File

@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
import { NotificationsService } from './notifications.service';
import {
companyNotifyEmailExpr,
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from './resolve-company-phone.util';
@@ -19,7 +20,8 @@ export async function sendCompanyChannels(
): Promise<void> {
const [contact]: Array<{ phone: string | null; email: string | null }> =
await dataSource.query(
`SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email
`SELECT ${companyNotifyPhoneExpr('co')} AS phone,
${companyNotifyEmailExpr('co')} AS email
FROM freight.companies co
${primaryContactUserJoin('co')}
WHERE co.id = $1 AND co.deleted_at IS NULL`,

View File

@@ -1,7 +1,7 @@
import { DataSource, EntityManager } from "typeorm";
/**
* Where a customer-facing SMS actually goes.
* Where a customer-facing SMS or email actually goes.
*
* The person who signs up, logs in, and receives OTPs is an IAM user, and
* `iam.users.phone_number` is the number they control and can change themselves
@@ -12,11 +12,13 @@ import { DataSource, EntityManager } from "typeorm";
* `companies.contact_person_phone` is deliberately NOT consulted: the live write
* path stores that value in the `attributes` jsonb and has never populated the
* column, so every reader of it was silently falling through to `phone` anyway.
* `companies.general_manager_email` is the same trap on the email side — see
* {@link companyNotifyEmailExpr}.
*/
/**
* LEFT JOIN a company alias to its primary contact's IAM user, exposing
* `pc.phone_number`.
* `pc.phone_number` and `pc.email`.
*
* LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a
* company having two `is_primary_contact` rows, and a plain join would then
@@ -28,7 +30,7 @@ import { DataSource, EntityManager } from "typeorm";
export function primaryContactUserJoin(alias: string): string {
return `
LEFT JOIN LATERAL (
SELECT u.phone_number
SELECT u.phone_number, u.email
FROM freight.external_profiles ep
JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true
WHERE ep.company_id = ${alias}.id
@@ -44,17 +46,45 @@ export function companyNotifyPhoneExpr(alias: string): string {
return `COALESCE(pc.phone_number, ${alias}.phone)`;
}
/** The SMS number for one company, or null when neither source has one. */
export async function resolveCompanyNotifyPhone(
/**
* SQL expression for the company's notification address, given the joined `pc`
* alias.
*
* `companies.email` alone is not enough: it is written from ONE place — a
* Fayda-verified owner's email claim — so a foreign company, whose owner proves
* identity by passport instead, never gets one. Readers papered over that with
* `COALESCE(email, general_manager_email)`, but that column has the same problem
* `contact_person_phone` has above: onboarding writes the value into the
* `attributes` jsonb and nothing has ever populated the column, so the fallback
* could not fire and the mail was dropped in silence.
*
* So: the company address, then the two the customer actually filled in during
* onboarding, then the account that registered them — which always has one,
* signup requires it. `NULLIF` because a blank jsonb key is not an address and
* `COALESCE` would happily stop on it.
*/
export function companyNotifyEmailExpr(alias: string): string {
return `COALESCE(
NULLIF(${alias}.email, ''),
NULLIF(${alias}.attributes->>'generalManagerEmail', ''),
NULLIF(${alias}.attributes->>'contactPersonEmail', ''),
NULLIF(pc.email, '')
)`;
}
/** Both channels for one company; either side is null when nothing has one. */
export async function resolveCompanyNotifyContact(
db: DataSource | EntityManager,
companyId: string,
): Promise<string | null> {
const rows: Array<{ phone: string | null }> = await db.query(
`SELECT ${companyNotifyPhoneExpr("co")} AS phone
FROM freight.companies co
${primaryContactUserJoin("co")}
WHERE co.id = $1 AND co.deleted_at IS NULL`,
[companyId],
);
return rows[0]?.phone ?? null;
): Promise<{ phone: string | null; email: string | null }> {
const rows: Array<{ phone: string | null; email: string | null }> =
await db.query(
`SELECT ${companyNotifyPhoneExpr("co")} AS phone,
${companyNotifyEmailExpr("co")} AS email
FROM freight.companies co
${primaryContactUserJoin("co")}
WHERE co.id = $1 AND co.deleted_at IS NULL`,
[companyId],
);
return { phone: rows[0]?.phone ?? null, email: rows[0]?.email ?? null };
}

View File

@@ -52,8 +52,9 @@ export class SmsClientService implements OnApplicationBootstrap {
this.logger.log(
`SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' confirmed=${queued}`,
);
// Recipient + content are PII — debug only.
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
// Full content logged on purpose (audit of what actually left the API).
// Note: recipient + body are PII and land in plain application logs.
this.logger.log(`SMS payload to=${dto.to} text="${dto.message}"`);
return { queued };
}
@@ -81,7 +82,7 @@ export class SmsClientService implements OnApplicationBootstrap {
this.logger.log(
`BULK SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} confirmed=${queued}`,
);
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
this.logger.log(`BULK SMS payload messages=${JSON.stringify(messages)}`);
return { queued };
}

View File

@@ -11,7 +11,7 @@ import {
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@@ -65,10 +65,11 @@ export class BookingNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.companyId
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
: null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
// One resolver for both channels — the company row's own email column is
// only set for a Fayda-verified owner (see companyNotifyEmailExpr).
const { phone, email } = b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
if (phone) {
try {

View File

@@ -14,6 +14,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import {
companyNotifyEmailExpr,
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
@@ -649,7 +650,7 @@ export class BookingWindowService implements OnModuleInit {
`SELECT DISTINCT
c.company_id,
${companyNotifyPhoneExpr('co')} AS phone,
COALESCE(co.email, co.general_manager_email) AS email
${companyNotifyEmailExpr('co')} AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
ON c.id = cr.contract_id
@@ -761,7 +762,7 @@ export class BookingWindowService implements OnModuleInit {
b.id AS "bookingId",
b.company_id AS "companyId",
${companyNotifyPhoneExpr('co')} AS phone,
COALESCE(co.email, co.general_manager_email) AS email,
${companyNotifyEmailExpr('co')} AS email,
COALESCE(oy.label, oy.code) || ' to ' ||
COALESCE(dy.label, dy.code) AS corridor
FROM freight.bookings b