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.
How it works
Section titled “How it works”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 saveValidation 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.
API behavior
Section titled “API behavior”Both endpoints return the same error when a duplicate is detected:
HTTP 400 Bad Request"This phone number is already assigned to another contact"Create phone
Section titled “Create phone”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", )?;}Update phone
Section titled “Update phone”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")?;UI error handling
Section titled “UI error handling”Errors display inline — directly in the form or card that triggered them, not as global toasts.
Keyed error signal
Section titled “Keyed error signal”The view uses a keyed signal to target errors to specific UI elements:
let mut phone_error = use_signal(|| None::<(Option<String>, String)>);| Signal value | Target |
|---|---|
Some((None, msg)) | Create phone form |
Some((Some(phone_id), msg)) | Specific phone card |
None | No error |
Error message formatting
Section titled “Error message formatting”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"
Create form behavior
Section titled “Create form behavior”- 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
Edit card behavior
Section titled “Edit card behavior”- On error: card stays open, error appears inline
- On cancel: error clears and field values revert
Files involved
Section titled “Files involved”| File | Role |
|---|---|
contact/api/create_contact_phone_api.rs | Create endpoint validation |
contact/api/update_contact_phone_api.rs | Update endpoint validation |
contact/views/contact_details_view.rs | Error signal + friendly_server_error |
contact/components/sidebar/contact_phone_section_component.rs | Create form error display |
contact/components/contact_phone_card_component.rs | Edit card error display |