diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 1e0908da6..c1b890719 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -82,8 +82,8 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder"; // import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; // import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; -import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; -import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; +// import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; +// import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; @@ -281,8 +281,8 @@ if (!process.env.APPLICATION_NAME) { // WarehouseDemoSeeder, // ExportDjiboutiInterchangeDemoSeeder, // MarshallingDemoTrainsSeeder, - ApprovedFirstLastMileDemoBookingsSeeder, - PaidImportExportMileDemoSeeder, + // ApprovedFirstLastMileDemoBookingsSeeder, + // PaidImportExportMileDemoSeeder, LoginAudienceMiddleware, // Feeds position-TYPE grants to the synchronous permission checks — without // it, staff whose permissions live on their position type resolve to none. diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 90b020472..eff15ec3d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -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 { 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 { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index c96c54f9b..2fb9b3032 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -92,6 +92,7 @@ interface CarriageAcceptanceWagonRow { interface CarriageAcceptanceReceivedRow { allocatedWeightTons: string | null; containerNumbers: string | null; + sealNumbers?: string | null; } const URGENT_PRIORITY_THRESHOLD = 1000; @@ -291,15 +292,19 @@ export class BookingsService { if (pendingWagons) { // Direct truck-to-train cargo never enters the warehouse, so there is no // GRN'd inventory to build the sheet from. Choosing direct handover is - // itself the acceptance, so the sheet issues off the booking's own - // containers (or its VGM weight when the cargo is bulk). + // itself the acceptance, so the sheet issues off the containers the + // customer declared on the booking — freight.containers only gains rows at + // allocation, by which point the wagon query above already serves. const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport ? await this.dataSource.query( `SELECT NULL::numeric AS "allocatedWeightTons", - c.container_number AS "containerNumbers" - FROM freight.containers c - WHERE c.booking_id = $1 AND c.deleted_at IS NULL - ORDER BY c.container_number`, + unit.container_number AS "containerNumbers", + unit.seal_number AS "sealNumbers" + FROM freight.booking_container_units unit + JOIN freight.booking_container line + ON line.id = unit.booking_container_id AND line.deleted_at IS NULL + WHERE line.booking_id = $1 AND unit.deleted_at IS NULL + ORDER BY unit.container_number`, [bookingId], ) : booking.tradeDirection === 'EXPORT' @@ -319,11 +324,13 @@ export class BookingsService { ) : []; // Bulk direct cargo has no containers — one line carrying the booking's - // declared weight still makes a valid sheet. + // declared weight still makes a valid sheet. bulkTotalWeightTons only + // holds the real tonnage for PER_ITEM break-bulk; everywhere else (PER_TON + // bulk and every container booking) the VGM column is the weight. if (isDirectExport && receivedLines.length === 0) { + const totalWeight = booking.bulkTotalWeightTons ?? booking.cargoTotalWeightVgm; receivedLines.push({ - allocatedWeightTons: - booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons), + allocatedWeightTons: totalWeight == null ? null : String(totalWeight), containerNumbers: null, }); } @@ -347,7 +354,7 @@ export class BookingsService { marshalledAt: null, arrivalAt: null, containerNumbers: row.containerNumbers, - sealNumbers: null, + sealNumbers: row.sealNumbers ?? null, })); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 599187b22..4a53915ec 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -147,6 +147,10 @@ function makeService(overrides: Partial = {}) { 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 = {}) { 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 2cddb2974..72dcfdded 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 178ace492..6bcd21f08 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -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 { - 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 { diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts index d41e61e8e..ae4a73a77 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -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), diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index e3c79a742..477724839 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -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 { 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 { diff --git a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts index 20a43d061..52dd7802e 100644 --- a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts +++ b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index 9f121e18a..e3b2fd80a 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -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 { 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`, diff --git a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts index 511f3cf8c..886aa8b85 100644 --- a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts @@ -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 { - 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 }; } diff --git a/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts b/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts index 43e47f350..1c4f3adba 100644 --- a/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 8668b4f75..bf6ab5069 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -95,6 +95,20 @@ export class BookingJourneyService { await manager .getRepository(TrainScheduleBooking) .update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' }); + // Warehouse cargo may be loaded either from the warehouse Load-to-Train + // queue or from the schedule itself. Loading here must move its inventory + // too, otherwise the goods read as still sitting in the shed while the + // train leaves with them. No-ops for direct truck-to-train (no inventory). + // ponytail: no WarehouseLoading record on this path — those are only read + // back as per-inventory loading history, never billed. Create them here if + // that history ever has to be complete. + await manager.query( + `UPDATE freight.warehouse_inventory + SET status = 'LOADED', loaded_at = COALESCE(loaded_at, $2), updated_at = NOW() + WHERE booking_id = $1 AND deleted_at IS NULL + AND status NOT IN ('LOADED', 'DISPATCHED')`, + [bookingId, now], + ); // The facility handed the cargo over — raise its GRN. No-ops for yards // without a facility (import/export terminals), which keep their own flow. await this.facilityHandling.recordHandling(manager, { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index efb540066..9468387c5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -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 { 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 { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 5036b01c1..47d0a0d21 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -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 diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4f13ec6e0..8e609a26e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -46,12 +46,7 @@ import PaymentsPage from "./pages/payments/PaymentsPage"; import AuditLogsPage from "./pages/audit/AuditLogsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; -import { - FREIGHT_PERMS, - isDjiboutiGl, - isEthiopianGl, - isSuperAdmin, -} from "./lib/permissions"; +import { FREIGHT_PERMS } from "./lib/permissions"; import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing"; import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; @@ -92,7 +87,6 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage"; import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"; import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; -import EDRLastMileReturnsPage from "./pages/warehouses/EDRLastMileReturnsPage"; import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; @@ -119,11 +113,8 @@ import SupportInboxPage from "./pages/support/SupportInboxPage"; import { APP_TITLE, buildSidebarSections, - DJ_CLEARANCE_HREF, - ET_CLEARANCE_HREF, filterSidebarByPermission, findActiveSidebarLabel, - GL_WORKFLOW_PATH_PATTERNS, } from "@/components/layout/sidebar-sections"; const DashboardShell = () => { @@ -139,18 +130,6 @@ const DashboardShell = () => { ); const displayName = user?.name?.en || user?.username || user?.email || "User"; - // GL positions are locked to their single clearance page: if they navigate - // (or deep-link) anywhere else, send them back to their clearance hub. - // Super admin is exempt. Allow the clearance path + its detail sub-routes. - const superAdmin = isSuperAdmin(user); - const glClearanceHome = !superAdmin - ? isEthiopianGl(user) - ? ET_CLEARANCE_HREF - : isDjiboutiGl(user) - ? DJ_CLEARANCE_HREF - : null - : null; - useEffect(() => { const activeLabel = findActiveSidebarLabel( location.pathname, @@ -159,20 +138,9 @@ const DashboardShell = () => { document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE; }, [location.pathname, sidebarSections]); - if ( - glClearanceHome && - !location.pathname.startsWith(glClearanceHome) && - !GL_WORKFLOW_PATH_PATTERNS.some((re) => re.test(location.pathname)) - ) { - return ; - } - return ( { } /> } /> } /> - } /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index e0bf818ff..cfbc50528 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -344,12 +344,6 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] icon: , permission: FREIGHT_PERMS.warehouseInventory.view, }, - { - label: "EDR Last Mile Returns", - href: "/dashboard/edr-last-mile-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, { label: "Container Returns", href: "/dashboard/container-returns", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/EDRLastMileReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/EDRLastMileReturnsPage.tsx deleted file mode 100644 index 4c38841f8..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/EDRLastMileReturnsPage.tsx +++ /dev/null @@ -1,406 +0,0 @@ -import { Fragment, useMemo, useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ActionIcon, - Alert, - Badge, - Button, - Group, - Loader, - Modal, - Stack, - Table, - Text, - TextInput, - Textarea, - Select, - Checkbox, -} from "@mantine/core"; -import { ChevronDown, ChevronRight } from "lucide-react"; - -import { PageContainer, PageHeader } from "@/components/page"; -import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; -import { useListControls } from "@/hooks/useListControls"; -import { useToast } from "@/hooks/use-toast"; -import { api } from "@/services/api"; -import { warehouseService } from "@/services/warehouse.service"; -import { importOperationsService } from "@/services/importOperations.service"; - -interface ReturnContainer { - containerNumber: string; - size: string | null; - type: string | null; - selected: boolean; -} - -interface TruckReturn { - key: string; - plate: string; - companyName: string | null; - bookingRef: string; - bookingId: string; - customerId: string | null; - containers: ReturnContainer[]; -} - - -export default function EDRLastMileReturnsPage() { - const { toast } = useToast(); - const qc = useQueryClient(); - const [expanded, setExpanded] = useState(null); - const [returnModalOpen, setReturnModalOpen] = useState(false); - const [activeKey, setActiveKey] = useState(null); - - const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({ - queryKey: ["import-unloaded-queue"], - queryFn: async () => { - const response = await api.warehouses.importUnloadedQueue.call(); - return response ?? []; - }, - }); - - const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[]; - const truckReturnsQuery = useQuery({ - queryKey: ["edr-last-mile-returns", bookingIds], - queryFn: async () => { - const grouped = new Map(); - - for (const item of unloadedQueue) { - if (!item.bookingId) continue; - - const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []); - for (const truck of edrTrucks) { - const inventory = await api.warehouses.listInventory.call({ filter: { bookingId: item.bookingId } }).catch(() => []); - - const returnContainers = inventory - .filter((inv: any) => inv.isReturn) - .map((inv: any) => ({ - containerNumber: inv.containerNumber || "—", - size: inv.containerSize || null, - type: inv.containerType || null, - selected: false, - })); - - if (returnContainers.length > 0) { - const key = `${item.bookingId}-${truck.vehicleId}`; - grouped.set(key, { - key, - plate: [truck.truckPlateNumber, truck.trailerPlateNumber].filter(Boolean).join(" + ") || "—", - companyName: item.customerName ?? null, - bookingRef: item.bookingReference ?? item.bookingId, - bookingId: item.bookingId, - customerId: item.customerId || null, - containers: returnContainers, - }); - } - } - } - - return Array.from(grouped.values()); - }, - enabled: bookingIds.length > 0 && !queueLoading, - }); - - const trucksWithReturns = useMemo(() => truckReturnsQuery.data ?? [], [truckReturnsQuery.data]); - const controls = useListControls(trucksWithReturns, { - searchKeys: ["plate", "companyName", "bookingRef"], - }); - - const createReturnsMutation = useMutation({ - mutationFn: async (payload: { trucks: Array<{ bookingId: string; customerId: string | null; containers: Array<{ containerNumber: string; returnDate: string; facility: string; yard?: string; zone?: string; condition?: string; handoverNote?: string }> }> }) => { - const results = []; - for (const truck of payload.trucks) { - for (const container of truck.containers) { - const result = await importOperationsService.createEmptyReturn({ - containerNumber: container.containerNumber, - returnDate: new Date(container.returnDate).toISOString(), - bookingId: truck.bookingId, - customerId: truck.customerId ?? undefined, - facility: container.facility, - yard: container.yard, - zone: container.zone, - condition: container.condition, - handoverNote: container.handoverNote, - }); - results.push(result); - } - } - return results; - }, - onSuccess: () => { - toast({ title: "Empty container returns recorded" }); - qc.invalidateQueries({ queryKey: ["edr-last-mile-returns", bookingIds] }); - setReturnModalOpen(false); - setActiveKey(null); - }, - onError: (error: any) => { - toast({ - variant: "destructive", - title: "Failed to record returns", - description: error?.response?.data?.message || error?.message, - }); - }, - }); - - const activeTruck = activeKey ? trucksWithReturns.find(t => t.key === activeKey) ?? null : null; - - if (queueLoading || truckReturnsQuery.isLoading) { - return ( - - - - - - ); - } - - return ( - - - - {trucksWithReturns.length === 0 ? ( - No EDR trucks with return containers found. - ) : ( - <> - - - - - - Plate - Company - Booking Ref - Return Containers - Actions - - - - {controls.pagedRows.map((truck) => { - const isOpen = expanded === truck.key; - return ( - - - - setExpanded(isOpen ? null : truck.key)} - > - {isOpen ? : } - - - - {truck.plate} - - {truck.companyName ?? "—"} - {truck.bookingRef} - - {truck.containers.length} container{truck.containers.length !== 1 ? "s" : ""} - - - - - - {isOpen && ( - - -
- - - - - - Container - Size - Type - - - - {truck.containers.map((container, idx) => ( - - - - - {container.containerNumber} - {container.size ?? "—"} - {container.type ?? "—"} - - ))} - -
- - - )} - - ); - })} - - -
- - - )} - - setReturnModalOpen(false)} - truck={activeTruck} - onSubmit={(payload) => createReturnsMutation.mutate(payload)} - loading={createReturnsMutation.isPending} - /> -
- ); -} - -interface EmptyContainerReturnModalProps { - opened: boolean; - onClose: () => void; - truck: TruckReturn | null; - onSubmit: (payload: any) => void; - loading: boolean; -} - -function EmptyContainerReturnModal({ opened, onClose, truck, onSubmit, loading }: EmptyContainerReturnModalProps) { - const [selectedContainers, setSelectedContainers] = useState([]); - const [returnDate, setReturnDate] = useState(new Date().toISOString().split("T")[0]); - const [warehouse, setWarehouse] = useState(null); - const [condition, setCondition] = useState(""); - const [handoverNote, setHandoverNote] = useState(""); - - const { data: warehousesResponse } = useQuery({ - queryKey: ["warehouses-list"], - queryFn: async () => { - return await warehouseService.list({}); - }, - }); - - const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? []; - const warehouseOptions = Array.isArray(warehouses) ? warehouses.map((wh: any) => ({ - value: wh.id, - label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`, - })) : []; - - const selectedWarehouse = warehouse && Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null; - - const handleSubmit = () => { - if (!truck || !selectedContainers.length || !warehouse) return; - - const containers = truck.containers - .filter((c) => selectedContainers.includes(c.containerNumber)) - .map((c) => ({ - containerNumber: c.containerNumber, - returnDate, - facility: selectedWarehouse?.name || warehouse, - yard: selectedWarehouse?.code || undefined, - zone: undefined, - condition: condition || undefined, - handoverNote: handoverNote || undefined, - })); - - onSubmit({ - trucks: [{ - bookingId: truck.bookingId, - customerId: truck.customerId, - containers, - }], - }); - }; - - return ( - - {truck && ( - - - {truck.plate} - {truck.bookingRef} - - -
- Select containers to return: - - {truck.containers.map((container) => ( - { - if (e.currentTarget.checked) { - setSelectedContainers([...selectedContainers, container.containerNumber]); - } else { - setSelectedContainers(selectedContainers.filter(c => c !== container.containerNumber)); - } - }} - /> - ))} - -
- -