Skip to content

Phone Resolution Service

The resolve_sender_phone service in src/mods/messaging/services/resolve_sender_phone_service.rs determines which phone number a member uses to send outbound SMS. It replaced duplicated resolution logic that previously lived in both the assistant SMS tool and the plan execution service.

The service follows a strict priority chain:

  1. Default assigned phone — the member’s assignment with is_default = true
  2. Any assigned phone — falls back to the first assignment if no default is set
  3. Org fallback (owners only) — org owners with no assignments use any org phone number
  4. Error (non-owners) — non-owners with no assignments get a clear error
pub async fn resolve_sender_phone(
db: &DatabaseConnection,
member_id: Uuid,
organization_id: Uuid,
is_owner: bool,
) -> Result<phone_number::Model, AppError>

Before looking up assignments, the service verifies the member belongs to the given organization. If the member doesn’t belong, it returns AppError::Forbidden.

let member_exists = schemas::member::Entity::find_by_id(member_id)
.filter(schemas::member::Column::OrganizationId.eq(organization_id))
.one(db)
.await?
.is_some();
CallerFileBehavior
Assistant SMS toolai_send_sms_tool.rsResolves member’s phone; validates assignment if a specific phone is requested
Plan SMS executionexecute_send_sms_service.rsUses the template’s sender_phone_number_id directly (no resolution needed)
Compose bar (UI)messaging_contact_view.rsUses the same priority: member’s default phone → first available phone

The assistant tool calls resolve_sender_phone when the user doesn’t specify a phone number. When the user explicitly picks a phone, the tool validates the member is assigned to it before sending.

The compose bar in MessagingContactView follows the same resolution order — selecting the member’s default phone first, then falling back to the first available phone. If the member has no allowed phones (non-owner with no assignments), the compose bar does not render.

sender_phone_number_id is now a required Uuid field on both PlanTemplate and PlanTemplateData (previously Option<Uuid>). The UI no longer shows a “Default (auto-detect)” option in the phone dropdown — you must select a phone number when creating or editing a template.

Migration m20260327_120625 handles the schema change:

  1. Backfills NULLs — sets sender_phone_number_id to the org’s first phone number for any template missing it
  2. Adds NOT NULLALTER TABLE plan_template ALTER COLUMN sender_phone_number_id SET NOT NULL
  3. Switches FK to RESTRICT — replaces ON DELETE SET NULL with ON DELETE RESTRICT (you can’t delete a phone number that’s referenced by a template)
// Before
pub struct PlanTemplateData {
pub sender_phone_number_id: Option<Uuid>,
}
// After
pub struct PlanTemplateData {
pub sender_phone_number_id: Uuid,
}

The PlanTemplate type mirrors this change. Plan creation services wrap the template’s phone ID in Some() when writing to the plan table, which keeps sender_phone_number_id nullable on plans for historical data.

ScenarioError
Member not in orgForbidden: "Member does not belong to this organization."
Non-owner, no assignmentsInvalidInput: "No phone number assigned. Ask an owner to assign you a phone number."
Owner, no org phonesInvalidInput: "Organization has no phone number configured."
Assigned phone not foundNotFound: "Assigned phone number"