fix issue, add transit flow, fix cancellation

This commit is contained in:
Marshal
2026-08-28 22:13:49 +00:00
parent 3015de7508
commit 7e163088d9
50 changed files with 2787 additions and 337 deletions

View File

@@ -35,9 +35,24 @@ describe("isDomesticPhone", () => {
(phone) => expect(isDomesticPhone(phone)).toBe(true),
);
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
"rejects non-domestic or malformed %s",
(phone) => expect(isDomesticPhone(phone)).toBe(false),
// Djibouti is the line's other end: the gateway reaches its 77x mobiles.
it.each(["+25377123456", "25377123456", "77123456"])(
"accepts Djibouti mobile form %s",
(phone) => expect(isDomesticPhone(phone)).toBe(true),
);
it.each([
"+14155550123",
"+447911123456",
"0712345678",
"+2519866",
"12345",
// Djibouti fixed line (2x) — valid number, not a mobile the gateway serves.
"+25321350000",
// Right length, wrong Djibouti prefix.
"+25366123456",
])("rejects unreachable or malformed %s", (phone) =>
expect(isDomesticPhone(phone)).toBe(false),
);
});

View File

@@ -42,20 +42,42 @@ function normalizePhone(rawPhone: string): string {
if (digits.startsWith("+")) return digits;
const bare = digits.replace(/^0+/, "");
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
if (/^253\d{8}$/.test(digits)) return `+${digits}`;
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
// Djibouti mobiles are 8 digits starting 77 and have no trunk prefix, so a
// bare "77…" is unambiguous — it cannot be an Ethiopian local number, which
// is always 9 digits after the trunk zero.
if (/^77\d{6}$/.test(bare)) return `+253${bare}`;
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
// it looks like a full international number, else leave as typed.
return digits.length >= 11 ? `+${digits}` : raw;
}
/**
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
* the carrier integration is domestic-only, so a send to anything else is
* queued and silently lost. Callers use this to fall back to email instead of
* pretending an SMS is on its way.
* Mobile ranges the SMS gateway is contracted to reach, as E.164 patterns.
*
* The gateway itself is opaque from here — `SmsClientService` publishes to
* RabbitMQ and the carrier sits several hops downstream — so this list is a
* policy statement, not a capability probe: a number outside it is treated as
* unreachable and callers fall back to email rather than promising an SMS that
* would be queued and silently dropped.
*
* - Ethiopia: `+2519…` mobiles only. `+2517…` is deliberately absent; it parses
* as a valid ET number but is not a range this gateway delivers to.
* - Djibouti: `+25377…`, the country's only mobile range (2x is fixed-line).
*/
const REACHABLE_MOBILE_PATTERNS = [/^\+2519\d{8}$/, /^\+25377\d{6}$/];
/**
* Whether a phone sits in a mobile range the SMS gateway can actually reach.
*
* Named "domestic" for the Ethiopian-only era this predates; it now covers both
* countries the railway runs through. Callers use it to fall back to email
* instead of pretending an SMS is on its way.
*/
export function isDomesticPhone(rawPhone: string): boolean {
return /^\+2519\d{8}$/.test(normalizePhone(rawPhone));
const normalized = normalizePhone(rawPhone);
return REACHABLE_MOBILE_PATTERNS.some((p) => p.test(normalized));
}
/**
@@ -99,7 +121,7 @@ export class OtpService {
private readonly otpRepository: OtpRepository,
private readonly notifications: NotificationsService,
private readonly emailClient: EmailClientService,
) { }
) {}
// ---------------------------------------------------------------------------
// Generate OTP
@@ -197,8 +219,10 @@ export class OtpService {
for (const outcome of outcomes) {
this.logger.log(
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
} latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
outcome.queued
} latencyMs=${Date.now() - startedAt}${
outcome.error ? ` error=${outcome.error}` : ""
}`,
);
}
@@ -222,7 +246,8 @@ export class OtpService {
// user who never receives a code — indistinguishable from carrier loss,
// and the misleading success response makes it look like our side worked.
this.logger.error(
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
process.env.RABBITMQ_ENABLED ?? "unset"
} — no transport reported hand-off; no code will arrive for this send`,
);
}
@@ -247,7 +272,8 @@ export class OtpService {
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
this.logger.error(
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
Date.now() - startedAt
}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
@@ -330,8 +356,9 @@ export class OtpService {
) {
const line = `otp.verify channels=${channelsOf(target).join(
"+",
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
}`;
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
detail ? ` ${detail}` : ""
}`;
if (result === "ok") this.logger.log(line);
else this.logger.warn(line);