fix: gm and poa email requirement

This commit is contained in:
Nathnael
2026-08-10 06:46:25 +00:00
parent f6dac28ac0
commit 3d53f67156
16 changed files with 556 additions and 88 deletions

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 };
}