Skip to content

Duplicate phone validation

The contact module prevents duplicate phone numbers within the same organization. When you create or update a phone record, the API checks whether that number already belongs to another contact in the org. If it does, the request fails with a 400 error and the UI shows an inline message on the relevant form.

Create/Update phone request
→ Query contact_phone JOIN contact
WHERE phone_number = input AND organization_id = session.org_id
(Update also excludes the current record)
→ Match found? → 400 Bad Request
→ No match? → Proceed with save

Validation is application-level only — there’s no database unique constraint. This gives you control over error messaging and simplifies soft-delete scenarios.

Cross-org duplicates are allowed. Two contacts in different organizations can share the same phone number.

Both endpoints return the same error when a duplicate is detected:

HTTP 400 Bad Request
"This phone number is already assigned to another contact"

POST /api/contacts/{contact_id}/phones

The handler queries contact_phone joined to contact for matching phone_number + organization_id. If a record exists, it rejects the request.

let duplicate = schemas::contact_phone::Entity::find()
.filter(schemas::contact_phone::Column::PhoneNumber.eq(&data.phone_number))
.inner_join(schemas::contact::Entity)
.filter(schemas::contact::Column::OrganizationId.eq(session.organization.id))
.one(&db)
.await
.or_internal_server_error("Database query failed")?;
if duplicate.is_some() {
None::<()>.or_bad_request(
"This phone number is already assigned to another contact",
)?;
}

PUT /api/contacts/{contact_id}/phones/{phone_id}

Same query, but excludes the current record with .filter(Column::Id.ne(pid)). This prevents false positives when you edit the label or preferred flag without changing the number.

let duplicate = schemas::contact_phone::Entity::find()
.filter(schemas::contact_phone::Column::PhoneNumber.eq(&data.phone_number))
.filter(schemas::contact_phone::Column::Id.ne(pid))
.inner_join(schemas::contact::Entity)
.filter(schemas::contact::Column::OrganizationId.eq(session.organization.id))
.one(&db)
.await
.or_internal_server_error("Database query failed")?;

Errors display inline — directly in the form or card that triggered them, not as global toasts.

The view uses a keyed signal to target errors to specific UI elements:

let mut phone_error = use_signal(|| None::<(Option<String>, String)>);
Signal valueTarget
Some((None, msg))Create phone form
Some((Some(phone_id), msg))Specific phone card
NoneNo error

A helper strips the HTTP prefix before display:

fn friendly_server_error(raw: &str) -> String {
raw.split_once(": ")
.map(|(_, msg)| msg.to_string())
.unwrap_or_else(|| raw.to_string())
}

Input: "400 Bad Request: This phone number is already assigned to another contact" Output: "This phone number is already assigned to another contact"

  • On error: form stays open, error appears below the input as text-xs text-destructive
  • On success: form auto-closes when the phone list length increases
  • On error: card stays open, error appears inline
  • On cancel: error clears and field values revert
FileRole
contact/api/create_contact_phone_api.rsCreate endpoint validation
contact/api/update_contact_phone_api.rsUpdate endpoint validation
contact/views/contact_details_view.rsError signal + friendly_server_error
contact/components/sidebar/contact_phone_section_component.rsCreate form error display
contact/components/contact_phone_card_component.rsEdit card error display