Skip to content

backend: clean up Twilio caller IDs when an account is deleted#7642

Merged
mdmohsin7 merged 4 commits into
mainfrom
caleb/twilio-caller-id-cleanup-on-delete
Jun 4, 2026
Merged

backend: clean up Twilio caller IDs when an account is deleted#7642
mdmohsin7 merged 4 commits into
mainfrom
caleb/twilio-caller-id-cleanup-on-delete

Conversation

@mdmohsin7

Copy link
Copy Markdown
Member

Closes #7640

Problem

When a user deletes their omi account, the background wipe removes their Firestore data but never tells Twilio about it. The verified outgoing caller IDs they had registered stay on our Twilio account forever.

Twilio enforces uniqueness at the account level, so any later attempt to verify the same number — by a brand-new omi user, or by the same person signing back up — fails with error 21450 → "This phone number is already registered." Remediation today is an engineer manually calling client.outgoing_caller_ids({sid}).delete() after confirming no live omi user owns the number.

Fix

  • New utils.twilio_service.delete_user_caller_ids(uid) reads users/{uid}/phone_numbers, calls delete_caller_id(twilio_sid) for every entry that has one. Best-effort: per-entry Twilio errors and the phone-list fetch are caught + logged so a momentary Twilio issue cannot leave the user half-deleted.
  • _background_wipe_user_data (in routers/users.py) calls it before delete_user_data(uid) — must run while the twilio_sid metadata is still in Firestore.
  • 5 unit tests cover: per-entry call, skipping entries without twilio_sid, continuing past a raising delete call, no-phone-numbers no-op, and a Firestore list failure being swallowed.

Test plan

  • pytest tests/unit/test_twilio_account_deletion.py -v → 5 passed
  • pytest tests/unit/test_twilio_service.py -v (existing) → still passes
  • black-formatted at line-length 120, skip-string-normalization
  • No PII in commits / tests / PR

mdmohsin7 added 3 commits June 4, 2026 12:50
Iterate a user's phone_numbers subcollection and call Twilio's
outgoing_caller_ids({sid}).delete() for each entry that carries a
twilio_sid. Best-effort: per-entry exceptions and the phone-number list
fetch are caught and logged so the caller (delete_account background
wipe) can keep going to the Firestore wipe even if Twilio is down.

Returns the count of successfully deleted caller IDs for visibility.
The lazy import of database.phone_calls keeps the test module
stub-friendly.

Refs #7640
Call delete_user_caller_ids(uid) inside _background_wipe_user_data
before delete_user_data(uid) so the twilio_sid metadata is still
available when the Twilio cleanup runs.

Without this, deleting an account left every verified caller ID
registered on our Twilio account forever. When another user (or the
same user signing back up with a fresh uid) later tried to verify the
same number, Twilio rejected the request with error 21450 and the
backend returned "This phone number is already registered." The only
remediation was an engineer manually calling outgoing_caller_ids.delete
on the orphaned sid.

Closes #7640
Five cases:
  - calls delete_caller_id once per phone_numbers doc with a twilio_sid
  - skips entries with missing / None / empty twilio_sid
  - keeps going when one twilio call raises (rest still attempted)
  - returns 0 cleanly when the user has no phone_numbers
  - swallows a Firestore error fetching phone_numbers so the caller
    can still proceed with the Firestore wipe

The test installs a stub for database.phone_calls before importing
utils.twilio_service so the unit suite does not require firebase_admin
or the rest of the database init chain.
@greptile-apps

greptile-apps Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a resource leak where deleted Omi accounts left their verified Twilio outgoing caller IDs registered at the account level, permanently blocking re-verification of those phone numbers for any future user. The fix hooks into the existing _background_wipe_user_data path to clean up Twilio before the Firestore data is removed.

  • utils/twilio_service.py: New delete_user_caller_ids(uid) reads users/{uid}/phone_numbers, calls delete_caller_id for each entry that carries a twilio_sid, and returns the count of successfully deleted IDs. Both the Firestore fetch and each per-entry Twilio call are independently guarded so a Twilio or DB error cannot leave the account in a half-deleted state.
  • routers/users.py: delete_user_caller_ids is called first in _background_wipe_user_data, before delete_user_data, ensuring the twilio_sid metadata is still present in Firestore at call time.
  • tests/unit/test_twilio_account_deletion.py: Five unit tests covering the happy path, skip-without-sid, per-entry exception continuity, empty list, and Firestore list failure; the top-level sys.modules stub correctly models the module boundary so patch.object intercepts calls in twilio_service's namespace.

Confidence Score: 5/5

Safe to merge — the Twilio cleanup is fully best-effort and cannot block or corrupt the Firestore wipe that follows it.

The ordering guarantee (Twilio before Firestore) is correctly enforced by sequential calls in the background task. delete_user_caller_ids cannot raise to its caller under any real condition — both the Firestore list and each per-entry Twilio call are independently caught and logged. No data loss or half-deleted state is possible.

No files require special attention.

Important Files Changed

Filename Overview
backend/utils/twilio_service.py Adds delete_user_caller_ids with top-level import (previously flagged in-function import is now fixed); logic is correct and best-effort by design with clear comments.
backend/routers/users.py Calls delete_user_caller_ids before delete_user_data — correct ordering to ensure Twilio SID metadata is still in Firestore; wrapped in the existing outer try/except.
backend/tests/unit/test_twilio_account_deletion.py Five unit tests with correct patch.object targeting; covers SID iteration, skip-without-sid, per-entry exception continuity, empty list, and Firestore list failure — all assertions are accurate.
backend/test.sh Adds the new test file to the unit test suite in the correct position.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Router as routers/users.py
    participant TwilioSvc as utils/twilio_service.py
    participant Twilio as Twilio API
    participant DB as database/phone_calls.py
    participant Firestore

    Client->>Router: DELETE /v1/users/delete-account
    Router->>Router: _background_wipe_user_data(uid) [background thread]
    Router->>TwilioSvc: delete_user_caller_ids(uid)
    TwilioSvc->>DB: get_phone_numbers(uid)
    DB->>Firestore: "users/{uid}/phone_numbers.stream()"
    Firestore-->>DB: phone number docs
    DB-->>TwilioSvc: "[{twilio_sid, ...}, ...]"
    loop For each entry with twilio_sid
        TwilioSvc->>TwilioSvc: delete_caller_id(sid)
        TwilioSvc->>Twilio: outgoing_caller_ids(sid).delete()
        Twilio-->>TwilioSvc: success / error (swallowed)
    end
    TwilioSvc-->>Router: deleted count
    Router->>Router: delete_user_data(uid)
    Note over Router,Firestore: Firestore wipe runs after Twilio cleanup
Loading

Reviews (2): Last reviewed commit: "backend: greptile review — top-level imp..." | Re-trigger Greptile

Comment thread backend/utils/twilio_service.py Outdated
# error) is logged and swallowed so account-deletion callers can run this
# before a Firestore wipe without risking a half-deleted user if Twilio is
# momentarily down.
from database import phone_calls as phone_calls_db

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 In-function import violates team rule

from database import phone_calls as phone_calls_db is placed inside the function body. The team's import rule explicitly prohibits in-function imports and requires all imports to be at the module top-level. Thirteen other utils/ modules already import from database at the top level without issues (e.g. utils/stripe.py, utils/analytics.py).

The reason this was done here is to let the test stub database.phone_calls via sys.modules before twilio_service is imported — but that same stub approach works equally well with a top-level import, since the stub is installed before twilio_service is first touched. Moving the import to the top of the file would conform to the team standard without breaking the test.

Context Used: Backend Python import rules - no in-function impor... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread backend/utils/twilio_service.py Outdated
Comment on lines +166 to +173
continue
try:
if delete_caller_id(sid):
deleted += 1
else:
logger.warning(f'delete_user_caller_ids: twilio reported failure for sid={sid}')
except Exception as e:
logger.error(f'delete_user_caller_ids: twilio raised for sid={sid}: {e}')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Outer except around delete_caller_id only guards _get_client() failures

delete_caller_id already swallows every exception inside its own try/except and returns False — the only way an exception can escape it and reach the except Exception as e block here is if _get_client() raises (e.g. missing env vars) before the inner try. Twilio API errors in client.outgoing_caller_ids(sid).delete() are absorbed by delete_caller_id and produce a False return, which flows through the logger.warning branch instead.

The code is functionally correct, but the test comment "An exception escaping delete_caller_id" and the PR description "per-entry Twilio errors are caught + logged" both imply Twilio 5xx errors will hit the except path — they won't. Consider adding a brief inline note clarifying that this guard is specifically for _get_client() raising, so future maintainers don't conflate the two paths.

Two P2 comments on #7642:

- P2: in-function `from database import phone_calls` violated the
  backend module rule that all imports live at the top level. Hoisted
  to the top of utils/twilio_service.py; the test's sys.modules stub
  still works because it is installed before twilio_service is first
  imported.
- P2: the outer `try/except` around `delete_caller_id(sid)` cannot ever
  see a Twilio API error — those are absorbed by delete_caller_id's own
  internal handler and surface as a False return on the warning path.
  The outer guard is reachable only if `_get_client()` raises (missing
  TWILIO_* env vars, credential rotation, etc.). Clarified the
  docstring, the inline comment, and the test-case comment so future
  maintainers don't conflate the two paths. Renamed the simulated
  error string in the test to match the actual failure mode.
@mdmohsin7

Copy link
Copy Markdown
Member Author

@greptile-apps re-review

@mdmohsin7
mdmohsin7 merged commit 85198f4 into main Jun 4, 2026
2 checks passed
@mdmohsin7
mdmohsin7 deleted the caleb/twilio-caller-id-cleanup-on-delete branch June 4, 2026 13:11
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.

Account deletion leaves verified Twilio caller IDs orphaned

1 participant