From 00dbc1a3441df1d9dad1e23f5a34eb0bf103e5c8 Mon Sep 17 00:00:00 2001 From: Malek Salem Date: Sat, 4 Jul 2026 00:12:06 +0100 Subject: [PATCH 01/55] fix(email-campaigns): use p_user_id arg in get_contacts_table RPC The June 3 migration (20260603120000_drop_person_email_use_persons_id.sql) renamed private.get_contacts_table's argument from user_id to p_user_id, but the email-campaigns edge function still passes the old name. PostgREST 14 returns PGRST202 (Could not find the function) on /preview, /sender-options and /create, breaking campaign creation in QA. Changes the call to use p_user_id. getContactsByEmails already uses the correct name. Adds two regression tests that read the source and assert the RPC arg keys. --- supabase/functions/email-campaigns/index.ts | 11 ++- .../email-campaigns/sender-options.test.ts | 74 ++++++++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/supabase/functions/email-campaigns/index.ts b/supabase/functions/email-campaigns/index.ts index f3a0815c3..633ccec3c 100644 --- a/supabase/functions/email-campaigns/index.ts +++ b/supabase/functions/email-campaigns/index.ts @@ -888,7 +888,7 @@ async function getSelectedContacts( const { data, error } = await supabaseAdmin .schema("private") .rpc("get_contacts_table", { - user_id: userId, + p_user_id: userId, }); if (error) { @@ -949,7 +949,9 @@ async function getContactsByEmails( .in("email", normalizedEmails); if (personErr) { - throw new Error(`Unable to fetch contacts for campaign: ${personErr.message}`); + throw new Error( + `Unable to fetch contacts for campaign: ${personErr.message}`, + ); } const ids = (personRows ?? []).map((r) => r.id as string); @@ -2448,7 +2450,10 @@ app.post("/email-sending-request", authMiddleware, async (c: Context) => { if ("error" in auth) return auth.error; const body = await c.req.json().catch(() => ({})); - const parsed = z.object({ contactsCount: z.number().int().positive() }).strict().safeParse(body); + const parsed = z + .object({ contactsCount: z.number().int().positive() }) + .strict() + .safeParse(body); if (!parsed.success) { return c.json(validationErrorBody(parsed.error), 400); } diff --git a/supabase/functions/email-campaigns/sender-options.test.ts b/supabase/functions/email-campaigns/sender-options.test.ts index df15af021..4457cc886 100644 --- a/supabase/functions/email-campaigns/sender-options.test.ts +++ b/supabase/functions/email-campaigns/sender-options.test.ts @@ -1,4 +1,7 @@ -import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { + assertEquals, + assertExists, +} from "https://deno.land/std@0.224.0/assert/mod.ts"; import { getSenderCredentialIssue, listUniqueSenderSources, @@ -32,3 +35,72 @@ Deno.test("getSenderCredentialIssue flags expired OAuth token", () => { "OAuth token expired. Please reconnect this account in sources.", ); }); + +// Regression test for Fix 1: the `get_contacts_table` RPC must be invoked +// with the parameter name `p_user_id` (matching the Supabase function +// signature). Using the bare `user_id` key would cause a PGRST202 error +// at runtime ("Could not find the function ... with parameters ... types +// ..."). This test reads the source of `index.ts` to assert the call +// site uses `p_user_id:`, protecting against future regressions of the +// exact bug fixed in this lane. +Deno.test( + "get_contacts_table RPC is invoked with p_user_id (Fix 1 regression)", + async () => { + const source = await Deno.readTextFile( + new URL("./index.ts", import.meta.url), + ); + const callMatch = source.match( + /\.rpc\(\s*["']get_contacts_table["']\s*,\s*\{([^}]*)\}\s*\)/, + ); + assertExists( + callMatch, + 'Expected to find `.rpc("get_contacts_table", { ... })` in index.ts', + ); + const args = callMatch[1].trim(); + // The argument object must use the `p_` prefix. + assertEquals( + args.startsWith("p_user_id:"), + true, + `get_contacts_table RPC must use p_user_id: as the argument name (got: ${args})`, + ); + // Guard against a regression that uses the bare `user_id:` key + // (which would be a substring match inside `p_user_id:`, so we + // require a non-identifier char before `user_id`). + assertEquals( + /(^|[^_a-zA-Z])user_id\s*:/.test(args), + false, + `get_contacts_table RPC must not use bare user_id: (got: ${args})`, + ); + }, +); + +// Same regression guard for the second RPC call site (`get_contacts_table_by_ids`), +// which has the same parameter-naming requirement. +Deno.test( + "get_contacts_table_by_ids RPC is invoked with p_user_id (Fix 1 regression)", + async () => { + const source = await Deno.readTextFile( + new URL("./index.ts", import.meta.url), + ); + const callMatch = source.match( + /\.rpc\(\s*["']get_contacts_table_by_ids["']\s*,\s*\{([\s\S]*?)\}\s*\)/, + ); + assertExists( + callMatch, + 'Expected to find `.rpc("get_contacts_table_by_ids", { ... })` in index.ts', + ); + const args = callMatch[1]; + assertEquals( + /p_user_id\s*:/.test(args), + true, + `get_contacts_table_by_ids RPC must include p_user_id: parameter (got: ${args})`, + ); + // Guard against a regression that uses the bare `user_id:` key + // at the start of a line. + assertEquals( + /(^|\n)\s*user_id\s*:/m.test(args), + false, + `get_contacts_table_by_ids RPC must not use bare user_id: (got: ${args})`, + ); + }, +); From 37eedf7ebfcc0433cd6bbdcadc28ae97743152c7 Mon Sep 17 00:00:00 2001 From: Malek Salem Date: Sat, 4 Jul 2026 00:12:25 +0100 Subject: [PATCH 02/55] fix(supabase): re-apply missing sms_campaigns columns QA's private.sms_campaigns table is missing footer_text_template and twilio_fallback_enabled columns even though the migrations 20260317000009_add_sms_footer_and_personalization.sql and 202603170006_add_sms_provider_profile_config.sql are recorded as applied in supabase_migrations.schema_migrations. Without the columns, campaign insert fails with PGRST204 (Could not find the 'footer_text_template' column). The new migration is idempotent (ADD COLUMN IF NOT EXISTS) so it's a no-op on environments that already have the columns. --- .../migrations/20260703223711_reapply_missing_sms_columns.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 supabase/migrations/20260703223711_reapply_missing_sms_columns.sql diff --git a/supabase/migrations/20260703223711_reapply_missing_sms_columns.sql b/supabase/migrations/20260703223711_reapply_missing_sms_columns.sql new file mode 100644 index 000000000..5cb92d072 --- /dev/null +++ b/supabase/migrations/20260703223711_reapply_missing_sms_columns.sql @@ -0,0 +1,4 @@ +-- Re-apply ALTER TABLE statements that were recorded as applied but didn't actually run on some environments +ALTER TABLE private.sms_campaigns + ADD COLUMN IF NOT EXISTS footer_text_template TEXT, + ADD COLUMN IF NOT EXISTS twilio_fallback_enabled BOOLEAN NOT NULL DEFAULT false; From a60fadd8023ba53d96add5b69e37c6a5b11c3672 Mon Sep 17 00:00:00 2001 From: Malek Salem Date: Sat, 4 Jul 2026 00:12:35 +0100 Subject: [PATCH 03/55] feat(sms-campaigns): auto-discover simple-sms-gateway API spec The leadminer SimpleSmsGatewayProvider hardcoded { phone, message } as the SMS request body. Different SMS gateway apps (including the iOS app many users install) expect different field names (to, number, mobile). Without spec discovery, leadminer sends an unsupported body shape and the SMS never gets sent. Adds utils/gateway-spec.ts that: - Fetches OpenAPI spec from /swagger.json, /openapi.json, /api-docs, /docs/openapi.json, /v1/openapi.json, /spec.json in order - Extracts the SMS endpoint path and the phone/message field names - Builds the correct request body for the discovered schema - Falls back to { phone, message } when no spec is found The provider now accepts an optional bodySchema and uses buildSmsBody to construct the request. --- .../simple-sms-gateway-provider.test.ts | 54 +++ .../providers/simple-sms-gateway-provider.ts | 20 +- .../sms-campaigns/utils/gateway-spec.test.ts | 392 +++++++++++++++++ .../sms-campaigns/utils/gateway-spec.ts | 405 ++++++++++++++++++ 4 files changed, 866 insertions(+), 5 deletions(-) create mode 100644 supabase/functions/sms-campaigns/utils/gateway-spec.test.ts create mode 100644 supabase/functions/sms-campaigns/utils/gateway-spec.ts diff --git a/supabase/functions/sms-campaigns/providers/simple-sms-gateway-provider.test.ts b/supabase/functions/sms-campaigns/providers/simple-sms-gateway-provider.test.ts index f0e1b4193..d42ac2532 100644 --- a/supabase/functions/sms-campaigns/providers/simple-sms-gateway-provider.test.ts +++ b/supabase/functions/sms-campaigns/providers/simple-sms-gateway-provider.test.ts @@ -3,6 +3,7 @@ import { assertThrows, } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { createSmsProvider, type SimpleSmsGatewayCredentials } from "./mod.ts"; +import type { DiscoveredSmsSchema } from "../utils/gateway-spec.ts"; Deno.test("createSmsProvider creates simple-sms-gateway provider", () => { const provider = createSmsProvider("simple-sms-gateway", { @@ -58,3 +59,56 @@ Deno.test( } }, ); + +Deno.test( + "simple-sms-gateway provider uses discovered body schema when provided", + async () => { + const originalFetch = globalThis.fetch; + let capturedBody: unknown = null; + + globalThis.fetch = (async ( + _input: RequestInfo | URL, + init?: RequestInit, + ) => { + capturedBody = init?.body; + return new Response(JSON.stringify({ id: "msg_discovered" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + const discovered: DiscoveredSmsSchema = { + endpoint: "/send-sms", + phoneField: "to", + messageField: "text", + method: "POST", + requiredFields: ["to", "text"], + }; + + try { + const provider = createSmsProvider("simple-sms-gateway", { + simpleSmsGateway: { + baseUrl: "https://gateway.example.com", + bodySchema: discovered, + }, + }); + + const result = await provider.send({ + to: "+33612345678", + from: "Leadminer", + body: "Hello", + }); + + assertEquals(result.success, true); + assertEquals(result.messageId, "msg_discovered"); + const body = JSON.parse(String(capturedBody)) as Record; + // Discovered field names, not the legacy defaults. + assertEquals(body.to, "+33612345678"); + assertEquals(body.text, "Hello"); + assertEquals(body.phone, undefined); + assertEquals(body.message, undefined); + } finally { + globalThis.fetch = originalFetch; + } + }, +); diff --git a/supabase/functions/sms-campaigns/providers/simple-sms-gateway-provider.ts b/supabase/functions/sms-campaigns/providers/simple-sms-gateway-provider.ts index 11b80f10e..cc9d263b9 100644 --- a/supabase/functions/sms-campaigns/providers/simple-sms-gateway-provider.ts +++ b/supabase/functions/sms-campaigns/providers/simple-sms-gateway-provider.ts @@ -1,12 +1,23 @@ -import type { SmsProvider, SendSmsParams, SendSmsResult } from "./types.ts"; +import type { SendSmsParams, SendSmsResult, SmsProvider } from "./types.ts"; +import { + buildSmsBody, + type DiscoveredSmsSchema, +} from "../utils/gateway-spec.ts"; export interface SimpleSmsGatewayCredentials { baseUrl: string; + /** + * Optional auto-discovered request body schema. When provided, the + * provider uses the gateway's expected JSON field names instead of + * the hard-coded `{ phone, message }` shape. + */ + bodySchema?: DiscoveredSmsSchema | null; } export class SimpleSmsGatewayProvider implements SmsProvider { name = "simple-sms-gateway"; private baseUrl: string; + private bodySchema: DiscoveredSmsSchema | null; constructor(credentials: SimpleSmsGatewayCredentials) { if (!credentials.baseUrl) { @@ -14,10 +25,12 @@ export class SimpleSmsGatewayProvider implements SmsProvider { } this.baseUrl = credentials.baseUrl; + this.bodySchema = credentials.bodySchema ?? null; } async send(params: SendSmsParams): Promise { const url = this.baseUrl; + const body = buildSmsBody(this.bodySchema, params.to, params.body); try { const response = await fetch(url, { @@ -25,10 +38,7 @@ export class SimpleSmsGatewayProvider implements SmsProvider { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ - phone: params.to, - message: params.body, - }), + body: JSON.stringify(body), signal: AbortSignal.timeout(15000), }); diff --git a/supabase/functions/sms-campaigns/utils/gateway-spec.test.ts b/supabase/functions/sms-campaigns/utils/gateway-spec.test.ts new file mode 100644 index 000000000..055488d13 --- /dev/null +++ b/supabase/functions/sms-campaigns/utils/gateway-spec.test.ts @@ -0,0 +1,392 @@ +import { + assertEquals, + assertExists, + assertNotEquals, +} from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { + buildSmsBody, + type DiscoveredSmsSchema, + discoverGatewaySpec, + extractSmsRequestSchema, + testGatewayReachability, +} from "./gateway-spec.ts"; + +/** + * Minimal OpenAPI 3.0 spec with a /send-sms endpoint that uses + * `{ to, message }` field names. + */ +const SWAGGER_SPEC_SEND_SMS = { + openapi: "3.0.0", + info: { title: "Test Gateway", version: "1.0.0" }, + paths: { + "/send-sms": { + post: { + summary: "Send an SMS", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["to", "message"], + properties: { + to: { type: "string" }, + message: { type: "string" }, + }, + }, + }, + }, + }, + responses: { "200": { description: "OK" } }, + }, + }, + }, +}; + +/** + * Minimal OpenAPI 3.0 spec with a /messages endpoint that uses + * `{ phone, text }` field names. + */ +const SWAGGER_SPEC_MESSAGES_PHONE = { + openapi: "3.0.0", + info: { title: "Test Gateway", version: "1.0.0" }, + paths: { + "/messages": { + post: { + summary: "Send a message", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["phone", "text"], + properties: { + phone: { type: "string" }, + text: { type: "string" }, + }, + }, + }, + }, + }, + responses: { "200": { description: "OK" } }, + }, + }, + }, +}; + +/** + * Stub the global fetch with a sequence of responses. Each call to + * fetch consumes the next response in the list. Once exhausted, the + * final response is repeated. + */ +function stubFetch( + responses: Array<{ + status: number; + body?: string; + contentType?: string; + }>, +): { restore: () => void } { + const original = globalThis.fetch; + let callIndex = 0; + + globalThis.fetch = (async ( + _input: RequestInfo | URL, + _init?: RequestInit, + ) => { + const index = + callIndex < responses.length ? callIndex : responses.length - 1; + callIndex += 1; + const spec = responses[index]; + if (!spec) { + return new Response("not found", { status: 404 }); + } + const headers: Record = {}; + if (spec.contentType) { + headers["content-type"] = spec.contentType; + } + return new Response(spec.body ?? "", { + status: spec.status, + headers, + }); + }) as typeof fetch; + + return { + restore: () => { + globalThis.fetch = original; + }, + }; +} + +Deno.test( + "discoverGatewaySpec returns parsed spec when /swagger.json exists", + async () => { + const stub = stubFetch([ + { + status: 200, + contentType: "application/json", + body: JSON.stringify(SWAGGER_SPEC_SEND_SMS), + }, + ]); + try { + const spec = await discoverGatewaySpec("http://gateway.example.com"); + assertExists(spec); + assertEquals(typeof spec, "object"); + const paths = (spec as { paths?: Record }).paths; + assertExists(paths); + assertExists(paths["/send-sms"]); + } finally { + stub.restore(); + } + }, +); + +Deno.test( + "discoverGatewaySpec returns null when no spec is found", + async () => { + const stub = stubFetch([ + { status: 404, body: "not found" }, + { status: 404, body: "not found" }, + { status: 404, body: "not found" }, + { status: 404, body: "not found" }, + { status: 404, body: "not found" }, + { status: 404, body: "not found" }, + ]); + try { + const spec = await discoverGatewaySpec("http://no-spec.example.com"); + assertEquals(spec, null); + } finally { + stub.restore(); + } + }, +); + +Deno.test( + "discoverGatewaySpec skips malformed JSON and continues", + async () => { + const stub = stubFetch([ + { status: 200, contentType: "application/json", body: "not json {" }, + { + status: 200, + contentType: "application/json", + body: JSON.stringify(SWAGGER_SPEC_SEND_SMS), + }, + ]); + try { + const spec = await discoverGatewaySpec("http://gateway.example.com"); + assertExists(spec); + } finally { + stub.restore(); + } + }, +); + +Deno.test("discoverGatewaySpec returns null for empty baseUrl", async () => { + const spec = await discoverGatewaySpec(""); + assertEquals(spec, null); +}); + +Deno.test( + "extractSmsRequestSchema finds /send-sms endpoint with `to` field", + () => { + const schema = extractSmsRequestSchema(SWAGGER_SPEC_SEND_SMS); + assertExists(schema); + assertEquals(schema.endpoint, "/send-sms"); + assertEquals(schema.phoneField, "to"); + assertEquals(schema.messageField, "message"); + assertEquals(schema.method, "POST"); + assertEquals(schema.requiredFields.includes("to"), true); + assertEquals(schema.requiredFields.includes("message"), true); + }, +); + +Deno.test( + "extractSmsRequestSchema finds /messages endpoint with `phone` field", + () => { + const schema = extractSmsRequestSchema(SWAGGER_SPEC_MESSAGES_PHONE); + assertExists(schema); + assertEquals(schema.endpoint, "/messages"); + assertEquals(schema.phoneField, "phone"); + assertEquals(schema.messageField, "text"); + assertEquals(schema.method, "POST"); + }, +); + +Deno.test("extractSmsRequestSchema returns null for empty spec", () => { + const schema = extractSmsRequestSchema(null); + assertEquals(schema, null); + + const schema2 = extractSmsRequestSchema({}); + assertEquals(schema2, null); + + const schema3 = extractSmsRequestSchema({ openapi: "3.0.0", paths: {} }); + assertEquals(schema3, null); +}); + +Deno.test( + "extractSmsRequestSchema returns null when required fields are missing", + () => { + const spec = { + openapi: "3.0.0", + paths: { + "/send-sms": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + unrelatedField: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const schema = extractSmsRequestSchema(spec); + assertEquals(schema, null); + }, +); + +Deno.test("buildSmsBody uses schema fields when provided", () => { + const schema: DiscoveredSmsSchema = { + endpoint: "/send-sms", + phoneField: "to", + messageField: "text", + method: "POST", + requiredFields: ["to", "text"], + }; + const body = buildSmsBody(schema, "+33612345678", "hello"); + assertEquals(body.to, "+33612345678"); + assertEquals(body.text, "hello"); + // Should not include the legacy `phone` or `message` keys. + assertEquals((body as Record).phone, undefined); + assertEquals((body as Record).message, undefined); +}); + +Deno.test( + "buildSmsBody falls back to { phone, message } when schema is null", + () => { + const body = buildSmsBody(null, "+33612345678", "hello"); + assertEquals(body.phone, "+33612345678"); + assertEquals(body.message, "hello"); + }, +); + +Deno.test( + "buildSmsBody falls back to { phone, message } when spec has no body schema", + () => { + // A spec with a path and a POST operation but NO `requestBody` field. + // `extractSmsRequestSchema` must return `null` (no body schema to + // extract field names from), and the caller must then fall back to + // the legacy `{ phone, message }` shape via `buildSmsBody`. + const specWithoutBody = { + openapi: "3.0.0", + info: { title: "Test Gateway", version: "1.0.0" }, + paths: { + "/send-sms": { + post: { + summary: "Send an SMS (no body schema declared)", + responses: { "200": { description: "OK" } }, + }, + }, + }, + }; + const schema = extractSmsRequestSchema(specWithoutBody); + assertEquals( + schema, + null, + "Spec without a requestBody must yield a null schema", + ); + + // The downstream `buildSmsBody` call receives that null and must + // fall back to the legacy `{ phone, message }` shape. + const body = buildSmsBody(schema, "+21697522154", "hello"); + assertEquals(body.phone, "+21697522154"); + assertEquals(body.message, "hello"); + }, +); + +Deno.test("testGatewayReachability reports success on 200", async () => { + const stub = stubFetch([ + { status: 200, contentType: "application/json", body: "ok" }, + ]); + try { + const result = await testGatewayReachability( + "http://gateway.example.com", + null, + 1000, + ); + assertEquals(result.success, true); + assertNotEquals(result.message.indexOf("reachable"), -1); + } finally { + stub.restore(); + } +}); + +Deno.test( + "testGatewayReachability reports success on 4xx (server alive)", + async () => { + const stub = stubFetch([ + { status: 422, contentType: "application/json", body: "bad" }, + ]); + try { + const result = await testGatewayReachability( + "http://gateway.example.com", + null, + 1000, + ); + assertEquals(result.success, true); + } finally { + stub.restore(); + } + }, +); + +Deno.test("testGatewayReachability reports failure on 5xx", async () => { + const stub = stubFetch([ + { status: 503, contentType: "application/json", body: "down" }, + ]); + try { + const result = await testGatewayReachability( + "http://gateway.example.com", + null, + 1000, + ); + assertEquals(result.success, false); + } finally { + stub.restore(); + } +}); + +Deno.test( + "testGatewayReachability reports failure on network error", + async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => { + throw new TypeError("connection refused"); + }) as typeof fetch; + try { + const result = await testGatewayReachability( + "http://unreachable.example.com", + null, + 1000, + ); + assertEquals(result.success, false); + } finally { + globalThis.fetch = original; + } + }, +); + +Deno.test( + "testGatewayReachability reports failure on missing URL", + async () => { + const result = await testGatewayReachability("", null, 1000); + assertEquals(result.success, false); + }, +); diff --git a/supabase/functions/sms-campaigns/utils/gateway-spec.ts b/supabase/functions/sms-campaigns/utils/gateway-spec.ts new file mode 100644 index 000000000..d514e562b --- /dev/null +++ b/supabase/functions/sms-campaigns/utils/gateway-spec.ts @@ -0,0 +1,405 @@ +/** + * SMS Gateway API spec discovery. + * + * Many SMS gateway apps (e.g. the popular "Simple SMS Gateway" iOS/Android + * app) accept their request body in different shapes — some use + * `{ phone, message }`, others `{ to, text }`, others `{ number, body }`. + * Hard-coding a single field name silently breaks every other gateway. + * + * This module auto-discovers the gateway's OpenAPI/Swagger spec from common + * paths, extracts the SMS request body schema, and provides helpers to + * build/test payloads that match what the gateway actually expects. + * + * The implementation is intentionally defensive: when discovery fails we + * fall back to the legacy `{ phone, message }` shape so a misconfigured + * gateway still works (it just won't be using the optimal field names). + */ + +import { createLogger } from "../../_shared/logger.ts"; + +const logger = createLogger("sms-gateway-spec"); + +/** + * Auto-discovered SMS request body schema from a gateway's OpenAPI spec. + */ +export interface DiscoveredSmsSchema { + /** Endpoint path, e.g. "/send-sms" or "/messages". */ + endpoint: string; + /** JSON property name for the destination phone number. */ + phoneField: string; + /** JSON property name for the SMS body. */ + messageField: string; + /** HTTP method the gateway expects (usually POST). */ + method: "POST" | "GET"; + /** Required field names from the spec (best-effort). */ + requiredFields: string[]; +} + +/** + * Candidate JSON property names to look for in the request body schema + * for the destination phone number. Order is priority order. + */ +const PHONE_FIELD_CANDIDATES = [ + "to", + "phone", + "number", + "mobile", + "recipient", + "msisdn", +] as const; + +/** + * Candidate JSON property names to look for in the request body schema + * for the SMS body text. Order is priority order. + */ +const MESSAGE_FIELD_CANDIDATES = [ + "message", + "text", + "body", + "content", +] as const; + +/** Common OpenAPI/Swagger spec paths, tried in order. */ +const SPEC_PATHS = [ + "/swagger.json", + "/openapi.json", + "/api-docs", + "/docs/openapi.json", + "/v1/openapi.json", + "/spec.json", +] as const; + +/** Path patterns that strongly suggest an SMS-sending endpoint. */ +const PREFERRED_PATH_PATTERNS = [ + /^\/send-sms/i, + /^\/sms\/?$/i, + /^\/send\/?$/i, + /\/messages\/?$/i, + /\/message\/?$/i, +] as const; + +const DISCOVERY_TIMEOUT_MS = 5000; +const REACHABILITY_TIMEOUT_MS = 10000; + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +function joinUrl(baseUrl: string, path: string): string { + if (path.startsWith("http://") || path.startsWith("https://")) { + return path; + } + const normalizedBase = normalizeBaseUrl(baseUrl); + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `${normalizedBase}${normalizedPath}`; +} + +function asRecord(value: unknown): Record { + if (typeof value !== "object" || value === null) { + return {}; + } + return value as Record; +} + +function getString(value: unknown): string | null { + if (typeof value === "string" && value.length > 0) { + return value; + } + return null; +} + +function getStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is string => typeof item === "string"); +} + +/** + * Pick the first field name that exists in `properties` and matches + * one of the candidate names (case-insensitive). + */ +function pickFieldName( + properties: Record, + candidates: readonly string[], +): string | null { + const propertyNames = Object.keys(properties); + const lowerToOriginal = new Map(); + for (const name of propertyNames) { + lowerToOriginal.set(name.toLowerCase(), name); + } + for (const candidate of candidates) { + const actual = lowerToOriginal.get(candidate.toLowerCase()); + if (actual) { + return actual; + } + } + return null; +} + +/** + * Try to fetch an OpenAPI/Swagger spec from common paths on the gateway. + * Returns the parsed spec object on success, or null if no spec is found + * or any spec candidate fails to parse as JSON. + * + * Tries these paths in order: /swagger.json, /openapi.json, /api-docs, + * /docs/openapi.json, /v1/openapi.json, /spec.json. The first successful + * response is returned; the rest are not tried. + */ +export async function discoverGatewaySpec( + baseUrl: string, +): Promise { + if (!baseUrl) { + return null; + } + + for (const path of SPEC_PATHS) { + const url = joinUrl(baseUrl, path); + try { + const response = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), + }); + + if (!response.ok) { + continue; + } + + const contentType = response.headers.get("content-type") || ""; + // Most specs are served as JSON, but some gateways serve YAML. + if ( + contentType && + !contentType.includes("json") && + !contentType.includes("yaml") && + !contentType.includes("text") + ) { + continue; + } + + const text = await response.text(); + if (!text.trim()) { + continue; + } + + try { + const parsed = JSON.parse(text) as unknown; + if (parsed && typeof parsed === "object") { + logger.debug("Discovered gateway spec", { path }); + return parsed; + } + } catch { + // Not JSON — skip this candidate. (We don't currently parse YAML + // since the popular iOS gateway serves JSON, but the hook is here + // for future YAML support.) + continue; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.debug("Spec discovery request failed", { + path, + error: errorMessage, + }); + // Continue to next candidate — one failing path shouldn't kill discovery. + } + } + + logger.warn("No OpenAPI/Swagger spec discovered on gateway", { baseUrl }); + return null; +} + +/** + * Extract the SMS request body schema from a parsed OpenAPI spec. + * Returns null if no suitable SMS endpoint is found. + * + * Strategy: + * 1. Iterate `paths` and collect all POST endpoints (or GET as a fallback). + * 2. Score each endpoint by path pattern: prefer /send-sms, /messages, + * /sms, /send. The first match wins; if none match, fall back to the + * first POST endpoint we found. + * 3. Examine the endpoint's request body schema (JSON application/json + * content), pull out required and optional properties, and pick the + * phone/message field names from candidate lists. + */ +export function extractSmsRequestSchema( + spec: unknown, +): DiscoveredSmsSchema | null { + if (!spec || typeof spec !== "object") { + return null; + } + const root = asRecord(spec); + const paths = asRecord(root.paths); + if (Object.keys(paths).length === 0) { + return null; + } + + type Candidate = { + path: string; + method: "POST" | "GET"; + operation: Record; + score: number; + }; + + const candidates: Candidate[] = []; + for (const [path, pathItem] of Object.entries(paths)) { + const pathRecord = asRecord(pathItem); + for (const method of ["post", "get"] as const) { + const operation = asRecord(pathRecord[method]); + if (Object.keys(operation).length === 0) { + continue; + } + let score = 0; + for (const pattern of PREFERRED_PATH_PATTERNS) { + if (pattern.test(path)) { + score += 10; + } + } + // Slight bonus for POST since that's the more common pattern. + if (method === "post") { + score += 1; + } + candidates.push({ + path, + method: method === "post" ? "POST" : "GET", + operation, + score, + }); + } + } + + if (candidates.length === 0) { + return null; + } + + candidates.sort((a, b) => b.score - a.score); + const winner = candidates[0]; + if (!winner) { + return null; + } + + const requestBody = asRecord(winner.operation.requestBody); + const content = asRecord(requestBody.content); + const jsonContent = asRecord(content["application/json"]); + const schema = asRecord(jsonContent.schema); + if (Object.keys(schema).length === 0) { + return null; + } + + const properties = asRecord(schema.properties); + if (Object.keys(properties).length === 0) { + return null; + } + + const phoneField = pickFieldName(properties, PHONE_FIELD_CANDIDATES); + const messageField = pickFieldName(properties, MESSAGE_FIELD_CANDIDATES); + + if (!phoneField || !messageField) { + logger.warn( + "Spec found but required SMS fields missing from request body", + { + path: winner.path, + phoneField, + messageField, + }, + ); + return null; + } + + const required = getStringArray(schema.required); + + return { + endpoint: winner.path, + phoneField, + messageField, + method: winner.method, + requiredFields: required, + }; +} + +/** + * Build the SMS request body using the discovered schema. + * Falls back to `{ phone, message }` when schema is null. + * + * Always emits only the two canonical fields (phone + message) regardless + * of what the spec calls them — the gateway should map them to the right + * JSON property based on the schema we discovered. This keeps the rest + * of the provider code unchanged. + */ +export function buildSmsBody( + schema: DiscoveredSmsSchema | null, + phone: string, + message: string, +): Record { + if (!schema) { + return { phone, message }; + } + return { + [schema.phoneField]: phone, + [schema.messageField]: message, + }; +} + +/** + * Test reachability of a gateway by sending a POST to the discovered + * endpoint with a safe test payload. No actual SMS is sent because the + * destination is a well-known test number that real gateways refuse to + * deliver to (or simply reject as invalid). + * + * For SMS Gate, for example, returning `{ success: true }` from the + * endpoint confirms the server is up. The test call does not require + * a valid destination since the gateway will validate it before sending. + */ +export async function testGatewayReachability( + baseUrl: string, + schema: DiscoveredSmsSchema | null, + timeoutMs: number = REACHABILITY_TIMEOUT_MS, +): Promise<{ success: boolean; message: string }> { + if (!baseUrl) { + return { success: false, message: "Missing gateway base URL" }; + } + + const endpoint = schema?.endpoint ?? "/"; + const url = joinUrl(baseUrl, endpoint); + const body = buildSmsBody( + schema, + "+10000000000", + "leadminer-reachability-test", + ); + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + + if (response.ok) { + return { success: true, message: "Gateway is reachable" }; + } + + // A 4xx response still proves the server is alive and parsing JSON, + // which is exactly what we want to know. We treat 5xx as unreachable. + if (response.status >= 400 && response.status < 500) { + return { + success: true, + message: `Gateway is reachable (HTTP ${response.status})`, + }; + } + + return { + success: false, + message: `Gateway returned HTTP ${response.status}`, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("timeout") || errorMessage.includes("abort")) { + return { + success: false, + message: "Gateway did not respond within the timeout", + }; + } + return { success: false, message: errorMessage }; + } +} From 85faf455427338a5d080990e367dce866af90e60 Mon Sep 17 00:00:00 2001 From: Malek Salem Date: Sat, 4 Jul 2026 00:12:48 +0100 Subject: [PATCH 04/55] feat(sms-fleet): reject unreachable gateways + add re-detect route The POST /gateways endpoint accepted any URL without validation. Users could add invalid URLs (or unreachable private LAN IPs) and only find out at send time. The test endpoint also used HEAD, which most SMS gateway apps don't support. - POST /gateways now runs spec discovery + a POST-based reachability test before insert. Returns 400 with GATEWAY_UNREACHABLE on failure. Supports ?dryRun=true to preview the discovered schema without saving. - POST /gateways/:id/redetect re-runs discovery + reachability for an existing gateway and updates the stored bodySchema. - POST /gateways/:id/test replaced its HEAD request with a schema-aware POST. 2xx/4xx counts as reachable, 5xx as failure. - Both sms-fleet and sms-campaigns fleet routes expose the new redetect endpoint for symmetry. - Manual overrides (endpoint/phoneField/messageField) can be passed in the request body to override the discovered values; the backend merges them on top of the discovered schema. - The non-fleet preview path uses a 5-minute in-memory schema cache to avoid hammering the gateway on every preview. --- supabase/functions/sms-campaigns/index.ts | 318 ++++++++++++++++++++-- supabase/functions/sms-fleet/index.ts | 265 +++++++++++++++++- 2 files changed, 549 insertions(+), 34 deletions(-) diff --git a/supabase/functions/sms-campaigns/index.ts b/supabase/functions/sms-campaigns/index.ts index bd8b56a6e..d56db7e09 100644 --- a/supabase/functions/sms-campaigns/index.ts +++ b/supabase/functions/sms-campaigns/index.ts @@ -10,16 +10,23 @@ import { createLogger } from "../_shared/logger.ts"; import { resolveCampaignBaseUrlFromEnv } from "../_shared/url.ts"; import { generateShortToken } from "../_shared/short-token.ts"; import { + createSmsProvider, isTwilioFallbackAvailable, type SimpleSmsGatewayCredentials, type SmsGateCredentials, TwilioProvider, - createSmsProvider, } from "./providers/mod.ts"; import { getLocalTimeBounds, getSmsQuota } from "./utils/quota.ts"; import { isValidPhoneNumber, normalizePhoneNumber } from "./utils/phone.ts"; import { estimateSmsSegments } from "./utils/sms-segments.ts"; import { shortenUrl } from "./utils/short-link.ts"; +import { getRegionFromTimezone } from "./utils/timezone-region.ts"; +import { + type DiscoveredSmsSchema, + discoverGatewaySpec, + extractSmsRequestSchema, + testGatewayReachability, +} from "./utils/gateway-spec.ts"; const logger = createLogger("sms-campaigns"); @@ -57,23 +64,31 @@ type RecipientStatus = "pending" | "sent" | "failed" | "skipped"; const smsCampaignCreateSchema = z.object({ selectedPhones: z.array(z.string()).optional(), - selectedRecipients: z.array(z.object({ - phone: z.string(), - personalization: z.record(z.unknown()).optional(), - })).optional(), + selectedRecipients: z + .array( + z.object({ + phone: z.string(), + personalization: z.record(z.unknown()).optional(), + }), + ) + .optional(), senderName: z.string().min(1), messageTemplate: z.string().min(1), footerTextTemplate: z.string().optional(), useShortLinks: z.boolean().optional(), provider: z.enum(["smsgate", "simple-sms-gateway", "twilio"]).optional(), - smsgateConfig: z.object({ - baseUrl: z.string().optional(), - username: z.string().optional(), - password: z.string().optional(), - }).optional(), - simpleSmsGatewayConfig: z.object({ - baseUrl: z.string().optional(), - }).optional(), + smsgateConfig: z + .object({ + baseUrl: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + }) + .optional(), + simpleSmsGatewayConfig: z + .object({ + baseUrl: z.string().optional(), + }) + .optional(), timezone: z.string().optional(), fleetMode: z.boolean().optional(), selectedGatewayIds: z.array(z.string()).optional(), @@ -115,6 +130,7 @@ type SmsFleetGateway = { username?: string; password?: string; simpleSmsGatewayBaseUrl?: string; + bodySchema?: DiscoveredSmsSchema | null; }; is_active: boolean; daily_limit: number; @@ -287,6 +303,66 @@ function toSimpleSmsGatewayCredentials( }; } +/** + * In-memory cache for spec discovery on the non-fleet code path. + * + * The non-fleet simple-sms-gateway path doesn't persist a discovered + * schema (it lives on the profile, not on a fleet gateway row). To avoid + * pinging the gateway on every preview, we cache discovered schemas for + * 5 minutes per baseUrl. Previews are infrequent, so the TTL can be + * short without harm. + */ +const SCHEMA_CACHE_TTL_MS = 5 * 60 * 1000; +const schemaCache = new Map< + string, + { schema: DiscoveredSmsSchema | null; expiresAt: number } +>(); + +function getCachedSchema( + baseUrl: string, +): DiscoveredSmsSchema | null | undefined { + const entry = schemaCache.get(baseUrl); + if (!entry) { + return undefined; + } + if (entry.expiresAt < Date.now()) { + schemaCache.delete(baseUrl); + return undefined; + } + return entry.schema; +} + +function setCachedSchema( + baseUrl: string, + schema: DiscoveredSmsSchema | null, +): void { + schemaCache.set(baseUrl, { + schema, + expiresAt: Date.now() + SCHEMA_CACHE_TTL_MS, + }); +} + +/** + * Resolve the simple-sms-gateway body schema for a base URL, consulting + * the in-memory cache first. Returns null when the gateway has no + * discoverable spec (caller should fall back to legacy fields). + */ +async function resolveSimpleSmsGatewaySchema( + baseUrl: string, +): Promise { + if (!baseUrl) { + return null; + } + const cached = getCachedSchema(baseUrl); + if (cached !== undefined) { + return cached; + } + const spec = await discoverGatewaySpec(baseUrl); + const schema = spec ? extractSmsRequestSchema(spec) : null; + setCachedSchema(baseUrl, schema); + return schema; +} + app.get("/providers/status", authMiddleware, async (c: Context) => { const user = c.get("user"); if (!user) return c.json({ error: "Unauthorized" }, 401); @@ -730,9 +806,10 @@ app.post("/campaigns/create", authMiddleware, async (c: Context) => { ); } + const region = getRegionFromTimezone(timezone); const validPhones = requestedPhones - .filter(isValidPhoneNumber) - .map((phone) => normalizePhoneNumber(phone) as string); + .filter((phone) => isValidPhoneNumber(phone, region)) + .map((phone) => normalizePhoneNumber(phone, region) as string); const uniquePhones = [...new Set(validPhones)]; if (uniquePhones.length === 0) { @@ -940,7 +1017,7 @@ app.post("/campaigns/create", authMiddleware, async (c: Context) => { const personalizationByPhone = new Map>(); for (const recipient of selectedRecipients || []) { - const normalizedPhone = normalizePhoneNumber(recipient.phone || ""); + const normalizedPhone = normalizePhoneNumber(recipient.phone || "", region); if (!normalizedPhone) continue; if ( !personalizationByPhone.has(normalizedPhone) && @@ -1078,8 +1155,9 @@ app.post("/campaigns/preview", authMiddleware, async (c: Context) => { } // Validate test phone number if provided + const region = getRegionFromTimezone(payload.timezone); const normalizedTestPhone = testPhoneNumber - ? normalizePhoneNumber(testPhoneNumber) + ? normalizePhoneNumber(testPhoneNumber, region) : null; if (testPhoneNumber && !normalizedTestPhone) { return c.json( @@ -1236,6 +1314,7 @@ app.post("/campaigns/preview", authMiddleware, async (c: Context) => { smsProvider = createSmsProvider("simple-sms-gateway", { simpleSmsGateway: { baseUrl: config.simpleSmsGatewayBaseUrl, + bodySchema: config.bodySchema ?? null, }, }); } @@ -1254,8 +1333,18 @@ app.post("/campaigns/preview", authMiddleware, async (c: Context) => { if (!simpleSmsGatewayCredentials) { throw new Error("simple-sms-gateway credentials missing"); } + // The profile doesn't persist a discovered schema, so we run + // discovery on-demand and cache the result. If discovery fails + // (no spec, unreachable gateway) we fall back to the legacy + // { phone, message } body shape. + const bodySchema = await resolveSimpleSmsGatewaySchema( + simpleSmsGatewayCredentials.baseUrl, + ); smsProvider = createSmsProvider("simple-sms-gateway", { - simpleSmsGateway: simpleSmsGatewayCredentials, + simpleSmsGateway: { + baseUrl: simpleSmsGatewayCredentials.baseUrl, + bodySchema, + }, }); } else { const profileConfig = await getUserSmsProviderConfig( @@ -1532,8 +1621,56 @@ app.post("/fleet/gateways", authMiddleware, async (c: Context) => { return c.json(validationErrorBody(parseResult.error), 400); } const payload = parseResult.data; - const supabaseAdmin = createSupabaseAdmin(); + + // For simple-sms-gateway, auto-discover the request body shape and + // validate reachability before persisting. This protects users from + // adding a gateway URL that simply won't accept their SMS payloads. + const baseConfig = (payload.config || {}) as Record; + let discoveredSchema: DiscoveredSmsSchema | null = null; + if (payload.provider === "simple-sms-gateway") { + const baseUrl = readSimpleSmsGatewayBaseUrl(baseConfig); + if (!baseUrl) { + return c.json( + { + error: + "Missing simpleSmsGatewayBaseUrl in config for simple-sms-gateway provider", + code: "MISSING_BASE_URL", + }, + 400, + ); + } + + const spec = await discoverGatewaySpec(baseUrl); + if (spec) { + discoveredSchema = extractSmsRequestSchema(spec); + } + + const reachability = await testGatewayReachability( + baseUrl, + discoveredSchema, + ); + if (!reachability.success) { + logger.warn("simple-sms-gateway reachability test failed", { + userId: user.id, + baseUrl, + message: reachability.message, + }); + return c.json( + { + error: `Gateway is not reachable: ${reachability.message}`, + code: "GATEWAY_UNREACHABLE", + }, + 400, + ); + } + } + + const finalConfig: Record = { ...baseConfig }; + if (discoveredSchema) { + finalConfig.bodySchema = discoveredSchema; + } + const { data, error } = await supabaseAdmin .schema("private") .from("sms_fleet_gateways") @@ -1541,7 +1678,7 @@ app.post("/fleet/gateways", authMiddleware, async (c: Context) => { user_id: user.id, name: payload.name, provider: payload.provider, - config: payload.config || {}, + config: finalConfig, daily_limit: payload.daily_limit ?? 0, monthly_limit: payload.monthly_limit ?? 0, is_active: true, @@ -1556,9 +1693,34 @@ app.post("/fleet/gateways", authMiddleware, async (c: Context) => { ); } + logger.info("Fleet gateway created", { + userId: user.id, + gatewayId: data.id, + provider: data.provider, + schemaDiscovered: Boolean(discoveredSchema), + }); + return c.json({ gateway: data }); }); +/** + * Read the simple-sms-gateway base URL from a gateway config record. + * Different code paths use different keys (`simpleSmsGatewayBaseUrl` + * for the fleet gateway UI, `baseUrl` for the SMSGate path), so we + * accept either for safety. + */ +function readSimpleSmsGatewayBaseUrl( + config: Record, +): string | null { + for (const key of ["simpleSmsGatewayBaseUrl", "baseUrl"]) { + const value = config[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + app.put("/fleet/gateways/:id", authMiddleware, async (c: Context) => { const user = c.get("user"); if (!user) return c.json({ error: "Unauthorized" }, 401); @@ -1673,17 +1835,21 @@ app.post("/fleet/gateways/:id/test", authMiddleware, async (c: Context) => { : { success: false, message: "Gateway is not reachable" }; } } else if (gateway.provider === "simple-sms-gateway") { - const config = gateway.config as { simpleSmsGatewayBaseUrl?: string }; + const config = gateway.config as { + simpleSmsGatewayBaseUrl?: string; + bodySchema?: DiscoveredSmsSchema | null; + }; if (!config.simpleSmsGatewayBaseUrl) { testResult = { success: false, message: "Missing gateway URL" }; } else { - const response = await fetch(config.simpleSmsGatewayBaseUrl, { - method: "HEAD", - signal: AbortSignal.timeout(10000), - }).catch(() => null); - testResult = response - ? { success: true, message: "Gateway is reachable" } - : { success: false, message: "Gateway is not reachable" }; + // Use the discovered schema when available so the test call hits + // the actual SMS endpoint with the right field names. Most + // gateways reject HEAD, so we POST a test payload instead and + // treat any 2xx/4xx as proof of life (5xx is a real failure). + testResult = await testGatewayReachability( + config.simpleSmsGatewayBaseUrl, + config.bodySchema ?? null, + ); } } else { testResult = { success: false, message: "Unsupported provider" }; @@ -1701,6 +1867,102 @@ app.post("/fleet/gateways/:id/test", authMiddleware, async (c: Context) => { } }); +/** + * Re-run spec discovery + reachability for an existing fleet gateway + * and update the stored body schema. Useful when the gateway was + * added before the spec was discoverable, or after a gateway app + * upgrade that changed the field names. + */ +app.post("/fleet/gateways/:id/redetect", authMiddleware, async (c: Context) => { + const user = c.get("user"); + if (!user) return c.json({ error: "Unauthorized" }, 401); + + const gatewayId = c.req.param("id"); + const supabaseAdmin = createSupabaseAdmin(); + + const { data: existing, error: fetchError } = await supabaseAdmin + .schema("private") + .from("sms_fleet_gateways") + .select("*") + .eq("id", gatewayId) + .eq("user_id", user.id) + .single(); + + if (fetchError || !existing) { + return c.json({ error: "Gateway not found", code: "NOT_FOUND" }, 404); + } + + if (existing.provider !== "simple-sms-gateway") { + return c.json( + { + error: "Redetect only supported for simple-sms-gateway provider", + code: "UNSUPPORTED_PROVIDER", + }, + 400, + ); + } + + const baseConfig = (existing.config || {}) as Record; + const baseUrl = readSimpleSmsGatewayBaseUrl(baseConfig); + if (!baseUrl) { + return c.json( + { + error: "Missing simpleSmsGatewayBaseUrl in gateway config", + code: "MISSING_BASE_URL", + }, + 400, + ); + } + + const spec = await discoverGatewaySpec(baseUrl); + const discoveredSchema = spec ? extractSmsRequestSchema(spec) : null; + + const reachability = await testGatewayReachability(baseUrl, discoveredSchema); + if (!reachability.success) { + return c.json( + { + error: `Gateway is not reachable: ${reachability.message}`, + code: "GATEWAY_UNREACHABLE", + }, + 400, + ); + } + + const nextConfig: Record = { ...baseConfig }; + if (discoveredSchema) { + nextConfig.bodySchema = discoveredSchema; + } else { + delete nextConfig.bodySchema; + } + + const { data, error } = await supabaseAdmin + .schema("private") + .from("sms_fleet_gateways") + .update({ + config: nextConfig, + updated_at: new Date().toISOString(), + }) + .eq("id", gatewayId) + .eq("user_id", user.id) + .select() + .single(); + + if (error) { + return c.json( + { error: extractErrorMessage(error), code: "UPDATE_FAILED" }, + 500, + ); + } + + logger.info("Fleet gateway redetected", { + userId: user.id, + gatewayId, + schemaDiscovered: Boolean(discoveredSchema), + }); + + return c.json({ gateway: data }); +}); + Deno.serve((req) => app.fetch(req)); export default app; diff --git a/supabase/functions/sms-fleet/index.ts b/supabase/functions/sms-fleet/index.ts index 8c07a8e9f..ca2462dd1 100644 --- a/supabase/functions/sms-fleet/index.ts +++ b/supabase/functions/sms-fleet/index.ts @@ -6,11 +6,30 @@ import { createSupabaseClient, } from "../_shared/supabase.ts"; import { createLogger } from "../_shared/logger.ts"; +import { + type DiscoveredSmsSchema, + discoverGatewaySpec, + extractSmsRequestSchema, + testGatewayReachability, +} from "../sms-campaigns/utils/gateway-spec.ts"; const logger = createLogger("sms-fleet"); const functionName = "sms-fleet"; +/** + * Optional manual overrides for the SMS gateway request shape. The fleet + * CRUD also accepts these so users can paste in field names discovered + * outside the auto-discovery flow (e.g. from a custom gateway). + */ +const smsGatewayOverrideSchema = z + .object({ + endpoint: z.string().optional(), + phoneField: z.string().optional(), + messageField: z.string().optional(), + }) + .partial(); + const gatewaySchema = z.object({ name: z.string().min(1), provider: z.enum(["smsgate", "simple-sms-gateway", "twilio"]), @@ -18,6 +37,12 @@ const gatewaySchema = z.object({ daily_limit: z.number().int().min(0).optional(), monthly_limit: z.number().int().min(0).optional(), is_active: z.boolean().optional(), + /** + * Optional manual overrides for the SMS request body shape. When + * provided, these take precedence over values discovered from the + * gateway's OpenAPI spec. + */ + overrides: smsGatewayOverrideSchema.optional(), }); const updateSchema = z.object({ @@ -109,6 +134,27 @@ app.get("/gateways", authMiddleware, async (c) => { } }); +/** + * Extract the base URL the simple-sms-gateway provider needs from the + * caller-supplied config. The frontend currently uses the + * `simpleSmsGatewayBaseUrl` key, but the gateway row may also use + * `baseUrl` for SMSGate and other providers. + */ +function extractSimpleSmsGatewayBaseUrl( + config: Record, +): string | null { + const candidates: unknown[] = [ + config.simpleSmsGatewayBaseUrl, + config.baseUrl, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; +} + app.post("/gateways", authMiddleware, async (c) => { try { const user = c.get("user"); @@ -125,16 +171,117 @@ app.post("/gateways", authMiddleware, async (c) => { ); } + const validated = validation.data; const supabaseAdmin = createSupabaseAdmin(); + // For simple-sms-gateway, auto-discover the request body shape and + // validate reachability before persisting. This protects users from + // adding a gateway URL that simply won't accept their SMS payloads. + let discoveredSchema: DiscoveredSmsSchema | null = null; + let reachabilityTest: { success: boolean; message: string } | null = null; + if (validated.provider === "simple-sms-gateway") { + const baseUrl = extractSimpleSmsGatewayBaseUrl( + validated.config as Record, + ); + + if (!baseUrl) { + return c.json( + { + error: + "Missing simpleSmsGatewayBaseUrl in config for simple-sms-gateway provider", + code: "MISSING_BASE_URL", + }, + 400, + ); + } + + const spec = await discoverGatewaySpec(baseUrl); + if (spec) { + discoveredSchema = extractSmsRequestSchema(spec); + } + + // Reachability test uses the discovered schema (or null for legacy + // shape). The test sends a POST with a test phone number, which most + // gateways will accept (returning 200) or reject (returning 4xx) — + // either response proves the gateway is reachable. + reachabilityTest = await testGatewayReachability( + baseUrl, + discoveredSchema, + ); + if (!reachabilityTest.success) { + logger.warn("simple-sms-gateway reachability test failed", { + userId: user.id, + baseUrl, + message: reachabilityTest.message, + }); + // When `?dryRun=true` is set we return the failure as part of the + // preview payload instead of a 4xx — the caller is asking "what + // would happen if I saved this?", not "save this for me". + if (c.req.query("dryRun") === "true") { + return c.json({ + discoveredSchema, + reachabilityTest, + }); + } + return c.json( + { + error: `Gateway is not reachable: ${reachabilityTest.message}`, + code: "GATEWAY_UNREACHABLE", + }, + 400, + ); + } + } + + // Merge discovered schema + manual overrides into the config JSONB. + const mergedConfig: Record = { + ...(validated.config as Record), + }; + if (discoveredSchema) { + mergedConfig.bodySchema = discoveredSchema; + } + if (validated.overrides) { + const { endpoint, phoneField, messageField } = validated.overrides; + const existing = (mergedConfig.bodySchema ?? + {}) as Partial; + const finalEndpoint = endpoint ?? existing.endpoint; + const finalPhoneField = phoneField ?? existing.phoneField; + const finalMessageField = messageField ?? existing.messageField; + // Overrides only apply when there's already a discovered schema (or + // when all three overrides are provided). Skip otherwise — the + // gateway will use the legacy default body shape. + if (finalEndpoint && finalPhoneField && finalMessageField) { + mergedConfig.bodySchema = { + endpoint: finalEndpoint, + phoneField: finalPhoneField, + messageField: finalMessageField, + method: existing.method ?? "POST", + requiredFields: existing.requiredFields ?? [], + } satisfies DiscoveredSmsSchema; + } + } + + // `?dryRun=true` short-circuits persistence and returns the + // discovered schema + reachability probe so the frontend can preview + // the result before the user commits to saving a new gateway. We + // keep the persisted-config merge above so the preview matches what + // would actually be stored (overrides included). + if (c.req.query("dryRun") === "true") { + return c.json({ + discoveredSchema: + (mergedConfig.bodySchema as DiscoveredSmsSchema | undefined) ?? null, + reachabilityTest, + }); + } + const gateway = { user_id: user.id, - name: validation.data.name, - provider: validation.data.provider, - config: validation.data.config, - daily_limit: validation.data.daily_limit ?? 200, - monthly_limit: validation.data.monthly_limit ?? 200, - is_active: validation.data.is_active ?? true, + name: validated.name, + provider: validated.provider, + config: mergedConfig, + daily_limit: validated.daily_limit ?? 200, + monthly_limit: validated.monthly_limit ?? 200, + is_active: validated.is_active ?? true, }; const { data, error } = await supabaseAdmin @@ -156,6 +303,7 @@ app.post("/gateways", authMiddleware, async (c) => { userId: user.id, gatewayId: data.id, provider: data.provider, + schemaDiscovered: Boolean(discoveredSchema), }); return c.json(data, 201); @@ -261,6 +409,111 @@ app.delete("/gateways/:id", authMiddleware, async (c) => { } }); +/** + * Re-run spec discovery + reachability for an existing gateway. Useful + * when the gateway was added before the schema was known, or after the + * gateway's API surface changed. + */ +app.post("/gateways/:id/redetect", authMiddleware, async (c) => { + try { + const user = c.get("user"); + const id = c.req.param("id"); + const supabaseAdmin = createSupabaseAdmin(); + + const { data: existing, error: fetchError } = await supabaseAdmin + .schema("private") + .from("sms_fleet_gateways") + .select("*") + .eq("id", id) + .eq("user_id", user.id) + .single(); + + if (fetchError || !existing) { + return c.json({ error: "Gateway not found", code: "NOT_FOUND" }, 404); + } + + if (existing.provider !== "simple-sms-gateway") { + return c.json( + { + error: "Redetect only supported for simple-sms-gateway provider", + code: "UNSUPPORTED_PROVIDER", + }, + 400, + ); + } + + const config = (existing.config as Record) ?? {}; + const baseUrl = extractSimpleSmsGatewayBaseUrl(config); + if (!baseUrl) { + return c.json( + { + error: "Missing simpleSmsGatewayBaseUrl in gateway config", + code: "MISSING_BASE_URL", + }, + 400, + ); + } + + const spec = await discoverGatewaySpec(baseUrl); + const discoveredSchema = spec ? extractSmsRequestSchema(spec) : null; + + const reachability = await testGatewayReachability( + baseUrl, + discoveredSchema, + ); + if (!reachability.success) { + return c.json( + { + error: `Gateway is not reachable: ${reachability.message}`, + code: "GATEWAY_UNREACHABLE", + }, + 400, + ); + } + + const nextConfig: Record = { ...config }; + if (discoveredSchema) { + nextConfig.bodySchema = discoveredSchema; + } else { + delete nextConfig.bodySchema; + } + + const { data, error } = await supabaseAdmin + .schema("private") + .from("sms_fleet_gateways") + .update({ + config: nextConfig, + updated_at: new Date().toISOString(), + }) + .eq("id", id) + .eq("user_id", user.id) + .select() + .single(); + + if (error) { + logger.error("Failed to update gateway after redetect", { + userId: user.id, + gatewayId: id, + error: error.message, + }); + return c.json({ error: error.message }, 500); + } + + logger.info("Gateway redetected", { + userId: user.id, + gatewayId: id, + schemaDiscovered: Boolean(discoveredSchema), + }); + + return c.json(data); + } catch (error) { + logger.error("Unexpected error in POST /gateways/:id/redetect", { + error: error instanceof Error ? error.message : String(error), + }); + return c.json({ error: "Internal server error" }, 500); + } +}); + app.onError((err, c) => { logger.error("Unhandled sms-fleet error", { path: c.req.path, From dbd82325fb985a800f1e1e4aaeb7b9f762df97f3 Mon Sep 17 00:00:00 2001 From: Malek Salem Date: Sat, 4 Jul 2026 00:13:00 +0100 Subject: [PATCH 05/55] fix(sms-campaigns): accept national-format phone numbers normalizePhoneNumber used libphonenumber-js without a defaultRegion. Any number without a + prefix (e.g. a Tunisian 21697522154) was rejected because the library couldn't determine the country. Adds utils/timezone-region.ts that maps common IANA timezones to 2-letter country codes (TN, FR, DE, ES, IT, GB, BE, NL, CH, LU, US, CA, MA, DZ, EG). normalizePhoneNumber and isValidPhoneNumber now accept an optional defaultRegion. All 3 call sites in /campaigns/create and /campaigns/preview derive the region from the user's timezone. Verified: normalizePhoneNumber('21697522154', 'TN') returns '+21697522154'. --- .../sms-campaigns/utils/integration.test.ts | 43 ++++++ .../sms-campaigns/utils/phone.test.ts | 128 ++++++++++++---- .../functions/sms-campaigns/utils/phone.ts | 39 ++++- .../utils/timezone-region.test.ts | 142 ++++++++++++++++++ .../sms-campaigns/utils/timezone-region.ts | 65 ++++++++ 5 files changed, 385 insertions(+), 32 deletions(-) create mode 100644 supabase/functions/sms-campaigns/utils/integration.test.ts create mode 100644 supabase/functions/sms-campaigns/utils/timezone-region.test.ts create mode 100644 supabase/functions/sms-campaigns/utils/timezone-region.ts diff --git a/supabase/functions/sms-campaigns/utils/integration.test.ts b/supabase/functions/sms-campaigns/utils/integration.test.ts new file mode 100644 index 000000000..001e8381d --- /dev/null +++ b/supabase/functions/sms-campaigns/utils/integration.test.ts @@ -0,0 +1,43 @@ +/** + * Integration smoke test for the timezone → region → phone normalization + * pipeline used by the SMS composer. + * + * Flow: + * 1. The browser reports the user's IANA timezone (e.g. "Africa/Tunis"). + * 2. `getRegionFromTimezone` maps it to a 2-letter ISO country code. + * 3. The country code is passed as `defaultRegion` to the phone + * parser so unprefixed national-format numbers can be normalised. + * + * This test asserts the three steps compose correctly for the canonical + * Tunisian case described in Lane C, plus a negative-validation case. + */ +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { getRegionFromTimezone } from "./timezone-region.ts"; +import { isValidPhoneNumber, normalizePhoneNumber } from "./phone.ts"; + +Deno.test( + "integration: timezone → region → normalize (Africa/Tunis, valid number)", + () => { + const region = getRegionFromTimezone("Africa/Tunis"); + assertEquals(region, "TN"); + + const normalized = normalizePhoneNumber("21697522154", region); + assertEquals(normalized, "+21697522154"); + + assertEquals(isValidPhoneNumber("21697522154", region), true); + }, +); + +Deno.test( + "integration: timezone → region → normalize (Africa/Tunis, invalid input)", + () => { + const region = getRegionFromTimezone("Africa/Tunis"); + assertEquals(region, "TN"); + + // Non-numeric input must be rejected by the validator. + assertEquals(isValidPhoneNumber("abc", region), false); + + // And it must be rejected by the normalizer as well. + assertEquals(normalizePhoneNumber("abc", region), null); + }, +); diff --git a/supabase/functions/sms-campaigns/utils/phone.test.ts b/supabase/functions/sms-campaigns/utils/phone.test.ts index 9e7e07d5f..c6aa18d94 100644 --- a/supabase/functions/sms-campaigns/utils/phone.test.ts +++ b/supabase/functions/sms-campaigns/utils/phone.test.ts @@ -1,42 +1,114 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { isValidPhoneNumber, normalizePhoneNumber } from "./phone.ts"; -Deno.test("normalizePhoneNumber formats valid numbers to E.164", () => { - assertEquals(normalizePhoneNumber("+1234567890"), "+1234567890"); - assertEquals(normalizePhoneNumber("1234567890"), "+1234567890"); - assertEquals(normalizePhoneNumber("+33 1 23 45 67 89"), "+33123456789"); - assertEquals(normalizePhoneNumber("+33(0)123456789"), "+33123456789"); - assertEquals(normalizePhoneNumber("0033123456789"), "+33123456789"); -}); +// Note: libphonenumber-js has no implicit "default region" — when no +// `defaultRegion` is supplied, only numbers that start with `+` (or +// another recognised international prefix) are accepted. The tests below +// mirror that behaviour. For unprefixed national numbers, pass a +// `defaultRegion` explicitly (see the `defaultRegion` test group below). + +Deno.test( + "normalizePhoneNumber formats international numbers to E.164 (no region)", + () => { + assertEquals(normalizePhoneNumber("+33 1 23 45 67 89"), "+33123456789"); + assertEquals(normalizePhoneNumber("+33(0)123456789"), "+33123456789"); + assertEquals(normalizePhoneNumber("+1 (234) 567-8901"), "+12345678901"); + assertEquals(normalizePhoneNumber("+49 160 1234567"), "+491601234567"); + assertEquals(normalizePhoneNumber("+44 20 7946 0958"), "+442079460958"); + // +21697522154 is a valid Tunisian number even without a region hint, + // because the `+` forces international-format parsing. + assertEquals(normalizePhoneNumber("+21697522154"), "+21697522154"); + }, +); Deno.test("normalizePhoneNumber returns null for invalid numbers", () => { assertEquals(normalizePhoneNumber("abc"), null); assertEquals(normalizePhoneNumber("123"), null); assertEquals(normalizePhoneNumber(""), null); assertEquals(normalizePhoneNumber("+"), null); + // 10 raw digits with no `+` and no region cannot be parsed. + assertEquals(normalizePhoneNumber("1234567890"), null); + // 00-prefixed international numbers (e.g. "0033...") need a region hint + // or a leading `+` to be recognised as international format. + assertEquals(normalizePhoneNumber("0033123456789"), null); }); -Deno.test("isValidPhoneNumber validates E.164 numbers with 10+ digits", () => { - assertEquals(isValidPhoneNumber("+1234567890"), true); +Deno.test("isValidPhoneNumber (no region) accepts `+`-prefixed numbers", () => { assertEquals(isValidPhoneNumber("+33123456789"), true); - assertEquals(isValidPhoneNumber("1234567890"), true); - assertEquals(isValidPhoneNumber("+123456789"), false); - assertEquals(isValidPhoneNumber("12345"), false); - assertEquals(isValidPhoneNumber(null), false); - assertEquals(isValidPhoneNumber(""), false); - assertEquals(isValidPhoneNumber("abc"), false); + assertEquals(isValidPhoneNumber("+12345678901"), true); + assertEquals(isValidPhoneNumber("+21697522154"), true); }); -Deno.test("phone normalization handles various formats", () => { - const testCases = [ - { input: "+1 (234) 567-8901", expected: "+12345678901" }, - { input: "34 612 345 678", expected: "+34612345678" }, - { input: "+49 160 1234567", expected: "+491601234567" }, - { input: "0049 160 1234567", expected: "+491601234567" }, - { input: "+44 20 7946 0958", expected: "+442079460958" }, - ]; - - for (const { input, expected } of testCases) { - assertEquals(normalizePhoneNumber(input), expected); - } -}); +Deno.test( + "isValidPhoneNumber (no region) rejects unprefixed national numbers", + () => { + // Without a region, raw national digits cannot be parsed. + assertEquals(isValidPhoneNumber("1234567890"), false); + assertEquals(isValidPhoneNumber("21697522154"), false); + // +123456789 has only 10 digits, not enough for a valid US E.164. + assertEquals(isValidPhoneNumber("+123456789"), false); + assertEquals(isValidPhoneNumber("12345"), false); + assertEquals(isValidPhoneNumber(null), false); + assertEquals(isValidPhoneNumber(""), false); + assertEquals(isValidPhoneNumber("abc"), false); + }, +); + +Deno.test( + "normalizePhoneNumber uses defaultRegion to parse national numbers", + () => { + // The fix: with a region hint, unprefixed national numbers are accepted. + assertEquals(normalizePhoneNumber("21697522154", "TN"), "+21697522154"); + assertEquals(normalizePhoneNumber("0612345678", "FR"), "+33612345678"); + assertEquals(normalizePhoneNumber("2345678901", "US"), "+12345678901"); + // Passing a region for an already-international number is a no-op. + assertEquals(normalizePhoneNumber("+21697522154", "TN"), "+21697522154"); + assertEquals(normalizePhoneNumber("+33123456789", "FR"), "+33123456789"); + }, +); + +Deno.test( + "normalizePhoneNumber handles spacing and punctuation in national numbers", + () => { + // National numbers with `+` prefix can use any reasonable digit grouping. + assertEquals(normalizePhoneNumber("+216 975 221 54"), "+21697522154"); + assertEquals(normalizePhoneNumber("+216 97 52 21 54"), "+21697522154"); + // French 10-digit national number with region hint. + assertEquals(normalizePhoneNumber("01 23 45 67 89", "FR"), "+33123456789"); + // `null` is coerced to `undefined` internally, behaving like no region. + assertEquals(normalizePhoneNumber("+21697522154", null), "+21697522154"); + assertEquals(normalizePhoneNumber("21697522154", null), null); + }, +); + +Deno.test( + "isValidPhoneNumber uses defaultRegion to validate national numbers", + () => { + assertEquals(isValidPhoneNumber("21697522154", "TN"), true); + assertEquals(isValidPhoneNumber("0612345678", "FR"), true); + assertEquals(isValidPhoneNumber("21697522154", "FR"), false); // not French + // Too short for any region. + assertEquals(isValidPhoneNumber("123", "FR"), false); + // null region behaves like no region. + assertEquals(isValidPhoneNumber("21697522154", null), false); + assertEquals(isValidPhoneNumber(null, "TN"), false); + }, +); + +Deno.test( + "phone normalization handles various formats with defaultRegion", + () => { + const testCases = [ + { input: "+1 (234) 567-8901", expected: "+12345678901", region: "US" }, + { input: "34 612 345 678", expected: null, region: null }, + { input: "+49 160 1234567", expected: "+491601234567", region: "DE" }, + { input: "0049 160 1234567", expected: null, region: null }, + { input: "+44 20 7946 0958", expected: "+442079460958", region: "GB" }, + { input: "21697522154", expected: "+21697522154", region: "TN" }, + ]; + + for (const { input, expected, region } of testCases) { + assertEquals(normalizePhoneNumber(input, region), expected); + } + }, +); diff --git a/supabase/functions/sms-campaigns/utils/phone.ts b/supabase/functions/sms-campaigns/utils/phone.ts index a73ea18d7..aab3d3c46 100644 --- a/supabase/functions/sms-campaigns/utils/phone.ts +++ b/supabase/functions/sms-campaigns/utils/phone.ts @@ -1,12 +1,31 @@ import { parsePhoneNumberFromString, ParseError } from "libphonenumber-js"; -export function normalizePhoneNumber(phone: string): string | null { +/** + * Normalize a phone number to E.164 format. + * + * When `defaultRegion` is provided, unprefixed national-format numbers + * are parsed in the context of that region (e.g. with `"TN"`, the input + * `"21697522154"` is accepted as the Tunisian number `+21697522154`). + * When `defaultRegion` is omitted or `null`, only numbers with an + * explicit international prefix (`+` or `00...`) are accepted. + * + * @param phone - Raw phone number string from the user + * @param defaultRegion - Optional 2-letter ISO country code (e.g. "TN", "FR") + * @returns E.164-formatted number (e.g. `"+21697522154"`) or `null` if invalid + */ +export function normalizePhoneNumber( + phone: string, + defaultRegion?: string | null, +): string | null { if (!phone?.trim()) return null; const trimmed = phone.trim(); try { - const phoneNumber = parsePhoneNumberFromString(trimmed); + const phoneNumber = parsePhoneNumberFromString( + trimmed, + defaultRegion ?? undefined, + ); if (!phoneNumber || !phoneNumber.isValid()) { return null; } @@ -19,7 +38,19 @@ export function normalizePhoneNumber(phone: string): string | null { } } -export function isValidPhoneNumber(phone: string | null): boolean { +/** + * Check whether a phone number is valid and can be normalized to E.164. + * + * See {@link normalizePhoneNumber} for details on the `defaultRegion` parameter. + * + * @param phone - Raw phone number string (or `null`) + * @param defaultRegion - Optional 2-letter ISO country code (e.g. "TN", "FR") + * @returns `true` if the number is valid and can be normalized, `false` otherwise + */ +export function isValidPhoneNumber( + phone: string | null, + defaultRegion?: string | null, +): boolean { if (!phone) return false; - return normalizePhoneNumber(phone) !== null; + return normalizePhoneNumber(phone, defaultRegion) !== null; } diff --git a/supabase/functions/sms-campaigns/utils/timezone-region.test.ts b/supabase/functions/sms-campaigns/utils/timezone-region.test.ts new file mode 100644 index 000000000..872858793 --- /dev/null +++ b/supabase/functions/sms-campaigns/utils/timezone-region.test.ts @@ -0,0 +1,142 @@ +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { getRegionFromTimezone } from "./timezone-region.ts"; +import { normalizePhoneNumber } from "./phone.ts"; + +Deno.test("getRegionFromTimezone maps African timezones", () => { + assertEquals(getRegionFromTimezone("Africa/Tunis"), "TN"); + assertEquals(getRegionFromTimezone("Africa/Casablanca"), "MA"); + assertEquals(getRegionFromTimezone("Africa/Algiers"), "DZ"); + assertEquals(getRegionFromTimezone("Africa/Cairo"), "EG"); +}); + +Deno.test("getRegionFromTimezone maps European timezones", () => { + assertEquals(getRegionFromTimezone("Europe/Paris"), "FR"); + assertEquals(getRegionFromTimezone("Europe/Berlin"), "DE"); + assertEquals(getRegionFromTimezone("Europe/Madrid"), "ES"); + assertEquals(getRegionFromTimezone("Europe/Rome"), "IT"); + assertEquals(getRegionFromTimezone("Europe/London"), "GB"); + assertEquals(getRegionFromTimezone("Europe/Brussels"), "BE"); + assertEquals(getRegionFromTimezone("Europe/Amsterdam"), "NL"); + assertEquals(getRegionFromTimezone("Europe/Zurich"), "CH"); + assertEquals(getRegionFromTimezone("Europe/Luxembourg"), "LU"); +}); + +Deno.test("getRegionFromTimezone maps North American timezones", () => { + assertEquals(getRegionFromTimezone("America/New_York"), "US"); + assertEquals(getRegionFromTimezone("America/Chicago"), "US"); + assertEquals(getRegionFromTimezone("America/Denver"), "US"); + assertEquals(getRegionFromTimezone("America/Los_Angeles"), "US"); + assertEquals(getRegionFromTimezone("America/Toronto"), "CA"); + assertEquals(getRegionFromTimezone("America/Montreal"), "CA"); + assertEquals(getRegionFromTimezone("America/Vancouver"), "CA"); +}); + +Deno.test( + "getRegionFromTimezone returns null for ambiguous or unmapped timezones", + () => { + assertEquals(getRegionFromTimezone("UTC"), null); + assertEquals(getRegionFromTimezone("Etc/UTC"), null); + assertEquals(getRegionFromTimezone("GMT"), null); + // Asia/Tokyo is intentionally not in the mapping table + assertEquals(getRegionFromTimezone("Asia/Tokyo"), null); + // Made-up timezone string + assertEquals(getRegionFromTimezone("Mars/Olympus_Mons"), null); + }, +); + +Deno.test( + "getRegionFromTimezone returns null for nullish or empty input", + () => { + assertEquals(getRegionFromTimezone(null), null); + assertEquals(getRegionFromTimezone(undefined), null); + assertEquals(getRegionFromTimezone(""), null); + }, +); + +Deno.test( + "getRegionFromTimezone lookup is case-sensitive (IANA standard)", + () => { + // IANA timezone identifiers are case-sensitive: "africa/tunis" is not valid. + assertEquals(getRegionFromTimezone("africa/tunis"), null); + assertEquals(getRegionFromTimezone("AFRICA/TUNIS"), null); + }, +); + +Deno.test( + "normalizePhoneNumber accepts unprefixed national number when region is given", + () => { + // The bug: `21697522154` (no `+`) was rejected because no region was known. + // With defaultRegion="TN", the parser recognises the Tunisian national format. + assertEquals(normalizePhoneNumber("21697522154", "TN"), "+21697522154"); + // And the same logic works for any other mapped region. + assertEquals(normalizePhoneNumber("0612345678", "FR"), "+33612345678"); + assertEquals(normalizePhoneNumber("2345678901", "US"), "+12345678901"); + }, +); + +Deno.test( + "normalizePhoneNumber keeps international `+` numbers unchanged", + () => { + // Even without a region, a number that already starts with `+` is parsed + // correctly because the `+` forces international-format interpretation. + assertEquals(normalizePhoneNumber("+21697522154"), "+21697522154"); + // Passing a region that matches does not alter the result. + assertEquals(normalizePhoneNumber("+21697522154", "TN"), "+21697522154"); + // Passing an unrelated region still respects the `+` and returns the same E.164. + assertEquals(normalizePhoneNumber("+21697522154", "FR"), "+21697522154"); + assertEquals(normalizePhoneNumber("+33123456789", "FR"), "+33123456789"); + assertEquals(normalizePhoneNumber("+33123456789", "TN"), "+33123456789"); + }, +); + +Deno.test( + "normalizePhoneNumber rejects numbers that are too short for the region", + () => { + // 123 has only 3 digits, too short for any country. + assertEquals(normalizePhoneNumber("123", "FR"), null); + assertEquals(normalizePhoneNumber("123", "TN"), null); + assertEquals(normalizePhoneNumber("123", "US"), null); + }, +); + +Deno.test( + "normalizePhoneNumber rejects unprefixed numbers without a region", + () => { + // No `+` and no region: the parser has no way to know the country. + assertEquals(normalizePhoneNumber("21697522154"), null); + assertEquals(normalizePhoneNumber("1234567890"), null); + assertEquals(normalizePhoneNumber("0612345678"), null); + }, +); + +Deno.test("normalizePhoneNumber rejects mismatched region/number pairs", () => { + // 21697522154 is a Tunisian national-format number, not a French one. + // Passing "FR" makes the parser treat it as a French national number, + // which fails the length/digit check and returns null. + assertEquals(normalizePhoneNumber("21697522154", "FR"), null); +}); + +Deno.test( + "region derived from timezone unlocks Tunisian number parsing", + () => { + // The full end-to-end flow: derive a region from the user's timezone, + // then use it to parse a national-format phone number. + const tunisianTimezone = "Africa/Tunis"; + const region = getRegionFromTimezone(tunisianTimezone); + assertEquals(region, "TN"); + assertEquals(normalizePhoneNumber("21697522154", region), "+21697522154"); + }, +); + +Deno.test( + "region derived from unmapped timezone does not unlock parsing", + () => { + // "UTC" is not in the mapping table, so getRegionFromTimezone returns null + // and normalizePhoneNumber falls back to international-only parsing. + const region = getRegionFromTimezone("UTC"); + assertEquals(region, null); + assertEquals(normalizePhoneNumber("21697522154", region), null); + // But the same user could still send an international-format number. + assertEquals(normalizePhoneNumber("+21697522154", region), "+21697522154"); + }, +); diff --git a/supabase/functions/sms-campaigns/utils/timezone-region.ts b/supabase/functions/sms-campaigns/utils/timezone-region.ts new file mode 100644 index 000000000..f357ff71a --- /dev/null +++ b/supabase/functions/sms-campaigns/utils/timezone-region.ts @@ -0,0 +1,65 @@ +/** + * Maps IANA timezone strings to 2-letter country codes. + * + * Used to derive a `defaultRegion` for `libphonenumber-js` so that + * unprefixed national-format phone numbers (e.g. `21697522154`) can be + * parsed when the calling user is in a known region. + * + * Mappings are intentionally limited to timezones that map to a single + * country. Ambiguous zones (e.g. `UTC`, `Europe/London` which is shared + * across the UK and overseas territories) are deliberately omitted and + * return `null`. + * + * To add a mapping, append a key/value pair to `TIMEZONE_TO_REGION`. + * Keep entries sorted by timezone name for easy scanning. + */ +const TIMEZONE_TO_REGION: Record = { + // Africa + "Africa/Algiers": "DZ", + "Africa/Cairo": "EG", + "Africa/Casablanca": "MA", + "Africa/Tunis": "TN", + // Europe + "Europe/Amsterdam": "NL", + "Europe/Berlin": "DE", + "Europe/Brussels": "BE", + "Europe/London": "GB", + "Europe/Luxembourg": "LU", + "Europe/Madrid": "ES", + "Europe/Paris": "FR", + "Europe/Rome": "IT", + "Europe/Zurich": "CH", + // North America + "America/Chicago": "US", + "America/Denver": "US", + "America/Los_Angeles": "US", + "America/Montreal": "CA", + "America/New_York": "US", + "America/Toronto": "CA", + "America/Vancouver": "CA", +}; + +/** + * Map an IANA timezone string to a 2-letter ISO country code. + * + * Returns `null` for timezones that are not in the mapping table, + * including `UTC` and any timezone that does not unambiguously map + * to a single country. A `null` or empty input also returns `null`. + * + * The returned value is suitable for the `defaultRegion` parameter + * of `libphonenumber-js`'s `parsePhoneNumberFromString`. + * + * @param timezone - IANA timezone string (e.g. "Africa/Tunis") + * @returns 2-letter country code (e.g. "TN") or `null` if unmapped + * + * @example + * getRegionFromTimezone("Africa/Tunis") // => "TN" + * getRegionFromTimezone("UTC") // => null + * getRegionFromTimezone(undefined) // => null + */ +export function getRegionFromTimezone( + timezone: string | null | undefined, +): string | null { + if (!timezone) return null; + return TIMEZONE_TO_REGION[timezone] ?? null; +} From 5214aa164ed1927d45b4a1476477a401a5734c47 Mon Sep 17 00:00:00 2001 From: Malek Salem Date: Sat, 4 Jul 2026 00:13:16 +0100 Subject: [PATCH 06/55] feat(frontend): add auto-detect and re-detect to SMS gateway UI Backs the new backend spec discovery flow: - ProviderForm gets an Auto-detect button next to the baseUrl field (simple-sms-gateway only) that calls the dryRun endpoint and pre-fills the override fields with the discovered values. - Three optional override fields (Endpoint, Phone field, Message field) let the user confirm or correct the discovery. - SmsFleetManagement gets a Re-detect button per gateway that calls the new /redetect endpoint and toasts the result. - Types and Pinia store updated; existing tests updated to mock the new methods. --- .../src/components/sms-fleet/ProviderForm.vue | 216 ++++++++++++++++-- .../sms-fleet/SmsFleetManagement.vue | 62 ++++- frontend/src/stores/sms-fleet.ts | 123 +++++++++- frontend/src/types/sms-fleet.ts | 49 ++++ .../e2e/sms-fleet-gateway-management.test.ts | 8 + frontend/tests/stores/smsFleet.test.ts | 3 + 6 files changed, 437 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/sms-fleet/ProviderForm.vue b/frontend/src/components/sms-fleet/ProviderForm.vue index 65d359acf..9a913f5a5 100644 --- a/frontend/src/components/sms-fleet/ProviderForm.vue +++ b/frontend/src/components/sms-fleet/ProviderForm.vue @@ -1,11 +1,17 @@