Skip to content

Assignment Filters & Bulk Operations

The assignments view (/contacts/assignments) uses the same ContactFilter system as the contacts list. Search, tags, status, custom fields, and member filtering all work through the unified filter pipeline, with URL persistence and bulk operations built in.

The assignments view passes ContactFilter to the same apply_contact_filters service used by the contacts list. The one assignments-specific field is member_filter:

pub enum AssignmentMemberFilter {
All, // show all contacts
Unassigned, // contacts with no member assigned
Member(String), // contacts assigned to a specific member
}

ContactFilterBar renders the member dropdown when you set show_member_filter: true:

ContactFilterBar {
filter: filter_signal,
on_filter_change: move |f| { /* update filter + URL */ },
show_member_filter: true,
member_options: org_members.iter()
.map(|m| (m.id.to_string(), m.display_name.clone()))
.collect(),
}

The route includes query parameters so filter state survives page reloads:

#[route("/contacts/assignments?:query")]
ContactAssignmentsView { query: ContactFilterQuery },

The member query parameter maps to:

URL valueFilter state
"" or absentAll — show all contacts
"unassigned"Unassigned — no member assigned
UUID stringMember(id) — assigned to that member

When filtering by a specific member ID, the API validates that the member belongs to the current organization before executing the query:

let member_exists = schemas::member::Entity::find_by_id(member_id)
.filter(schemas::member::Column::OrganizationId.eq(session.organization.id))
.count(&db)
.await?;
if member_exists == 0 {
return HttpError::bad_request("Invalid member ID");
}

This prevents cross-org data access if a user crafts a request with another organization’s member ID.

Select multiple contacts via checkboxes, then use “Assign to…” or “Remove from…” to apply changes in batch.

  1. Select contacts — the bulk action bar appears with the count
  2. Pick an action (assign or unassign) and choose a member
  3. The run_bulk_operation helper processes each contact sequentially, tracking progress
  4. A toast summarizes the result: "Assigned to 10 contacts" or "Removed from 7 of 10 contacts — 3 failed"

The view tracks loading state per contact with a HashSet<Uuid> instead of a single Option<Uuid>. This lets multiple rows show spinners simultaneously during bulk operations:

let mut loading_ids: Signal<HashSet<Uuid>> = use_signal(HashSet::new);
// During bulk operation
loading_ids.write().insert(contact_id);
// ... API call ...
loading_ids.write().remove(&contact_id);

Bulk operations report progress via bulk_progress: Signal<Option<(usize, usize)>>. The action bar displays “Processing 3 of 10…” while the operation runs.

The assignments view adapts to small screens:

  • Phone column hides below the sm breakpoint (hidden sm:table-cell)
  • Inline member picker stretches full-width on mobile, fixed 256px on desktop
  • Bulk action bar spans full width with margins on mobile, auto-width centered on desktop

Returns paginated contacts with assignment data. Accepts all ContactFilter query parameters.

Response:

pub struct ContactAssignmentResponse {
pub contacts: Vec<ContactAssignment>,
pub total_count: u64,
pub assigned_count: u64, // org-wide assigned count
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
}

Permission: Contact::Collection(ContactCollectionPermission::List)

POST /api/contacts/{contact_id}/members/{member_id}

Section titled “POST /api/contacts/{contact_id}/members/{member_id}”

Assigns a member to a contact. Idempotent — assigning an already-assigned member is a no-op.

Permission: Contact::Instance(ContactInstancePermission::Update)

DELETE /api/contacts/{contact_id}/members/{member_id}

Section titled “DELETE /api/contacts/{contact_id}/members/{member_id}”

Removes a member assignment from a contact.

Permission: Contact::Instance(ContactInstancePermission::Update)