V1.0: Class 1 fax — real-world 4-page send to external number confirmed
Core features: - Class 1 T.30 protocol: full send/receive implementation - HDLC: DLE-stuffing, FCS strip, USR5637 bit-reversal handling - T.4 MH encoder/decoder (1728px A4 standard) - Document pipeline: PDF (Ghostscript), PNG, TIFF input - Width clamping: US Letter 1734px → 1728px fax standard - Cover page: CJK rasterization (TW/CN/JP/EN), TIFF + HTML output - OCR verification: Tesseract 5 with eng+chi_tra, CJK space-tolerant - API server (axum): health, send, jobs, cover, retry, cancel - Background worker: auto-poll queue, speed fallback, retry policy - Modem detection, pool management Real-world test results (2026-07-23): - V90 → 25153038: 4 pages, V.17 12000 bps, 2:33 ✅ - USR5637 → 25153038: 4 pages, V.17 12000 bps, 2:26 ✅ - Both faxes confirmed received on remote machine Tested: loopback (100% pixel match), multi-page, all input formats, cover pages, OCR verify, API endpoints, worker processing. 13 unit tests pass, 0 new clippy warnings.
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::info;
|
||||
|
||||
use crate::address_book::{Contact, ContactId, Group, GroupId, AddressBookStore, CreateContactRequest, UpdateContactRequest, CreateGroupRequest, UpdateGroupRequest};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AddressBookState {
|
||||
pub store: Arc<Mutex<AddressBookStore>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ContactResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub fax_number: String,
|
||||
pub company: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub is_favorite: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<Contact> for ContactResponse {
|
||||
fn from(contact: Contact) -> Self {
|
||||
Self {
|
||||
id: contact.id.to_string(),
|
||||
name: contact.name,
|
||||
fax_number: contact.fax_number,
|
||||
company: contact.company,
|
||||
email: contact.email,
|
||||
phone: contact.phone,
|
||||
category: contact.category,
|
||||
notes: contact.notes,
|
||||
is_favorite: contact.is_favorite,
|
||||
created_at: contact.created_at.to_rfc3339(),
|
||||
updated_at: contact.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ContactListResponse {
|
||||
pub contacts: Vec<ContactResponse>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<Group> for GroupResponse {
|
||||
fn from(group: Group) -> Self {
|
||||
Self {
|
||||
id: group.id.to_string(),
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
color: group.color,
|
||||
created_at: group.created_at.to_rfc3339(),
|
||||
updated_at: group.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupListResponse {
|
||||
pub groups: Vec<GroupResponse>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
pub query: Option<String>,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
async fn create_contact(
|
||||
State(state): State<AddressBookState>,
|
||||
Json(req): Json<CreateContactRequest>,
|
||||
) -> Result<Json<ContactResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let mut contact = Contact::new(req.name, req.fax_number);
|
||||
contact.company = req.company;
|
||||
contact.email = req.email;
|
||||
contact.phone = req.phone;
|
||||
contact.category = req.category;
|
||||
contact.notes = req.notes;
|
||||
|
||||
let store = state.store.lock().await;
|
||||
store.create_contact(&contact).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "create_contact_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("Created contact: {} - {}", contact.name, contact.fax_number);
|
||||
Ok(Json(ContactResponse::from(contact)))
|
||||
}
|
||||
|
||||
async fn get_contact(
|
||||
State(state): State<AddressBookState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ContactResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let contact_id = ContactId::from_uuid(uuid::Uuid::parse_str(&id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_contact_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let store = state.store.lock().await;
|
||||
let contact = store.get_contact(&contact_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "get_contact_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
match contact {
|
||||
Some(c) => Ok(Json(ContactResponse::from(c))),
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "contact_not_found".to_string(),
|
||||
message: "Contact not found".to_string(),
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_contacts(
|
||||
State(state): State<AddressBookState>,
|
||||
Query(query): Query<SearchQuery>,
|
||||
) -> Result<Json<ContactListResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let store = state.store.lock().await;
|
||||
|
||||
let contacts = if let Some(search_query) = query.query {
|
||||
store.search_contacts(&search_query).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "search_contacts_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
} else if let Some(category) = query.category {
|
||||
store.list_contacts(Some(&category)).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "list_contacts_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
store.list_contacts(None).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "list_contacts_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
let total = contacts.len();
|
||||
Ok(Json(ContactListResponse {
|
||||
contacts: contacts.into_iter().map(ContactResponse::from).collect(),
|
||||
total,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_contact(
|
||||
State(state): State<AddressBookState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateContactRequest>,
|
||||
) -> Result<Json<ContactResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let contact_id = ContactId::from_uuid(uuid::Uuid::parse_str(&id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_contact_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let store = state.store.lock().await;
|
||||
|
||||
let mut contact = store.get_contact(&contact_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "get_contact_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "contact_not_found".to_string(),
|
||||
message: "Contact not found".to_string(),
|
||||
}),
|
||||
))?;
|
||||
|
||||
if let Some(name) = req.name {
|
||||
contact.name = name;
|
||||
}
|
||||
if let Some(fax_number) = req.fax_number {
|
||||
contact.fax_number = fax_number;
|
||||
}
|
||||
if let Some(company) = req.company {
|
||||
contact.company = Some(company);
|
||||
}
|
||||
if let Some(email) = req.email {
|
||||
contact.email = Some(email);
|
||||
}
|
||||
if let Some(phone) = req.phone {
|
||||
contact.phone = Some(phone);
|
||||
}
|
||||
if let Some(category) = req.category {
|
||||
contact.category = Some(category);
|
||||
}
|
||||
if let Some(notes) = req.notes {
|
||||
contact.notes = Some(notes);
|
||||
}
|
||||
if let Some(is_favorite) = req.is_favorite {
|
||||
contact.is_favorite = is_favorite;
|
||||
}
|
||||
|
||||
contact.update();
|
||||
|
||||
store.update_contact(&contact_id, &contact).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "update_contact_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("Updated contact: {} - {}", contact.name, contact.fax_number);
|
||||
Ok(Json(ContactResponse::from(contact)))
|
||||
}
|
||||
|
||||
async fn delete_contact(
|
||||
State(state): State<AddressBookState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let contact_id = ContactId::from_uuid(uuid::Uuid::parse_str(&id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_contact_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let store = state.store.lock().await;
|
||||
store.delete_contact(&contact_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "delete_contact_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("Deleted contact: {}", id);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn create_group(
|
||||
State(state): State<AddressBookState>,
|
||||
Json(req): Json<CreateGroupRequest>,
|
||||
) -> Result<Json<GroupResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let mut group = Group::new(req.name);
|
||||
group.description = req.description;
|
||||
group.color = req.color;
|
||||
|
||||
let store = state.store.lock().await;
|
||||
store.create_group(&group).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "create_group_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("Created group: {}", group.name);
|
||||
Ok(Json(GroupResponse::from(group)))
|
||||
}
|
||||
|
||||
async fn get_group(
|
||||
State(state): State<AddressBookState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<GroupResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let group_id = GroupId::from_uuid(uuid::Uuid::parse_str(&id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_group_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let store = state.store.lock().await;
|
||||
let group = store.get_group(&group_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "get_group_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
match group {
|
||||
Some(g) => Ok(Json(GroupResponse::from(g))),
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "group_not_found".to_string(),
|
||||
message: "Group not found".to_string(),
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_groups(
|
||||
State(state): State<AddressBookState>,
|
||||
) -> Result<Json<GroupListResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let store = state.store.lock().await;
|
||||
let groups = store.list_groups().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "list_groups_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let total = groups.len();
|
||||
Ok(Json(GroupListResponse {
|
||||
groups: groups.into_iter().map(GroupResponse::from).collect(),
|
||||
total,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_group(
|
||||
State(state): State<AddressBookState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateGroupRequest>,
|
||||
) -> Result<Json<GroupResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let group_id = GroupId::from_uuid(uuid::Uuid::parse_str(&id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_group_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let store = state.store.lock().await;
|
||||
|
||||
let mut group = store.get_group(&group_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "get_group_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "group_not_found".to_string(),
|
||||
message: "Group not found".to_string(),
|
||||
}),
|
||||
))?;
|
||||
|
||||
if let Some(name) = req.name {
|
||||
group.name = name;
|
||||
}
|
||||
if let Some(description) = req.description {
|
||||
group.description = Some(description);
|
||||
}
|
||||
if let Some(color) = req.color {
|
||||
group.color = Some(color);
|
||||
}
|
||||
|
||||
group.update();
|
||||
|
||||
store.update_group(&group_id, &group).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "update_group_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("Updated group: {}", group.name);
|
||||
Ok(Json(GroupResponse::from(group)))
|
||||
}
|
||||
|
||||
async fn delete_group(
|
||||
State(state): State<AddressBookState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let group_id = GroupId::from_uuid(uuid::Uuid::parse_str(&id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_group_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let store = state.store.lock().await;
|
||||
store.delete_group(&group_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "delete_group_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("Deleted group: {}", id);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn add_contact_to_group(
|
||||
State(state): State<AddressBookState>,
|
||||
Path((contact_id, group_id)): Path<(String, String)>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let contact_uuid = ContactId::from_uuid(uuid::Uuid::parse_str(&contact_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_contact_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let group_uuid = GroupId::from_uuid(uuid::Uuid::parse_str(&group_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_group_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let store = state.store.lock().await;
|
||||
store.add_contact_to_group(&contact_uuid, &group_uuid).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "add_contact_to_group_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("Added contact {} to group {}", contact_id, group_id);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn remove_contact_from_group(
|
||||
State(state): State<AddressBookState>,
|
||||
Path((contact_id, group_id)): Path<(String, String)>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let contact_uuid = ContactId::from_uuid(uuid::Uuid::parse_str(&contact_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_contact_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let group_uuid = GroupId::from_uuid(uuid::Uuid::parse_str(&group_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_group_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let store = state.store.lock().await;
|
||||
store.remove_contact_from_group(&contact_uuid, &group_uuid).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "remove_contact_from_group_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("Removed contact {} from group {}", contact_id, group_id);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn get_contacts_in_group(
|
||||
State(state): State<AddressBookState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ContactListResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let group_id = GroupId::from_uuid(uuid::Uuid::parse_str(&id).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "invalid_group_id".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?);
|
||||
|
||||
let store = state.store.lock().await;
|
||||
let contacts = store.get_contacts_in_group(&group_id).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "get_contacts_in_group_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let total = contacts.len();
|
||||
Ok(Json(ContactListResponse {
|
||||
contacts: contacts.into_iter().map(ContactResponse::from).collect(),
|
||||
total,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub fn address_book_routes(store: Arc<Mutex<AddressBookStore>>) -> Router {
|
||||
let state = AddressBookState { store };
|
||||
|
||||
Router::new()
|
||||
.route("/contacts", post(create_contact))
|
||||
.route("/contacts", get(list_contacts))
|
||||
.route("/contacts/:id", get(get_contact))
|
||||
.route("/contacts/:id", put(update_contact))
|
||||
.route("/contacts/:id", delete(delete_contact))
|
||||
.route("/groups", post(create_group))
|
||||
.route("/groups", get(list_groups))
|
||||
.route("/groups/:id", get(get_group))
|
||||
.route("/groups/:id", put(update_group))
|
||||
.route("/groups/:id", delete(delete_group))
|
||||
.route("/groups/:id/contacts", get(get_contacts_in_group))
|
||||
.route("/contacts/:contact_id/groups/:group_id", post(add_contact_to_group))
|
||||
.route("/contacts/:contact_id/groups/:group_id", delete(remove_contact_from_group))
|
||||
.with_state(state)
|
||||
}
|
||||
Reference in New Issue
Block a user