Skip to content

fix(sms): drop iOS gateway and harden simple-sms-gateway flow - #2862

Merged
malek10xdev merged 72 commits into
mainfrom
fix/qa-bugs-batch
Jul 28, 2026
Merged

fix(sms): drop iOS gateway and harden simple-sms-gateway flow#2862
malek10xdev merged 72 commits into
mainfrom
fix/qa-bugs-batch

Conversation

@malek10xdev

@malek10xdev malek10xdev commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Port SMS gateway mock from Supabase edge function to standalone Express+Bun microservice
  • Fix critical bugs in SMS campaign processor (infinite loop, unsubscribe exclusion, gateway counters)
  • Add comprehensive test coverage via 6 parallel agent lanes

Changes

1. SMS Gateway Mock Microservice (micro-services/sms-gateway-mock/)

  • New standalone Express+Bun service on port 8085
  • Routes: /health, /config, /messages, /:provider/send-sms, /smsgate/3rdparty/v1/messages
  • Per-provider config overrides (successRate, failStatusCode, failMessage)
  • Campaign ID tracking via X-Campaign-Id header
  • 51/51 Jest tests passing

2. SMS Campaign Processor Fixes (supabase/functions/sms-campaigns-process/index.ts)

Critical fixes:

  • Infinite loop fix: Moved unsubscribe exclusion check before while loop (was causing infinite loop on unsubscribed recipients)
  • Unsubscribe exclusion: Added resolveGatewayForNonFleetMode() helper, fetches unsubscribed phones and skips them
  • Gateway counter fix: Moved sent_today increment out of fleet-only block (was broken for non-fleet mode)

Variable substitution fixes:

  • Added code to buildSmsTemplateContext() (was rendering empty)
  • Moved unsubscribeToken/unsubscribeUrl computation above main template render
  • Added campaignId: resolvedCampaignId to provider send() args

3. Edge Function Cleanup

  • Deleted supabase/functions/sms-gateway-mock/ (replaced by microservice)
  • Updated dev workflow to use ngrok tunnel for local testing

4. Test Report (docs/testing/sms-campaign-test-report-2026-07-27.md)

  • Consolidated findings from 6 parallel test lanes
  • Documents all bugs found and fixes applied
  • Cross-cutting bug analysis

Testing

6 parallel test lanes executed (2 providers × 3 scenarios):

  • simple-sms-gateway × happy path, partial failure, unsubscribe flow
  • smsgate × happy path, partial failure, unsubscribe flow

Sync re-tests after fixes:

  • Lane 1: PASS (all 7 verification points)
  • Lane 2: PARTIAL PASS (Bug A fix works, 2 minor issues remain)
  • Lane 3: PASS (all 3 unsubscribed contacts excluded)
  • Lane 6: PASS (all 2 unsubscribed contacts excluded)

Integration tests: 8/8 passing

Remaining Non-Blocking Issues

  1. Unsubscribe URL regenerated on retry (minor)
  2. campaignId not reaching mock via ngrok (ngrok header forwarding, not code)

Related

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.
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.
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.
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.
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'.
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.
The Auto-detect button in the frontend posts to
sms-campaigns/fleet/gateways?dryRun=true and expects back a
{ discoveredSchema, reachabilityTest } preview without persisting.
The sms-fleet edge function had this short-circuit, but the
sms-campaigns edge function (which also exposes POST /fleet/gateways
for in-app flows) silently ignored the query param and always
inserted, so the Auto-detect toast reported 'Could not auto-detect
schema' even when the gateway was reachable.

Mirrors the sms-fleet behaviour: between the finalConfig.bodySchema
assignment and the .from('sms_fleet_gateways').insert(...) call,
return the discovered schema + reachability probe when
?dryRun=true is set.

Adds fleet-gateways-dryrun.test.ts with two source-level regression
tests: one for the sms-campaigns handler, one parity check for
sms-fleet so both routes stay in sync.
The earlier auto-discover approach (OpenAPI spec discovery + dynamic
body building) is dropped because it does not work reliably across the
diverse Simple SMS Gateway apps in the wild — each app embeds its spec
differently and many don't expose a discoverable schema at all.

Replaces it with a small curated registry of known apps, each with its
exact request body shape pinned in code:

- Simple SMS Gateway (Android) — { phone, message }
- SMS Gateway (iOS, App Store id 6767250233) — { to, message, id }

A new SmsGatewayIosProvider lives in its own file (no overloading of
the Android provider). mod.ts dispatches based on
config.appId / SMS_GATEWAY_IOS_APP_ID. The frontend setup dialog now
offers both apps in the 'How to install' picker with download links
and per-app setup steps; the provider dropdown emits the right appId.

The reachability test now POSTs to /send-sms (2xx/3xx/4xx = reachable)
instead of the spec discovery + HEAD dance. bodySchema, overrides,
the dryRun=true branch, the redetect route, and the Auto-detect /
Re-detect UI are all removed.

20 files changed, 700 insertions(+), 1923 deletions(-).
The earlier refactor introduced 'sms-gateway-ios' as a separate
provider enum value and added a 'config.appId' field as a secondary
dispatch key. Both turned out to be unnecessary complexity:

- The user wants the enum to NOT mention 'ios' or 'android' — just
  the app's public name. The iOS app is called 'SMS Gateway' on the
  App Store, so the enum value is now 'sms-gateway' (matches the
  app, not the OS).
- 'gateway.provider' is the sole dispatch key. 'config.appId' is
  removed entirely. Existing rows without appId still work because
  the Android provider is the default fallback.

A new sms-gateway-provider.ts (renamed from sms-gateway-ios-provider.ts)
implements the iOS app's { to, message, id } body shape. mod.ts
dispatches on the provider string.

The cron processor in sms-campaigns-process/index.ts was missed in
the earlier refactor and hardcoded the Android provider in 3 places.
Now it dispatches on gateway.provider, mirroring the same logic
that's in sms-campaigns/index.ts.

A new migration 20260709120000_add_sms_gateway_provider_to_fleet.sql
widens the sms_fleet_gateways_provider_check constraint to allow
'sms-gateway' so existing rows can be UPDATEd in place.

This fixes the bug where the local campaign ee6ee308-... was stuck
in 'processing': the cron picked it up, the provider was built with
the Android shape { phone, message }, the iOS app rejected it, and
the recipient never got marked as sent or failed. After this fix
+ the UPDATE that flips the user's gateway to provider='sms-gateway',
sending the same campaign will use the iOS provider and succeed.

14 files changed, 349 insertions(+), 428 deletions(-).
…tion

- Extract probeGatewayReachability + extractSimpleSmsGatewayBaseUrl +
  joinUrl to supabase/functions/_shared/gateway-probe.ts so the
  duplicated copies in sms-fleet/index.ts and sms-campaigns/index.ts
  stay in sync. The probe now picks the request body shape per
  provider (Android: { phone, message }; iOS: { to, message, id })
  instead of sending both fields to both apps.

- Type createProviderFromGateway(provider: SmsGatewayProvider) in
  sms-campaigns/index.ts. Renamed the existing class to
  SmsGatewayProviderImpl to free the name for the string-union type
  alias in providers/mod.ts; the test file uses an aliased import so
  its new SmsGatewayProvider(...) calls stay unchanged.

- Rename migration 20260709120000_add_sms_gateway_provider_to_fleet.sql
  to 20260709120000_widen_sms_fleet_gateways_provider_check.sql to
  describe the actual SQL change (widening a CHECK constraint, not
  adding to a PG enum). Updated the leading comment to match.

- Rename i18n keys in FleetGatewaySelector.vue from
  ios_sms_gateway_setup_* to sms_gateway_setup_* and ios_app_store to
  open_app_store in both the en and fr blocks. Kept ios_sms_gateway
  (section header) unchanged since it still describes the iOS app.

- Convert the 7-line block comment in sms-gateway-provider.ts to a
  proper JSDoc on the class. Drop the unused SMS_GATEWAY_DOWNLOAD_URL
  export — the App Store URL is hardcoded in the frontend instead.

Verification: deno check (clean), deno test (44/45 sms-campaigns, 5/9
sms-fleet — all failures are pre-existing), tsc (no new errors in
touched files), eslint (0 errors), supabase db lint (no new errors).
The Deno worker running the SMS campaign processor was being hard-killed
by the Supabase supervisor (wall-clock limit) before the finally block
could update the campaign status. The beforeunload handler only saved
partial sent/failed counts but left status as 'processing' forever, and
the 10-min 'stale processing' recovery kept reprocessing the same stuck
campaign in an infinite loop. Confirmed by the local edge function logs
for campaign 9ac9ddd3-...: 'Starting SMS campaign processing' at
10:42:14.453, then complete silence for 18+ minutes.

The user also reported: 'I clicked Send SMS campaign, still showing
processing. Add logs if there are errors! And if error, frontend status
should be updated — processing is misleading.'

Changes:
- Add last_error column to private.sms_campaigns and surface it via
  get_sms_campaigns_overview RPC so the frontend can show *why* a
  campaign ended up in 'failed' status.
- In sms-campaigns-process/index.ts:
  - Add a 140s wall-clock watchdog inside the recipient loop. When the
    limit is hit, set processingError and break out so the finally block
    can write 'failed' + last_error.
  - The finally block now writes last_error when processingError is set.
  - beforeunload now marks status='failed' with a clear last_error
    ('Worker killed by wall-clock limit'). Idempotent via
    .eq('status', 'processing') so the finally block wins on clean
    shutdowns.
  - The 10-min 'stale processing' recovery no longer reprocesses
    campaigns where no recipient has been attempted — it marks them as
    'failed' with last_error='Worker was killed before any recipient
    was attempted'. This breaks the infinite reprocess loop. Campaigns
    with partial progress still get reprocessed as before.
  - Add structured logger.warn / logger.error at every error path in the
    recipient loop (provider failure + send() throw) with recipientId,
    phone, attempt, provider, error message, and stack trace.
- Frontend:
  - Add last_error?: string | null to CampaignOverview type and
    database.types.ts (Insert/Update/Row).
  - campaigns store passes last_error through to the mapped result.
  - campaigns page renders a red 'Error: ...' sub-line below the
    status when status === 'failed' && last_error is non-null.
  - Add i18n key 'campaign_last_error' in en + fr.

Verification:
- deno check on sms-campaigns-process: clean.
- tsc --noEmit: 0 new errors in touched files (44 pre-existing in
  unrelated files).
- eslint on touched files: 0 errors.
- supabase db lint: 0 new errors (pre-existing errors in
  populate_refined / get_unified_campaigns_overview etc. unchanged).
- Manual DB check: campaign 9ac9ddd3-... now status='failed' with
  last_error='Worker was killed before any recipient was attempted',
  completed_at=2026-07-09 11:03:37. The 11:00 cron hit the new
  stale-recovery path and unblocked the loop.
To identify where the Deno worker hangs in the recipient loop (it gets
silently killed by the 150s wall-clock limit). Next time a campaign
hangs, the edge function logs will reveal exactly which async step is
blocking. No logic changes — only logger.info/warn/error calls with
campaignId + recipientId context and Date.now() timing for each await.

Logs added in supabase/functions/sms-campaigns-process/index.ts:
- recordClickLink (5-insert retry loop): per-attempt start/done
- injectTrackers: function start, per-URL start/done, function done
- Top of recipient loop: 'Processing recipient' with index/total
- Step: X / Step done: X before/after each await in the loop body
- 'Calling provider.send' / 'provider.send returned' boundary with
  elapsedMs + messageLength — the iOS gateway HTTP call is the most
  important hang point to instrument
- Fleet-mode: gateway lookup, provider ready/creation failed
- End-of-iteration: 'Recipient loop iteration done' with totalElapsedMs

Logs added in supabase/functions/sms-campaigns/utils/short-link.ts:
- shortenUrl start/done/non-ok/error-payload/failed
- (shortenUrl's fetch already has a 5s AbortSignal.timeout — not a
  likely hang source, but the logs confirm.)
The trigger_sms_campaign_processor() function read supabase_url from
current_setting('app.settings.supabase_url', true), which returns NULL
inside the pg_cron context. The function then silently RETURN'd, so
the cron job appeared to succeed in cron.job but never actually
invoked the sms-campaigns-process edge function — that is why no SMS
campaigns ever fired.

This migration rewrites the function to read BOTH project_url and
service_role_key from vault.decrypted_secrets and RAISE EXCEPTION on
missing secrets (fail-fast). The request_id is captured and emitted
as RAISE NOTICE so the postgres log shows the queued HTTP request.

Verified locally:
- pg_get_functiondef returns the new definition
- SELECT private.trigger_sms_campaign_processor() runs without error
- net.http_request contains the queued POST to
  http://kong:8000/functions/v1/sms-campaigns-process/process
The shared edge-function logger calls JSON.stringify directly on the
log entry. JSON.stringify drops any key whose value is `undefined`, so
optional fields (e.g. campaignId when the cron fires with no payload)
silently vanish from the structured log output — making it impossible
to tell from a log line whether the field was missing, null, or just
not set.

Normalize `undefined` to `null` in the logger so all fields are
always present in the JSON. Real values, null, 0, and "" are
preserved; only `undefined` is rewritten.

Also tighten the `Campaign query result` log in the SMS processor to
use `?? null` so the structured fields (campaignId, campaignStatus,
provider, fleetMode, fetchError) always appear, even before the
logger change ships.

Verified:
- deno check: clean
- smoke test: log('All undefined', {a:undefined, b:undefined}) now
  emits {"a":null,"b":null} instead of {a:undefined,b:undefined}
  (i.e. the keys are present); null/0/""/real values are preserved.
…nges

The user asked to align fix/qa-bugs-batch with origin/main and keep only
the iOS SMS Gateway provider code (commits 0bb8f5a and c065917 in
particular). This revert undoes the 5 non-iOS commits on top of the
iOS-foundation stack:

- 2f13313 refactor(sms-fleet): dedup gateway probe, tighten types, rename migration
- 6c2eead fix(sms-campaigns): surface errors and break stuck-processing loop
- 6f589e8 chore(sms-campaigns): add per-step debug logging in processor
- 01ea0e7 fix(sms-campaigns): read project_url from vault in cron trigger
- 852eeff fix(logger): surface undefined fields as null in structured logs

Resulting tree: origin/main + 9 iOS-foundation commits (00dbc1a through
c065917) + this revert. The 4 migrations added by 6c2eead and 01ea0e7
are no longer in the tree; their schema changes (last_error column,
vault-based trigger function) are already applied to the dev DB and
will need a follow-up migration to undo in QA/prod if desired.

No commit-body or push yet — user will review before force-pushing.
…osis

The processor is stuck after 'Starting SMS campaign processing' for
fleet-mode campaigns with an iOS gateway. Last successful log is at
line 525 ('Starting SMS campaign processing'), next expected log is
the recipients SELECT result inside the IIFE.

Added 10 console.log statements with Date.now() timestamps around
each top-level await in the pre-loop setup:

- about to UPDATE / UPDATE done (mark status=processing)
- kicking off IIFE / IIFE entered
- about to SELECT / SELECT done (sms_campaign_recipients)
- about to SELECT / SELECT done (sms_campaign_recipient_gateways)
- about to SELECT / SELECT done (sms_fleet_gateways, guarded by
  gatewayIds.length > 0 to avoid Supabase .in() with empty array)

Also kept the user's existing uncommitted debug log around the
provider.send() boundary ("sending..." / "seeeeeeeeeeeeeeent").

The log prefix 'DEBUG-PROCESSOR[<ms>]' lets the user grep the edge
function log output to see exactly which await hangs.

No logic changes. The gatewayIds.length > 0 guard was added because
.in('id', []) would error on Supabase; pre-existing code had this
latent bug but it was only triggered now that we're in fleet mode
with empty assignments.

Verified:
- deno check: clean
- the previous syntax error (missing close-brace for the if block) is
  fixed in this commit.
The sms-campaigns-processor edge function uses an IIFE kicked off via
EdgeRuntime.waitUntil() after returning 202. With the default
oneshot policy, the local Supabase edge runtime terminates the worker
the moment the 202 response is sent, killing the IIFE before the
background DB queries can complete.

Symptom: cron-triggered campaigns hang silently in the local dev
environment. Last log is 'about to SELECT sms_campaign_recipients'
(or similar await inside the IIFE) and the worker is killed by the
150s wall-clock limit before any background work completes. Database
shows no locks, no idle-in-transaction backends — the request never
reaches Postgres because the worker is gone.

per_worker keeps the worker alive between requests, matching the
Supabase Cloud production behavior where background tasks complete
normally. Hot reload (oneshot's main use case) is no longer
relevant for this project since we deploy to QA/prod via Docker.

The cfg-level hint comment above the line already documents
per_worker as the 'load testing' option, but it is the only setting
that allows local testing of any edge function that uses
EdgeRuntime.waitUntil().
Campaign 2f4be096 completed with sentCount=0, failedCount=0 but no
'sending' or 'seeeeeeeeeeeeeeent' log appeared. The recipient loop
either didn't run, or the provider creation threw before reaching
currentProvider.send().

Added 7 new console.log statements:

- 'recipient loop iteration start' (at the top of each iteration,
  includes isFleetMode, selectedProvider, fleetGatewaysCount,
  gatewayAssignmentsSize so we can see if the gateway assignment
  is missing)
- 'about to call currentProvider.send' (before the send, includes
  providerName and messageLength)
- 'currentProvider.send result' (after the send, includes
  success/error/messageId)
- 'marking recipient as failed (retries exhausted)' (when the
  while loop exits without success)
- 'recipient loop iteration end (sent)' (when the recipient is
  marked as sent)
- 'recipient loop done' (after the for loop, includes final counts)

Next campaign run will reveal which path is being taken:
- If 'recipient loop iteration start' doesn't appear → the
  recipients array is empty despite SELECT returning count=1
- If it appears but 'about to call currentProvider.send' doesn't
  → the throw 'Failed to create provider for gateway' is being
  hit (gateway assignment missing or provider config invalid)
- If 'currentProvider.send result' shows success=true but no SMS
  was delivered → the iOS app responded 200 OK but didn't actually
  send (the app isn't running on the phone)
…le shadowing

The recipients SELECT was wrapped in a try/catch that declared
'const { data: recipients } = await supabaseAdmin...'. The 'recipients'
in the destructure created a new variable scoped to the try block,
shadowing the outer 'let recipients: any[] | null = []' declared
immediately above. As a result:

- The 'SELECT sms_campaign_recipients done' log (inside try) showed
  count: 1 (inner scope)
- The for loop (outside try) iterated over the outer 'recipients',
  which was still [] (the default)
- The 'recipient loop done' log showed recipientsProcessed: 0
- sentCount and failedCount stayed at 0
- The campaign 'completed successfully' without processing any
  recipient

Fixed by renaming the inner destructure to 'const { data }' and
assigning to the outer 'recipients = data || []' after the await.
The outer variable now reflects the actual query result, so the
for loop iterates correctly.

The try/catch around the SELECT is preserved (it lets the function
'recover gracefully' from a transient DB error and complete with 0
sent/failed instead of 'completed with error'). The 'error: ' log
inside catch is left in place for visibility.

Symptom discovered via the diagnostic logs added in 7394db9 +
9e9fca2: 'recipient loop iteration start' never appeared despite
SELECT count being 1.
To diagnose why 'fleetGatewaysCount: 0' and 'gatewayAssignmentsSize: 0'
appear in recipient loop iteration start, despite the assignments
SELECT returning count: 1 with a valid gateway_id in the DB.

The new log prints:
- assignmentsCount
- firstAssignment (the raw row from the SELECT)
- gatewayIds (after .map().filter())

This will reveal whether the data shape from the Supabase JS client
matches what the code expects, or if 'gateway_id' is null/undefined
at runtime for some reason.
Two logic bugs in supabase/functions/sms-campaigns-process/index.ts:

1. assignments variable shadowing: the let assignments = [] above the
   try block was shadowed by const { data: assignments } inside the
   try. The 'SELECT ... done' log inside try used the inner
   variable (count: 1), but the 'about to filter fleet gateways'
   log after try used the outer variable (assignmentsCount: 0,
   firstAssignment: null, gatewayIds: []). Result: gatewayAssignments
   map was empty, the recipient loop threw 'Failed to create provider
   for gateway unknown' for every recipient. Same pattern as the
   recipients shadowing fixed in 8da04cb.

2. Misleading 'completed' status when every recipient failed: the
   finally block's finalStatus was 'processingError ? failed :
   completed'. processingError is only set by the outer try/catch;
   the inner try/catch around the SELECT and the inner try/catch
   inside the recipient while-loop catch errors but never set
   processingError. So a campaign with failedCount: 1, sentCount: 0
   was marked 'completed' on the frontend. Now: 'failed' if either
   processingError is set OR failedCount > 0 && sentCount === 0.
   Partial success (sentCount > 0 && failedCount > 0) still reports
   'completed' since the successful recipients are done.

Verified:
- deno check: clean
- manual: campaign now correctly populates gatewayAssignments and
  fails the recipient with the right lastError
Removed ~30 DEBUG-PROCESSOR console.log calls and the
'sending.../seeeeeeeeeeeeeeent' user boundary markers that were
added during the hang / shadowing / fleet-gateway investigation
(commits 7394db9, 9e9fca2, 8da04cb, 64ced39). These were
temporary debug aids; the production code path uses
logger.info / logger.error from _shared/logger.ts.

Added one structured log at the currentProvider.send() boundary
(logger.info 'SMS provider send') that captures campaignId,
recipientId, provider, messageLength, success, error, messageId,
and elapsedMs — sufficient to diagnose any future per-recipient
send failure without the per-line noise of the previous logs.

Preserved the user's uncommitted console.log(parsed.data) at
line 421 (separate debug aid, not part of this cleanup).

Verified:
- deno check: clean
- grep confirms only line 421 has console.log in the IIFE file
… flicker

The accept callback in confirmDelete ran the store's deleteGateway
synchronously, which set isLoading.value = true and triggered a
reactive re-render. The re-render hit <ConfirmDialog /> while
PrimeVue's dialog-close state was mid-transition, causing the
dialog to briefly re-show (flicker: close -> flash open -> close).

Wrapping the delete call in nextTick(() => { ... }) defers the
reactive update until after Vue's next flush, which runs after
PrimeVue's close animation completes. The dialog now closes cleanly
on the first click with no flicker.

Only this file changed. No other $confirm.require calls in the
codebase were modified (out of scope for this fix).
When senderFilter === 'all' in pages/campaigns.vue, both
EmailSenderManagement and SmsFleetManagement are mounted
simultaneously (v-show on each wrapper div). Each component
had its own <ConfirmDialog /> in its template, so the delete
confirmation was rendered twice as stacked dialogs. PrimeVue's
useConfirm is a singleton but every <ConfirmDialog /> subscribes
to the service and renders, so two dialogs appeared.

Moved the single <ConfirmDialog /> to src/app.vue (next to the
existing global <Toast /> components) and removed the local
<ConfirmDialog /> and its import from both management components.
The useConfirm() calls and $confirm.require() invocations in the
components are unchanged — they call the singleton service which
the single global <ConfirmDialog /> now subscribes to.

Fixes the double-dialog for delete-gateway and (latently) for
delete-email-sender. The previous nextTick() wrapper for the
accept callback stays; it prevents the close-time flicker from
the reactive update, which is independent of this structural fix.

Verified:
- grep -rn 'ConfirmDialog' frontend/src/ src/app.vue:
  exactly one match (in src/app.vue)
- eslint: clean
- prettier: clean
- only src/app.vue + the two component files changed
…ecret

- Replace any[] / any types in sms-campaigns-process with typed interfaces
- Remove unused SmsGatewayCredentials import in sms-campaigns/index.ts
- Fix minor lint issues in provider + test files (async, undefined, concat)
- Replace hardcoded Google OAuth secret in config.toml with placeholder
- Add global.fetch to createSupabaseAdmin client
- Add missing function entries (delete-user, email-templates, imap, mail,
  passive-mining, sms-campaigns-process, sms-fleet, whatsapp-campaigns,
  whatsapp-campaigns-process, whatsapp-webhook)
- Set verify_jwt = true for all non-webhook functions
- Fix campaigns-track import_map/entrypoint that incorrectly pointed to
  email-campaigns
- Keep verify_jwt = false for webhooks (campaigns-track, whatsapp-webhook)
- Replace placeholder secret in config.toml with non-secret value
- Use property shorthand in createSupabaseAdmin global.fetch
- Return provider instead of void to satisfy assertThrows lint
- Add deepsource-disable comment for high cyclomatic complexity IIFE
- Delete the 7-line block comment explaining the iOS wire contract;
  the send() body and SmsGatewayCredentials type are self-explanatory.
- Rename shadowed 'errorMessage' variable in catch to 'message' (it
  was shadowing the errorMessage(data) helper).
- Add unit test for the timeout catch branch to guard against
  regression of the shadowing fix.
- Remove the JSDoc above extractSimpleSmsGatewayBaseUrl; the function
  name and body show what it does.
- Replace the 12-line JSDoc on probeGatewayReachability with a
  single-line summary.
- Remove the 6-line inline comment in the POST /gateways handler;
  the provider check + probe call are self-documenting.
…it enforcement

- Fix envelope-vs-data bug: if (!success) -> if (!success.data)
- Fix sentCount over-increment: move increment after quota check passes
- Fix log output: log result.data instead of envelope object
- Add DB CHECK constraint for sent_today <= daily_limit
- Add monthly limit check to increment_gateway_sent_count_atomic
- Add real-time campaign counter increments per recipient
…rage, /messages API

- Refactor sms-gateway-mock to support :provider/send-sms routing
- Add SMSGate provider with Basic Auth and correct request/response format
- Add in-memory message store with 10k ring buffer cap
- Add GET /messages API with filtering, pagination, PII redaction
- Add DELETE /messages endpoint
- Add per-provider config overrides
- Add production environment guard
- Add X-Campaign-Id header support to all providers
- Fix config deep merge: partial global updates no longer overwrite entire object
- Fix zod default() on nested objects: use explicit default constants
- Fix headers access: c.req.headers -> c.req.raw.headers
…outing

- Fix resetMockServer(): replace configSchema.parse({}) with direct defaults
- Update all tests to use /:provider/send-sms paths
- Add tests for smsgate routing, Basic Auth, 404/401 errors
- Add tests for /messages API: filters, pagination, PII redaction, token gate
- Add tests for DELETE /messages, per-provider config, resetMockServer clears store
- Rewrite README with multi-provider architecture, /messages API, PII redaction
- Add SMSGate provider documentation with Basic Auth
- Add 3 new test scenarios: SMSGate provider, message history API, variable substitution
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Coverage Report

Passed

Commit: 3ce1512

Summary

Name Stmts Branch Funcs Lines
🟡 Total 44.8% 41.9% 51.5% 45.1%
All files
Name Stmts Branch Funcs Lines
🔴 backend/src/controllers/smtp-senders.controller.ts 19.3% 14.8% 29.4% 19.5%
No coverage changes
Name Stmts Branch Funcs Lines
🔴 backend/src/controllers/smtp-senders.controller.ts 19.3% 14.8% 29.4% 19.5%

Generated by Test Coverage Reporter for commit 3ce1512

- Remove non-null assertion in getEffectiveConfig (JS-0339)
- Use template literal for body truncation (JS-0246)
- Remove unnecessary async from /messages handler (JS-0116)
- Use prop shorthand for messageId (JS-0240)
- Replace debug-token with <your-mock-token> placeholder in README (SCT-A000)
- Remove unused Config interface (JS-0356)
- Remove unused configSchema (JS-0356)
- Fix non-null assertion in campaignIndex (JS-0339)
- Remove unused assertObjectMatch, assertRejects imports (JS-0356)
- Add skipcq comments for test mock async functions (JS-0116)
- Fix non-null assertion in smtp-senders.controller.ts (JS-0339)
…ngrok dev workflow

The sms-gateway-mock was a Hono/Deno Supabase edge function whose
in-memory message store reset on every cold start of the edge function
worker, making the /messages history API unreliable for test scenarios.

Port the service to a standalone Express + Bun + zod + winston
microservice at micro-services/sms-gateway-mock/, mirroring the
canonical micro-services/emails-fetcher pattern. The Bun process keeps
state alive for the lifetime of `bun run dev`, so /messages persists
across requests.

Highlights:
- 5 routes preserved 1:1 (/health, /config, /messages GET+DELETE,
  /:provider/send-sms) plus a /smsgate/3rdparty/v1/messages alias for
  the live SMSGate provider path
- Production guard stricter than the original (default-deny on
  unset NODE_ENV via Zod-validated env)
- 51 Jest + supertest cases across 7 files (vs 21 Deno tests before)
- Wire-up: root package.json scripts, run.sh, generate_env.sh,
  docker-compose.yml (intentional production crash), docker-compose.dev.yml
- Docs: micro-services/sms-gateway-mock/README.md documents 4 dev-tunnel
  options (ngrok, Cloudflare quick tunnel, host.docker.internal, same
  Docker network). docs/testing/sms-campaign-*.md updated.

The Supabase edge function (in Docker) cannot reach localhost on the
host, so the dev workflow is: `bun run dev:micro-services-sms-gateway-mock`
then `ngrok http 8085` then paste the URL into sms_fleet_gateways.base_url.

Files: 47 added, 10 modified, 6 deleted.
Follows up PR #2862.

Pre-commit hook skipped via --no-verify (lint-staged missing in worktree;
code already passed bun run lint / build / test:unit inside the new
microservice). Per AGENTS.md, explicit user consent obtained.
Comment thread micro-services/sms-gateway-mock/src/api.ts Dismissed
Comment thread micro-services/sms-gateway-mock/src/api.ts Dismissed
… gateway counter bugs

- Move unsubscribe exclusion check before while loop to prevent infinite loop
- Add resolveGatewayForNonFleetMode helper for non-fleet gateway tracking
- Move gateway sent_today increment out of fleet-only block
- Add code, unsubscribeUrl, campaignId to template context and send args
- Add test report documenting all findings
Leadminer Bot and others added 2 commits July 28, 2026 00:37
Resolve conflict in backend/src/controllers/smtp-senders.controller.ts:
  - kept HEAD code (semantic equivalent), trailing commas normalized by prettier

Resolves merge conflict on PR #2862
- docs/testing/: mock-service report, test plan, test report with ngrok URL/test user IDs
- .ignore: opencode slim/deepwork tooling un-ignore (local only)
@malek10xdev
malek10xdev merged commit b01f7f4 into main Jul 28, 2026
15 checks passed
@malek10xdev
malek10xdev deleted the fix/qa-bugs-batch branch July 28, 2026 00:05
malek10xdev added a commit that referenced this pull request Jul 28, 2026
…serDialog i18n (#2866)

* 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.

* 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.

* 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.

* 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.

* 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'.

* 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.

* fix(sms-campaigns): honor ?dryRun=true on POST /fleet/gateways

The Auto-detect button in the frontend posts to
sms-campaigns/fleet/gateways?dryRun=true and expects back a
{ discoveredSchema, reachabilityTest } preview without persisting.
The sms-fleet edge function had this short-circuit, but the
sms-campaigns edge function (which also exposes POST /fleet/gateways
for in-app flows) silently ignored the query param and always
inserted, so the Auto-detect toast reported 'Could not auto-detect
schema' even when the gateway was reachable.

Mirrors the sms-fleet behaviour: between the finalConfig.bodySchema
assignment and the .from('sms_fleet_gateways').insert(...) call,
return the discovered schema + reachability probe when
?dryRun=true is set.

Adds fleet-gateways-dryrun.test.ts with two source-level regression
tests: one for the sms-campaigns handler, one parity check for
sms-fleet so both routes stay in sync.

* refactor(sms-fleet): replace auto-discover with curated SMS app registry

The earlier auto-discover approach (OpenAPI spec discovery + dynamic
body building) is dropped because it does not work reliably across the
diverse Simple SMS Gateway apps in the wild — each app embeds its spec
differently and many don't expose a discoverable schema at all.

Replaces it with a small curated registry of known apps, each with its
exact request body shape pinned in code:

- Simple SMS Gateway (Android) — { phone, message }
- SMS Gateway (iOS, App Store id 6767250233) — { to, message, id }

A new SmsGatewayIosProvider lives in its own file (no overloading of
the Android provider). mod.ts dispatches based on
config.appId / SMS_GATEWAY_IOS_APP_ID. The frontend setup dialog now
offers both apps in the 'How to install' picker with download links
and per-app setup steps; the provider dropdown emits the right appId.

The reachability test now POSTs to /send-sms (2xx/3xx/4xx = reachable)
instead of the spec discovery + HEAD dance. bodySchema, overrides,
the dryRun=true branch, the redetect route, and the Auto-detect /
Re-detect UI are all removed.

20 files changed, 700 insertions(+), 1923 deletions(-).

* fix(sms-fleet): rename iOS provider, remove appId, fix processor

The earlier refactor introduced 'sms-gateway-ios' as a separate
provider enum value and added a 'config.appId' field as a secondary
dispatch key. Both turned out to be unnecessary complexity:

- The user wants the enum to NOT mention 'ios' or 'android' — just
  the app's public name. The iOS app is called 'SMS Gateway' on the
  App Store, so the enum value is now 'sms-gateway' (matches the
  app, not the OS).
- 'gateway.provider' is the sole dispatch key. 'config.appId' is
  removed entirely. Existing rows without appId still work because
  the Android provider is the default fallback.

A new sms-gateway-provider.ts (renamed from sms-gateway-ios-provider.ts)
implements the iOS app's { to, message, id } body shape. mod.ts
dispatches on the provider string.

The cron processor in sms-campaigns-process/index.ts was missed in
the earlier refactor and hardcoded the Android provider in 3 places.
Now it dispatches on gateway.provider, mirroring the same logic
that's in sms-campaigns/index.ts.

A new migration 20260709120000_add_sms_gateway_provider_to_fleet.sql
widens the sms_fleet_gateways_provider_check constraint to allow
'sms-gateway' so existing rows can be UPDATEd in place.

This fixes the bug where the local campaign ee6ee308-... was stuck
in 'processing': the cron picked it up, the provider was built with
the Android shape { phone, message }, the iOS app rejected it, and
the recipient never got marked as sent or failed. After this fix
+ the UPDATE that flips the user's gateway to provider='sms-gateway',
sending the same campaign will use the iOS provider and succeed.

14 files changed, 349 insertions(+), 428 deletions(-).

* refactor(sms-fleet): dedup gateway probe, tighten types, rename migration

- Extract probeGatewayReachability + extractSimpleSmsGatewayBaseUrl +
  joinUrl to supabase/functions/_shared/gateway-probe.ts so the
  duplicated copies in sms-fleet/index.ts and sms-campaigns/index.ts
  stay in sync. The probe now picks the request body shape per
  provider (Android: { phone, message }; iOS: { to, message, id })
  instead of sending both fields to both apps.

- Type createProviderFromGateway(provider: SmsGatewayProvider) in
  sms-campaigns/index.ts. Renamed the existing class to
  SmsGatewayProviderImpl to free the name for the string-union type
  alias in providers/mod.ts; the test file uses an aliased import so
  its new SmsGatewayProvider(...) calls stay unchanged.

- Rename migration 20260709120000_add_sms_gateway_provider_to_fleet.sql
  to 20260709120000_widen_sms_fleet_gateways_provider_check.sql to
  describe the actual SQL change (widening a CHECK constraint, not
  adding to a PG enum). Updated the leading comment to match.

- Rename i18n keys in FleetGatewaySelector.vue from
  ios_sms_gateway_setup_* to sms_gateway_setup_* and ios_app_store to
  open_app_store in both the en and fr blocks. Kept ios_sms_gateway
  (section header) unchanged since it still describes the iOS app.

- Convert the 7-line block comment in sms-gateway-provider.ts to a
  proper JSDoc on the class. Drop the unused SMS_GATEWAY_DOWNLOAD_URL
  export — the App Store URL is hardcoded in the frontend instead.

Verification: deno check (clean), deno test (44/45 sms-campaigns, 5/9
sms-fleet — all failures are pre-existing), tsc (no new errors in
touched files), eslint (0 errors), supabase db lint (no new errors).

* fix(sms-campaigns): surface errors and break stuck-processing loop

The Deno worker running the SMS campaign processor was being hard-killed
by the Supabase supervisor (wall-clock limit) before the finally block
could update the campaign status. The beforeunload handler only saved
partial sent/failed counts but left status as 'processing' forever, and
the 10-min 'stale processing' recovery kept reprocessing the same stuck
campaign in an infinite loop. Confirmed by the local edge function logs
for campaign 9ac9ddd3-...: 'Starting SMS campaign processing' at
10:42:14.453, then complete silence for 18+ minutes.

The user also reported: 'I clicked Send SMS campaign, still showing
processing. Add logs if there are errors! And if error, frontend status
should be updated — processing is misleading.'

Changes:
- Add last_error column to private.sms_campaigns and surface it via
  get_sms_campaigns_overview RPC so the frontend can show *why* a
  campaign ended up in 'failed' status.
- In sms-campaigns-process/index.ts:
  - Add a 140s wall-clock watchdog inside the recipient loop. When the
    limit is hit, set processingError and break out so the finally block
    can write 'failed' + last_error.
  - The finally block now writes last_error when processingError is set.
  - beforeunload now marks status='failed' with a clear last_error
    ('Worker killed by wall-clock limit'). Idempotent via
    .eq('status', 'processing') so the finally block wins on clean
    shutdowns.
  - The 10-min 'stale processing' recovery no longer reprocesses
    campaigns where no recipient has been attempted — it marks them as
    'failed' with last_error='Worker was killed before any recipient
    was attempted'. This breaks the infinite reprocess loop. Campaigns
    with partial progress still get reprocessed as before.
  - Add structured logger.warn / logger.error at every error path in the
    recipient loop (provider failure + send() throw) with recipientId,
    phone, attempt, provider, error message, and stack trace.
- Frontend:
  - Add last_error?: string | null to CampaignOverview type and
    database.types.ts (Insert/Update/Row).
  - campaigns store passes last_error through to the mapped result.
  - campaigns page renders a red 'Error: ...' sub-line below the
    status when status === 'failed' && last_error is non-null.
  - Add i18n key 'campaign_last_error' in en + fr.

Verification:
- deno check on sms-campaigns-process: clean.
- tsc --noEmit: 0 new errors in touched files (44 pre-existing in
  unrelated files).
- eslint on touched files: 0 errors.
- supabase db lint: 0 new errors (pre-existing errors in
  populate_refined / get_unified_campaigns_overview etc. unchanged).
- Manual DB check: campaign 9ac9ddd3-... now status='failed' with
  last_error='Worker was killed before any recipient was attempted',
  completed_at=2026-07-09 11:03:37. The 11:00 cron hit the new
  stale-recovery path and unblocked the loop.

* chore(sms-campaigns): add per-step debug logging in processor

To identify where the Deno worker hangs in the recipient loop (it gets
silently killed by the 150s wall-clock limit). Next time a campaign
hangs, the edge function logs will reveal exactly which async step is
blocking. No logic changes — only logger.info/warn/error calls with
campaignId + recipientId context and Date.now() timing for each await.

Logs added in supabase/functions/sms-campaigns-process/index.ts:
- recordClickLink (5-insert retry loop): per-attempt start/done
- injectTrackers: function start, per-URL start/done, function done
- Top of recipient loop: 'Processing recipient' with index/total
- Step: X / Step done: X before/after each await in the loop body
- 'Calling provider.send' / 'provider.send returned' boundary with
  elapsedMs + messageLength — the iOS gateway HTTP call is the most
  important hang point to instrument
- Fleet-mode: gateway lookup, provider ready/creation failed
- End-of-iteration: 'Recipient loop iteration done' with totalElapsedMs

Logs added in supabase/functions/sms-campaigns/utils/short-link.ts:
- shortenUrl start/done/non-ok/error-payload/failed
- (shortenUrl's fetch already has a 5s AbortSignal.timeout — not a
  likely hang source, but the logs confirm.)

* fix(sms-campaigns): read project_url from vault in cron trigger

The trigger_sms_campaign_processor() function read supabase_url from
current_setting('app.settings.supabase_url', true), which returns NULL
inside the pg_cron context. The function then silently RETURN'd, so
the cron job appeared to succeed in cron.job but never actually
invoked the sms-campaigns-process edge function — that is why no SMS
campaigns ever fired.

This migration rewrites the function to read BOTH project_url and
service_role_key from vault.decrypted_secrets and RAISE EXCEPTION on
missing secrets (fail-fast). The request_id is captured and emitted
as RAISE NOTICE so the postgres log shows the queued HTTP request.

Verified locally:
- pg_get_functiondef returns the new definition
- SELECT private.trigger_sms_campaign_processor() runs without error
- net.http_request contains the queued POST to
  http://kong:8000/functions/v1/sms-campaigns-process/process

* fix(logger): surface undefined fields as null in structured logs

The shared edge-function logger calls JSON.stringify directly on the
log entry. JSON.stringify drops any key whose value is `undefined`, so
optional fields (e.g. campaignId when the cron fires with no payload)
silently vanish from the structured log output — making it impossible
to tell from a log line whether the field was missing, null, or just
not set.

Normalize `undefined` to `null` in the logger so all fields are
always present in the JSON. Real values, null, 0, and "" are
preserved; only `undefined` is rewritten.

Also tighten the `Campaign query result` log in the SMS processor to
use `?? null` so the structured fields (campaignId, campaignStatus,
provider, fleetMode, fetchError) always appear, even before the
logger change ships.

Verified:
- deno check: clean
- smoke test: log('All undefined', {a:undefined, b:undefined}) now
  emits {"a":null,"b":null} instead of {a:undefined,b:undefined}
  (i.e. the keys are present); null/0/""/real values are preserved.

* revert: drop wall-clock, audit, debug-log, vault-cron, and logger changes

The user asked to align fix/qa-bugs-batch with origin/main and keep only
the iOS SMS Gateway provider code (commits 0bb8f5a and c065917 in
particular). This revert undoes the 5 non-iOS commits on top of the
iOS-foundation stack:

- 2f13313 refactor(sms-fleet): dedup gateway probe, tighten types, rename migration
- 6c2eead fix(sms-campaigns): surface errors and break stuck-processing loop
- 6f589e8 chore(sms-campaigns): add per-step debug logging in processor
- 01ea0e7 fix(sms-campaigns): read project_url from vault in cron trigger
- 852eeff fix(logger): surface undefined fields as null in structured logs

Resulting tree: origin/main + 9 iOS-foundation commits (00dbc1a through
c065917) + this revert. The 4 migrations added by 6c2eead and 01ea0e7
are no longer in the tree; their schema changes (last_error column,
vault-based trigger function) are already applied to the dev DB and
will need a follow-up migration to undo in QA/prod if desired.

No commit-body or push yet — user will review before force-pushing.

* chore(sms-campaigns): add console.log debug statements for hang diagnosis

The processor is stuck after 'Starting SMS campaign processing' for
fleet-mode campaigns with an iOS gateway. Last successful log is at
line 525 ('Starting SMS campaign processing'), next expected log is
the recipients SELECT result inside the IIFE.

Added 10 console.log statements with Date.now() timestamps around
each top-level await in the pre-loop setup:

- about to UPDATE / UPDATE done (mark status=processing)
- kicking off IIFE / IIFE entered
- about to SELECT / SELECT done (sms_campaign_recipients)
- about to SELECT / SELECT done (sms_campaign_recipient_gateways)
- about to SELECT / SELECT done (sms_fleet_gateways, guarded by
  gatewayIds.length > 0 to avoid Supabase .in() with empty array)

Also kept the user's existing uncommitted debug log around the
provider.send() boundary ("sending..." / "seeeeeeeeeeeeeeent").

The log prefix 'DEBUG-PROCESSOR[<ms>]' lets the user grep the edge
function log output to see exactly which await hangs.

No logic changes. The gatewayIds.length > 0 guard was added because
.in('id', []) would error on Supabase; pre-existing code had this
latent bug but it was only triggered now that we're in fleet mode
with empty assignments.

Verified:
- deno check: clean
- the previous syntax error (missing close-brace for the if block) is
  fixed in this commit.

* fix(config): set edge_runtime policy to per_worker for background tasks

The sms-campaigns-processor edge function uses an IIFE kicked off via
EdgeRuntime.waitUntil() after returning 202. With the default
oneshot policy, the local Supabase edge runtime terminates the worker
the moment the 202 response is sent, killing the IIFE before the
background DB queries can complete.

Symptom: cron-triggered campaigns hang silently in the local dev
environment. Last log is 'about to SELECT sms_campaign_recipients'
(or similar await inside the IIFE) and the worker is killed by the
150s wall-clock limit before any background work completes. Database
shows no locks, no idle-in-transaction backends — the request never
reaches Postgres because the worker is gone.

per_worker keeps the worker alive between requests, matching the
Supabase Cloud production behavior where background tasks complete
normally. Hot reload (oneshot's main use case) is no longer
relevant for this project since we deploy to QA/prod via Docker.

The cfg-level hint comment above the line already documents
per_worker as the 'load testing' option, but it is the only setting
that allows local testing of any edge function that uses
EdgeRuntime.waitUntil().

* chore(sms-campaigns): add recipient-loop diagnostic logs

Campaign 2f4be096 completed with sentCount=0, failedCount=0 but no
'sending' or 'seeeeeeeeeeeeeeent' log appeared. The recipient loop
either didn't run, or the provider creation threw before reaching
currentProvider.send().

Added 7 new console.log statements:

- 'recipient loop iteration start' (at the top of each iteration,
  includes isFleetMode, selectedProvider, fleetGatewaysCount,
  gatewayAssignmentsSize so we can see if the gateway assignment
  is missing)
- 'about to call currentProvider.send' (before the send, includes
  providerName and messageLength)
- 'currentProvider.send result' (after the send, includes
  success/error/messageId)
- 'marking recipient as failed (retries exhausted)' (when the
  while loop exits without success)
- 'recipient loop iteration end (sent)' (when the recipient is
  marked as sent)
- 'recipient loop done' (after the for loop, includes final counts)

Next campaign run will reveal which path is being taken:
- If 'recipient loop iteration start' doesn't appear → the
  recipients array is empty despite SELECT returning count=1
- If it appears but 'about to call currentProvider.send' doesn't
  → the throw 'Failed to create provider for gateway' is being
  hit (gateway assignment missing or provider config invalid)
- If 'currentProvider.send result' shows success=true but no SMS
  was delivered → the iOS app responded 200 OK but didn't actually
  send (the app isn't running on the phone)

* fix(sms-campaigns): assign recipients outside try block to fix variable shadowing

The recipients SELECT was wrapped in a try/catch that declared
'const { data: recipients } = await supabaseAdmin...'. The 'recipients'
in the destructure created a new variable scoped to the try block,
shadowing the outer 'let recipients: any[] | null = []' declared
immediately above. As a result:

- The 'SELECT sms_campaign_recipients done' log (inside try) showed
  count: 1 (inner scope)
- The for loop (outside try) iterated over the outer 'recipients',
  which was still [] (the default)
- The 'recipient loop done' log showed recipientsProcessed: 0
- sentCount and failedCount stayed at 0
- The campaign 'completed successfully' without processing any
  recipient

Fixed by renaming the inner destructure to 'const { data }' and
assigning to the outer 'recipients = data || []' after the await.
The outer variable now reflects the actual query result, so the
for loop iterates correctly.

The try/catch around the SELECT is preserved (it lets the function
'recover gracefully' from a transient DB error and complete with 0
sent/failed instead of 'completed with error'). The 'error: ' log
inside catch is left in place for visibility.

Symptom discovered via the diagnostic logs added in 7394db9 +
9e9fca2: 'recipient loop iteration start' never appeared despite
SELECT count being 1.

* chore(sms-campaigns): log gatewayIds and first assignment before fetch

To diagnose why 'fleetGatewaysCount: 0' and 'gatewayAssignmentsSize: 0'
appear in recipient loop iteration start, despite the assignments
SELECT returning count: 1 with a valid gateway_id in the DB.

The new log prints:
- assignmentsCount
- firstAssignment (the raw row from the SELECT)
- gatewayIds (after .map().filter())

This will reveal whether the data shape from the Supabase JS client
matches what the code expects, or if 'gateway_id' is null/undefined
at runtime for some reason.

* fix(sms-campaigns): fix assignments shadowing and 'completed' status

Two logic bugs in supabase/functions/sms-campaigns-process/index.ts:

1. assignments variable shadowing: the let assignments = [] above the
   try block was shadowed by const { data: assignments } inside the
   try. The 'SELECT ... done' log inside try used the inner
   variable (count: 1), but the 'about to filter fleet gateways'
   log after try used the outer variable (assignmentsCount: 0,
   firstAssignment: null, gatewayIds: []). Result: gatewayAssignments
   map was empty, the recipient loop threw 'Failed to create provider
   for gateway unknown' for every recipient. Same pattern as the
   recipients shadowing fixed in 8da04cb.

2. Misleading 'completed' status when every recipient failed: the
   finally block's finalStatus was 'processingError ? failed :
   completed'. processingError is only set by the outer try/catch;
   the inner try/catch around the SELECT and the inner try/catch
   inside the recipient while-loop catch errors but never set
   processingError. So a campaign with failedCount: 1, sentCount: 0
   was marked 'completed' on the frontend. Now: 'failed' if either
   processingError is set OR failedCount > 0 && sentCount === 0.
   Partial success (sentCount > 0 && failedCount > 0) still reports
   'completed' since the successful recipients are done.

Verified:
- deno check: clean
- manual: campaign now correctly populates gatewayAssignments and
  fails the recipient with the right lastError

* chore(sms-campaigns): remove diagnostic console.log statements

Removed ~30 DEBUG-PROCESSOR console.log calls and the
'sending.../seeeeeeeeeeeeeeent' user boundary markers that were
added during the hang / shadowing / fleet-gateway investigation
(commits 7394db9, 9e9fca2, 8da04cb, 64ced39). These were
temporary debug aids; the production code path uses
logger.info / logger.error from _shared/logger.ts.

Added one structured log at the currentProvider.send() boundary
(logger.info 'SMS provider send') that captures campaignId,
recipientId, provider, messageLength, success, error, messageId,
and elapsedMs — sufficient to diagnose any future per-recipient
send failure without the per-line noise of the previous logs.

Preserved the user's uncommitted console.log(parsed.data) at
line 421 (separate debug aid, not part of this cleanup).

Verified:
- deno check: clean
- grep confirms only line 421 has console.log in the IIFE file

* fix(sms-fleet): defer delete-gateway call with nextTick to fix dialog flicker

The accept callback in confirmDelete ran the store's deleteGateway
synchronously, which set isLoading.value = true and triggered a
reactive re-render. The re-render hit <ConfirmDialog /> while
PrimeVue's dialog-close state was mid-transition, causing the
dialog to briefly re-show (flicker: close -> flash open -> close).

Wrapping the delete call in nextTick(() => { ... }) defers the
reactive update until after Vue's next flush, which runs after
PrimeVue's close animation completes. The dialog now closes cleanly
on the first click with no flicker.

Only this file changed. No other $confirm.require calls in the
codebase were modified (out of scope for this fix).

* fix(sms-fleet): move ConfirmDialog to global app.vue

When senderFilter === 'all' in pages/campaigns.vue, both
EmailSenderManagement and SmsFleetManagement are mounted
simultaneously (v-show on each wrapper div). Each component
had its own <ConfirmDialog /> in its template, so the delete
confirmation was rendered twice as stacked dialogs. PrimeVue's
useConfirm is a singleton but every <ConfirmDialog /> subscribes
to the service and renders, so two dialogs appeared.

Moved the single <ConfirmDialog /> to src/app.vue (next to the
existing global <Toast /> components) and removed the local
<ConfirmDialog /> and its import from both management components.
The useConfirm() calls and $confirm.require() invocations in the
components are unchanged — they call the singleton service which
the single global <ConfirmDialog /> now subscribes to.

Fixes the double-dialog for delete-gateway and (latently) for
delete-email-sender. The previous nextTick() wrapper for the
accept callback stays; it prevents the close-time flicker from
the reactive update, which is independent of this structural fix.

Verified:
- grep -rn 'ConfirmDialog' frontend/src/ src/app.vue:
  exactly one match (in src/app.vue)
- eslint: clean
- prettier: clean
- only src/app.vue + the two component files changed

* fix(sms-campaigns): resolve DeepSource issues and replace hardcoded secret

- Replace any[] / any types in sms-campaigns-process with typed interfaces
- Remove unused SmsGatewayCredentials import in sms-campaigns/index.ts
- Fix minor lint issues in provider + test files (async, undefined, concat)
- Replace hardcoded Google OAuth secret in config.toml with placeholder
- Add global.fetch to createSupabaseAdmin client

* fix: register all edge functions in config.toml with correct verify_jwt

- Add missing function entries (delete-user, email-templates, imap, mail,
  passive-mining, sms-campaigns-process, sms-fleet, whatsapp-campaigns,
  whatsapp-campaigns-process, whatsapp-webhook)
- Set verify_jwt = true for all non-webhook functions
- Fix campaigns-track import_map/entrypoint that incorrectly pointed to
  email-campaigns
- Keep verify_jwt = false for webhooks (campaigns-track, whatsapp-webhook)

* fix(sms-campaigns): resolve remaining DeepSource minor issues

- Replace placeholder secret in config.toml with non-secret value
- Use property shorthand in createSupabaseAdmin global.fetch
- Return provider instead of void to satisfy assertThrows lint
- Add deepsource-disable comment for high cyclomatic complexity IIFE

* fix(sms-campaigns): use correct skipcq syntax for complexity skip

* refactor(providers): drop what-comments in sms-gateway-provider

- Delete the 7-line block comment explaining the iOS wire contract;
  the send() body and SmsGatewayCredentials type are self-explanatory.
- Rename shadowed 'errorMessage' variable in catch to 'message' (it
  was shadowing the errorMessage(data) helper).
- Add unit test for the timeout catch branch to guard against
  regression of the shadowing fix.

* refactor(sms-fleet): drop redundant probe/handler comments

- Remove the JSDoc above extractSimpleSmsGatewayBaseUrl; the function
  name and body show what it does.
- Replace the 12-line JSDoc on probeGatewayReachability with a
  single-line summary.
- Remove the 6-line inline comment in the POST /gateways handler;
  the provider check + probe call are self-documenting.

* refactor(sms-campaigns-process): extract createProviderForGateway

The 4-way provider dispatch (smsgate | simple-sms-gateway |
sms-gateway | twilio) was duplicated across the primary and
fallback gateway branches. Extract it into a self-documenting
createProviderForGateway(gateway) function in a new
gateway-dispatch.ts module, so the dispatch is a single switch
with named cases instead of two copies of an if/else chain.

The behavior is preserved exactly: smsgate requires all 3 config
fields; simple-sms-gateway and sms-gateway only check
config.simpleSmsGatewayBaseUrl; twilio constructs unconditionally.
No new behavior added (no ?? config.baseUrl fallback for the
self-hosted providers).

Net -112 lines in the processor. Move SmsFleetGateway type to
the new module. Add 7 unit tests covering all 4 providers and
the null cases.

* fix(config): comment passive-mining import_map (no deno.json exists)

supabase/functions/passive-mining/ has no deno.json, so the
import_map line caused 'failed to load import map: no such file
or directory' on supabase start. Comment it out — the function
uses npm: specifiers (npm:hono@4.7.4) and relative imports, so
it doesn't need an import map.

* fix(config): disable verify_jwt for mining-sources OAuth callback

The /oauth/callback/:provider route is hit via browser redirect
from Google (and other OAuth providers). The redirect carries no
user JWT — the handler authenticates via the signed `state`
param instead. Setting verify_jwt = true blocks the callback with
a 401 before the handler can run.

* fix(config): disable verify_jwt for email-campaigns

Browser-facing routes (unsubscribe, tracking pixel, click tracking)
are hit by email recipients with no user JWT. Authenticated routes
already use authMiddleware inside the function.

* fix: address DeepSource anti-pattern issues

- Add explicit `return null` and `default` case in
  `createProviderForGateway` switch (JS-0045, JS-0047)
- Change `let gatewayAssignments` to `const` — never reassigned
  (JS-0242)

* fix(google-contacts): use canonicalForm for phone numbers

Google People API returns canonicalForm (E.164 with country code)
on each PhoneNumber, but the fetcher only passed value (raw
user-entered number) to the extractor.

- Fetcher: prefer canonicalForm over value when mapping API response
- Extractor: switch to parsePhoneNumberFromString (newer API)
- Add test for E.164 phone number normalization

* fix(contacts): resolve org name on correct object in realtime handler

updateContactsCache assigned the resolved organization name to
`clean` but stored `updatedContact` (a different object after
spread merge) in the cache, so the UUID was displayed instead of
the name.

* perf: skip org lookup during mining + hide works_for column

- Add skipOrgLookup flag to contacts store, set on mining start/complete
  in leadminer store — avoids per-contact DB query during realtime streaming
- When redirected to /contacts, get_contacts_table RPC resolves UUIDs
- Hide works_for column and column-picker option when origin is 'mine'

* fix: chunk .in() query to prevent URI-too-long during bulk enrichment

* style: prettier fix frontend formatting

* fix: add eslint-disable for intentional no-await-in-loop in enrichment chunking

* refactor(sms): drop ios sms-gateway provider from edge functions

* feat(sms): drop sms-gateway from sms_fleet_gateways provider check

* refactor(sms-ui): drop ios sms-gateway, surface {{name}} syntax, show daily usage

* fix(sms): make click tracker failures non-fatal, log partial-failures, upsert unsubscribes

* fix(sms-ui): re-apply iOS removal after PR #2860 merge

* ci(deepsource): resolve critical and major issues in PR #2862

* ci(frontend): fix prettier formatting in src/types/sms-fleet.ts

* fix(sms): migrate existing ios gateways in check constraint update; harden totalRecipients init

* feat(sms-ui): surface partial failures in campaign list and validate {{name}} placeholder syntax

* style: prettier fix SmsCampaignComposerDialog and campaigns page

* ci(deepsource): add skipcq for pre-existing cyclomatic complexity in /campaigns/preview

* feat(sms): add mock gateway server and integration tests for campaign delivery

* refactor(sms): extract testable processCampaignRecipients; fix mock server issues; rewrite integration tests

* docs(sms-mock): update docs with correct edge function URL and frontend testing guide

* fix(sms): quota check envelope bug, sentCount accounting, monthly limit enforcement

- Fix envelope-vs-data bug: if (!success) -> if (!success.data)
- Fix sentCount over-increment: move increment after quota check passes
- Fix log output: log result.data instead of envelope object
- Add DB CHECK constraint for sent_today <= daily_limit
- Add monthly limit check to increment_gateway_sent_count_atomic
- Add real-time campaign counter increments per recipient

* feat(sms-mock): multi-provider mock with SMSGate support, message storage, /messages API

- Refactor sms-gateway-mock to support :provider/send-sms routing
- Add SMSGate provider with Basic Auth and correct request/response format
- Add in-memory message store with 10k ring buffer cap
- Add GET /messages API with filtering, pagination, PII redaction
- Add DELETE /messages endpoint
- Add per-provider config overrides
- Add production environment guard
- Add X-Campaign-Id header support to all providers

* fix(sms-mock): config deep merge, zod defaults, headers access

- Fix config deep merge: partial global updates no longer overwrite entire object
- Fix zod default() on nested objects: use explicit default constants
- Fix headers access: c.req.headers -> c.req.raw.headers

* fix(sms-mock): fix resetMockServer, update tests for multi-provider routing

- Fix resetMockServer(): replace configSchema.parse({}) with direct defaults
- Update all tests to use /:provider/send-sms paths
- Add tests for smsgate routing, Basic Auth, 404/401 errors
- Add tests for /messages API: filters, pagination, PII redaction, token gate
- Add tests for DELETE /messages, per-provider config, resetMockServer clears store

* fix(senders): tri-state active badge with live availability, refresh after test

* docs(sms-mock): multi-provider README, test plan with SMSGate scenarios

- Rewrite README with multi-provider architecture, /messages API, PII redaction
- Add SMSGate provider documentation with Basic Auth
- Add 3 new test scenarios: SMSGate provider, message history API, variable substitution

* style: prettier fix smtp-senders.controller.ts

* fix(sms-mock): resolve DeepSource issues

- Remove non-null assertion in getEffectiveConfig (JS-0339)
- Use template literal for body truncation (JS-0246)
- Remove unnecessary async from /messages handler (JS-0116)
- Use prop shorthand for messageId (JS-0240)
- Replace debug-token with <your-mock-token> placeholder in README (SCT-A000)

* fix(deepsource): resolve all 11 remaining issues

- Remove unused Config interface (JS-0356)
- Remove unused configSchema (JS-0356)
- Fix non-null assertion in campaignIndex (JS-0339)
- Remove unused assertObjectMatch, assertRejects imports (JS-0356)
- Add skipcq comments for test mock async functions (JS-0116)
- Fix non-null assertion in smtp-senders.controller.ts (JS-0339)

* fix(deepsource): remove unused assertMatch import from integration test

* refactor(sms-mock): port to standalone Express+Bun microservice with ngrok dev workflow

The sms-gateway-mock was a Hono/Deno Supabase edge function whose
in-memory message store reset on every cold start of the edge function
worker, making the /messages history API unreliable for test scenarios.

Port the service to a standalone Express + Bun + zod + winston
microservice at micro-services/sms-gateway-mock/, mirroring the
canonical micro-services/emails-fetcher pattern. The Bun process keeps
state alive for the lifetime of `bun run dev`, so /messages persists
across requests.

Highlights:
- 5 routes preserved 1:1 (/health, /config, /messages GET+DELETE,
  /:provider/send-sms) plus a /smsgate/3rdparty/v1/messages alias for
  the live SMSGate provider path
- Production guard stricter than the original (default-deny on
  unset NODE_ENV via Zod-validated env)
- 51 Jest + supertest cases across 7 files (vs 21 Deno tests before)
- Wire-up: root package.json scripts, run.sh, generate_env.sh,
  docker-compose.yml (intentional production crash), docker-compose.dev.yml
- Docs: micro-services/sms-gateway-mock/README.md documents 4 dev-tunnel
  options (ngrok, Cloudflare quick tunnel, host.docker.internal, same
  Docker network). docs/testing/sms-campaign-*.md updated.

The Supabase edge function (in Docker) cannot reach localhost on the
host, so the dev workflow is: `bun run dev:micro-services-sms-gateway-mock`
then `ngrok http 8085` then paste the URL into sms_fleet_gateways.base_url.

Files: 47 added, 10 modified, 6 deleted.
Follows up PR #2862.

Pre-commit hook skipped via --no-verify (lint-staged missing in worktree;
code already passed bun run lint / build / test:unit inside the new
microservice). Per AGENTS.md, explicit user consent obtained.

* fix(sms-campaigns): resolve infinite loop, unsubscribe exclusion, and gateway counter bugs

- Move unsubscribe exclusion check before while loop to prevent infinite loop
- Add resolveGatewayForNonFleetMode helper for non-fleet gateway tracking
- Move gateway sent_today increment out of fleet-only block
- Add code, unsubscribeUrl, campaignId to template context and send args
- Add test report documenting all findings

* fix(deepsource): resolve issues from PR #2862

* chore: remove dev-testing artifacts from PR

- docs/testing/: mock-service report, test plan, test report with ngrok URL/test user IDs
- .ignore: opencode slim/deepwork tooling un-ignore (local only)

* fix(frontend): escape {{name}} placeholder syntax in SmsCampaignComposerDialog i18n

The unplugin-vue-i18n compiler (ICU message format) rejected the literal
{{name}} sequences in the <i18n> block with NOT_ALLOW_NEST_PLACEHOLDER
(error code 9), breaking the frontend Docker image build.

Replace {{name}} with {name} ICU interpolation keys and pass the literal
{{name}} as a parameter value via computed properties. The user-facing
text still renders {{name}} because the runtime substitutes {name} with
the literal string.

Affects message_placeholder, attribute_syntax_help, placeholder_syntax_error,
and footer_template_hint in both en and fr blocks.

---------

Co-authored-by: Leadminer Bot <bot@leadminer.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants