use axum::{ Json, extract::{Path, Query, State}, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, }; use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::error; use utoipa::ToSchema; use uuid::Uuid; use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto}; use crate::application::dtos::contact_dto::{ AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, EmailDto, GroupMembershipDto, PhoneDto, UpdateContactDto, UpdateContactGroupDto, }; use crate::application::dtos::user_dto::UserDto; use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; use crate::application::services::auth_application_service::AuthApplicationService; use crate::application::services::contact_service::ContactService; use crate::domain::errors::ErrorKind; use crate::interfaces::middleware::auth::AuthUser; const SYSTEM_BOOK_ID: &str = "system"; /// Combined state for the contacts REST API. #[derive(Clone)] pub struct ContactsApiState { pub contact_service: Arc, pub auth_service: Option>, /// When false, the virtual "system" address book (OxiCloud users) is hidden. pub expose_system_users: bool, } /// Address book entry with `is_readonly` and `is_system` flags. #[derive(Debug, Serialize, ToSchema)] pub struct AddressBookResponse { pub id: String, pub name: String, pub owner_id: String, pub description: Option, pub color: Option, pub is_public: bool, pub is_readonly: bool, pub is_system: bool, pub created_at: DateTime, pub updated_at: DateTime, } /// Request body for creating an address book. #[derive(Debug, Deserialize, ToSchema)] pub struct CreateAddressBookRequest { pub name: String, pub description: Option, pub color: Option, pub is_public: Option, } /// Request body for updating an address book. #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateAddressBookRequest { pub name: Option, pub description: Option, pub color: Option, pub is_public: Option, } /// Request body for creating a contact. #[derive(Debug, Deserialize, ToSchema)] pub struct CreateContactRequest { pub full_name: Option, pub first_name: Option, pub last_name: Option, pub nickname: Option, #[serde(default)] pub email: Vec, #[serde(default)] pub phone: Vec, #[serde(default)] pub address: Vec, pub organization: Option, pub title: Option, pub notes: Option, pub photo_url: Option, pub birthday: Option, pub anniversary: Option, } /// Request body for updating a contact (all fields optional). #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateContactRequest { pub full_name: Option, pub first_name: Option, pub last_name: Option, pub nickname: Option, pub email: Option>, pub phone: Option>, pub address: Option>, pub organization: Option, pub title: Option, pub notes: Option, pub photo_url: Option, pub birthday: Option, pub anniversary: Option, } /// Request body for creating or renaming a group. #[derive(Debug, Deserialize, ToSchema)] pub struct GroupNameRequest { pub name: String, } /// Request body for adding a contact to a group. #[derive(Debug, Deserialize, ToSchema)] pub struct AddMemberRequest { pub contact_id: String, } /// Query parameters for paginated listing. /// /// Both fields are optional: when omitted, regular address books return /// the full contact list (the frontend relies on this), while the system /// book falls back to its own defaults (limit 100, offset 0). #[derive(Deserialize)] pub struct ListQuery { limit: Option, offset: Option, /// System address book only — when `true`, includes the calling user /// in the returned contacts. Default `false` matches the share-modal /// "you can't share with yourself" semantics; the admin drive-owner /// surface flips it on so an admin can add themselves as Owner. /// Ignored for non-system books. #[serde(default)] include_self: bool, } // ── Helpers ────────────────────────────────────────────────────────────────── fn domain_err_to_response(err: crate::domain::errors::DomainError) -> Response { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, ErrorKind::AccessDenied => StatusCode::FORBIDDEN, ErrorKind::InvalidInput => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, }; ( status, Json(serde_json::json!({ "error": err.to_string() })), ) .into_response() } fn system_book_unavailable() -> Response { ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "System address book not available" })), ) .into_response() } fn system_book_readonly() -> Response { ( StatusCode::METHOD_NOT_ALLOWED, Json(serde_json::json!({ "error": "System address book is read-only" })), ) .into_response() } /// Attach a quoted `ETag` header to an existing response. fn with_etag(mut response: Response, etag: &str) -> Response { if let Ok(val) = HeaderValue::from_str(&format!("\"{}\"", etag)) { response.headers_mut().insert(header::ETAG, val); } response } /// Return `false` when the stored etag does not satisfy the `If-Match` value. /// Handles the `*` wildcard and quoted strings per RFC 7232. fn if_match_passes(if_match: Option<&str>, stored_etag: &str) -> bool { match if_match { None | Some("*") => true, Some(value) => value.trim_matches('"') == stored_etag, } } /// Map a `UserDto` to a `ContactDto` so OxiCloud users appear as contacts /// inside the virtual system address book. /// /// `given_name`/`family_name` come from OIDC standard claims at JIT /// provisioning (or NULL for password-only or pre-OIDC users). When /// they're present, prefer a "First Last" full name; otherwise fall /// back to the username (which is always present). fn user_to_contact(user: UserDto) -> ContactDto { // Display fallback chain: given+family name → username → email. // Username is `Option` post PR 16; externals start with None. let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) { (Some(g), Some(f)) => format!("{g} {f}"), (Some(g), None) => g.to_string(), (None, Some(f)) => f.to_string(), (None, None) => user.username.clone().unwrap_or_else(|| user.email.clone()), }; ContactDto { id: user.id.clone(), address_book_id: SYSTEM_BOOK_ID.to_string(), uid: format!("{}@oxicloud", user.id), full_name: Some(full_name), first_name: user.given_name.clone(), last_name: user.family_name.clone(), nickname: None, email: vec![EmailDto { email: user.email, r#type: "work".to_string(), is_primary: true, }], phone: vec![], address: vec![], organization: Some("OxiCloud".to_string()), title: None, notes: None, photo_url: user.image.clone(), birthday: None, anniversary: None, created_at: user.created_at, updated_at: user.updated_at, etag: user.id, } } // ── Address books ───────────────────────────────────────────────────────────── /// List all address books accessible to the current user (owned + shared), /// plus the virtual read-only system book listing all OxiCloud users. #[utoipa::path( get, path = "/api/address-books", responses( (status = 200, description = "List of address books"), (status = 500, description = "Internal server error"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn list_address_books( State(state): State, auth_user: AuthUser, ) -> impl IntoResponse { match state .contact_service .list_user_address_books(auth_user.id) .await { Ok(books) => { let user_id_str = auth_user.id.to_string(); let mut response: Vec = books .into_iter() .map(|b| { let is_readonly = b.owner_id != user_id_str; AddressBookResponse { id: b.id, name: b.name, owner_id: b.owner_id, description: b.description, color: b.color, is_public: b.is_public, is_readonly, is_system: false, created_at: b.created_at, updated_at: b.updated_at, } }) .collect(); // Skip the system address book for external callers so they // don't see an internal-user directory entry (let alone its // contents). The system book is only useful to internal // users picking sharees out of the directory. let hide_system_for_external = match state.auth_service.as_ref() { Some(svc) => { crate::interfaces::middleware::user::require_internal_user(svc, auth_user.id) .await .is_err() } None => false, }; if state.expose_system_users && state.auth_service.is_some() && !hide_system_for_external { let now = Utc::now(); response.push(AddressBookResponse { id: SYSTEM_BOOK_ID.to_string(), name: "OxiCloud Users".to_string(), owner_id: "system".to_string(), description: Some("All users registered on this OxiCloud instance".to_string()), color: None, is_public: false, is_readonly: true, is_system: true, created_at: now, updated_at: now, }); } (StatusCode::OK, Json(response)).into_response() } Err(err) => { error!( "Error listing address books for user {}: {}", auth_user.id, err ); domain_err_to_response(err) } } } /// Create a new personal address book. #[utoipa::path( post, path = "/api/address-books", responses( (status = 201, description = "Address book created", body = AddressBookResponse), (status = 400, description = "Invalid input"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn create_address_book( State(state): State, auth_user: AuthUser, Json(body): Json, ) -> impl IntoResponse { let dto = CreateAddressBookDto { name: body.name, owner_id: auth_user.id.to_string(), description: body.description, color: body.color, is_public: body.is_public, }; match state.contact_service.create_address_book(dto).await { Ok(book) => { let response = AddressBookResponse { id: book.id, name: book.name, owner_id: book.owner_id, description: book.description, color: book.color, is_public: book.is_public, is_readonly: false, is_system: false, created_at: book.created_at, updated_at: book.updated_at, }; (StatusCode::CREATED, Json(response)).into_response() } Err(err) => { error!("Error creating address book: {}", err); domain_err_to_response(err) } } } /// Update an address book's name, description, or color. #[utoipa::path( put, path = "/api/address-books/{book_id}", params(("book_id" = String, Path, description = "Address book UUID")), responses( (status = 200, description = "Address book updated", body = AddressBookResponse), (status = 403, description = "Only the owner can update"), (status = 404, description = "Address book not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn update_address_book( State(state): State, auth_user: AuthUser, Path(book_id): Path, Json(body): Json, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return system_book_readonly(); } let dto = UpdateAddressBookDto { name: body.name, description: body.description, color: body.color, is_public: body.is_public, user_id: auth_user.id.to_string(), }; match state .contact_service .update_address_book(&book_id, dto) .await { Ok(book) => { let response = AddressBookResponse { id: book.id, name: book.name, owner_id: book.owner_id.clone(), description: book.description, color: book.color, is_public: book.is_public, is_readonly: book.owner_id != auth_user.id.to_string(), is_system: false, created_at: book.created_at, updated_at: book.updated_at, }; (StatusCode::OK, Json(response)).into_response() } Err(err) => { error!("Error updating address book {}: {}", book_id, err); domain_err_to_response(err) } } } /// Delete an address book and all its contacts. Only the owner can do this. #[utoipa::path( delete, path = "/api/address-books/{book_id}", params(("book_id" = String, Path, description = "Address book UUID")), responses( (status = 204, description = "Address book deleted"), (status = 403, description = "Only the owner can delete"), (status = 404, description = "Address book not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn delete_address_book( State(state): State, auth_user: AuthUser, Path(book_id): Path, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return system_book_readonly(); } match state .contact_service .delete_address_book(&book_id, auth_user.id) .await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(err) => { error!("Error deleting address book {}: {}", book_id, err); domain_err_to_response(err) } } } // ── Contacts ────────────────────────────────────────────────────────────────── /// List contacts in an address book. /// `book_id = "system"` returns all OxiCloud users (excluding the caller). #[utoipa::path( get, path = "/api/address-books/{book_id}/contacts", params( ("book_id" = String, Path, description = "Address book UUID or \"system\""), ("limit" = Option, Query, description = "Max results (omit for the full book; system book defaults to 100)"), ("offset" = Option, Query, description = "Pagination offset (omit for none; system book defaults to 0)"), ), responses( (status = 200, description = "List of contacts"), (status = 403, description = "Access denied"), (status = 404, description = "Address book not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn list_contacts( State(state): State, auth_user: AuthUser, Path(book_id): Path, Query(params): Query, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { if !state.expose_system_users { return system_book_unavailable(); } let Some(auth_service) = &state.auth_service else { return system_book_unavailable(); }; // External callers must not enumerate the internal-user // directory through the system address book. if let Err(e) = crate::interfaces::middleware::user::require_internal_user(auth_service, auth_user.id) .await { return e.into_response(); } let caller_id = auth_user.id.to_string(); match auth_service .list_users(params.limit.unwrap_or(100), params.offset.unwrap_or(0)) .await { Ok(users) => { let include_self = params.include_self; let contacts: Vec = users .into_iter() .filter(|u| include_self || u.id != caller_id) .map(user_to_contact) .collect(); (StatusCode::OK, Json(contacts)).into_response() } Err(err) => { error!("Error listing OxiCloud users: {}", err); domain_err_to_response(err) } } } else { match state .contact_service .list_contacts(&book_id, params.limit, params.offset, auth_user.id) .await { Ok(contacts) => (StatusCode::OK, Json(contacts)).into_response(), Err(err) => { error!("Error listing contacts in book {}: {}", book_id, err); domain_err_to_response(err) } } } } /// Create a new contact in an address book. #[utoipa::path( post, path = "/api/address-books/{book_id}/contacts", params(("book_id" = String, Path, description = "Address book UUID")), responses( (status = 201, description = "Contact created"), (status = 400, description = "Invalid input"), (status = 403, description = "Access denied or read-only book"), (status = 404, description = "Address book not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn create_contact( State(state): State, auth_user: AuthUser, Path(book_id): Path, Json(body): Json, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return system_book_readonly(); } let dto = CreateContactDto { address_book_id: book_id.clone(), full_name: body.full_name, first_name: body.first_name, last_name: body.last_name, nickname: body.nickname, email: body.email, phone: body.phone, address: body.address, organization: body.organization, title: body.title, notes: body.notes, photo_url: body.photo_url, birthday: body.birthday, anniversary: body.anniversary, user_id: auth_user.id.to_string(), }; match state.contact_service.create_contact(dto).await { Ok(contact) => { let etag = contact.etag.clone(); with_etag((StatusCode::CREATED, Json(contact)).into_response(), &etag) } Err(err) => { error!("Error creating contact in book {}: {}", book_id, err); domain_err_to_response(err) } } } /// Get a single contact. Returns an `ETag` header for optimistic concurrency. /// `book_id = "system"` looks up an OxiCloud user by UUID. #[utoipa::path( get, path = "/api/address-books/{book_id}/contacts/{contact_id}", params( ("book_id" = String, Path, description = "Address book UUID or \"system\""), ("contact_id" = String, Path, description = "Contact UUID"), ), responses( (status = 200, description = "Contact details"), (status = 403, description = "Access denied"), (status = 404, description = "Contact not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn get_contact( State(state): State, auth_user: AuthUser, Path((book_id, contact_id)): Path<(String, String)>, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { if !state.expose_system_users { return system_book_unavailable(); } let Some(auth_service) = &state.auth_service else { return system_book_unavailable(); }; if let Err(e) = crate::interfaces::middleware::user::require_internal_user(auth_service, auth_user.id) .await { return e.into_response(); } let Ok(uuid) = Uuid::parse_str(&contact_id) else { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid user ID format" })), ) .into_response(); }; match auth_service.get_user_by_id(uuid).await { Ok(user) => { let contact = user_to_contact(user); let etag = contact.etag.clone(); with_etag((StatusCode::OK, Json(contact)).into_response(), &etag) } Err(err) => { error!("Error fetching OxiCloud user {}: {}", contact_id, err); domain_err_to_response(err) } } } else { match state .contact_service .get_contact(&contact_id, auth_user.id) .await { Ok(contact) => { let etag = contact.etag.clone(); with_etag((StatusCode::OK, Json(contact)).into_response(), &etag) } Err(err) => { error!("Error fetching contact {}: {}", contact_id, err); domain_err_to_response(err) } } } } /// Update a contact. Honours `If-Match` for optimistic concurrency — returns /// `412 Precondition Failed` when the stored ETag does not match. #[utoipa::path( put, path = "/api/address-books/{book_id}/contacts/{contact_id}", params( ("book_id" = String, Path, description = "Address book UUID"), ("contact_id" = String, Path, description = "Contact UUID"), ), responses( (status = 200, description = "Contact updated"), (status = 400, description = "Invalid input"), (status = 403, description = "Access denied or read-only book"), (status = 404, description = "Contact not found"), (status = 412, description = "ETag mismatch — contact was modified"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn update_contact( State(state): State, auth_user: AuthUser, headers: HeaderMap, Path((book_id, contact_id)): Path<(String, String)>, Json(body): Json, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return system_book_readonly(); } // ETag check: fetch current contact first, compare against If-Match. let if_match = headers.get(header::IF_MATCH).and_then(|v| v.to_str().ok()); if if_match.is_some() { match state .contact_service .get_contact(&contact_id, auth_user.id) .await { Ok(current) => { if !if_match_passes(if_match, ¤t.etag) { return ( StatusCode::PRECONDITION_FAILED, Json(serde_json::json!({ "error": "Contact was modified — fetch the latest version and retry" })), ) .into_response(); } } Err(err) => return domain_err_to_response(err), } } let dto = UpdateContactDto { full_name: body.full_name, first_name: body.first_name, last_name: body.last_name, nickname: body.nickname, email: body.email, phone: body.phone, address: body.address, organization: body.organization, title: body.title, notes: body.notes, photo_url: body.photo_url, birthday: body.birthday, anniversary: body.anniversary, user_id: auth_user.id.to_string(), }; match state.contact_service.update_contact(&contact_id, dto).await { Ok(contact) => { let etag = contact.etag.clone(); with_etag((StatusCode::OK, Json(contact)).into_response(), &etag) } Err(err) => { error!("Error updating contact {}: {}", contact_id, err); domain_err_to_response(err) } } } /// Delete a contact. Optionally honours `If-Match`. #[utoipa::path( delete, path = "/api/address-books/{book_id}/contacts/{contact_id}", params( ("book_id" = String, Path, description = "Address book UUID"), ("contact_id" = String, Path, description = "Contact UUID"), ), responses( (status = 204, description = "Contact deleted"), (status = 403, description = "Access denied or read-only book"), (status = 404, description = "Contact not found"), (status = 412, description = "ETag mismatch"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn delete_contact( State(state): State, auth_user: AuthUser, headers: HeaderMap, Path((book_id, contact_id)): Path<(String, String)>, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return system_book_readonly(); } let if_match = headers.get(header::IF_MATCH).and_then(|v| v.to_str().ok()); if if_match.is_some() { match state .contact_service .get_contact(&contact_id, auth_user.id) .await { Ok(current) => { if !if_match_passes(if_match, ¤t.etag) { return ( StatusCode::PRECONDITION_FAILED, Json(serde_json::json!({ "error": "Contact was modified — fetch the latest version and retry" })), ) .into_response(); } } Err(err) => return domain_err_to_response(err), } } match state .contact_service .delete_contact(&contact_id, auth_user.id) .await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(err) => { error!("Error deleting contact {}: {}", contact_id, err); domain_err_to_response(err) } } } // ── Groups ──────────────────────────────────────────────────────────────────── /// List contact groups in an address book. The system book has no groups. #[utoipa::path( get, path = "/api/address-books/{book_id}/groups", params(("book_id" = String, Path, description = "Address book UUID or \"system\"")), responses( (status = 200, description = "List of contact groups"), (status = 403, description = "Access denied"), (status = 404, description = "Address book not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn list_groups( State(state): State, auth_user: AuthUser, Path(book_id): Path, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return (StatusCode::OK, Json(Vec::::new())).into_response(); } match state .contact_service .list_groups(&book_id, auth_user.id) .await { Ok(groups) => (StatusCode::OK, Json(groups)).into_response(), Err(err) => { error!("Error listing groups in book {}: {}", book_id, err); domain_err_to_response(err) } } } /// Create a contact group in an address book. #[utoipa::path( post, path = "/api/address-books/{book_id}/groups", params(("book_id" = String, Path, description = "Address book UUID")), responses( (status = 201, description = "Group created"), (status = 403, description = "Access denied or read-only book"), (status = 404, description = "Address book not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn create_group( State(state): State, auth_user: AuthUser, Path(book_id): Path, Json(body): Json, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return system_book_readonly(); } let dto = CreateContactGroupDto { address_book_id: book_id.clone(), name: body.name, user_id: auth_user.id.to_string(), }; match state.contact_service.create_group(dto).await { Ok(group) => (StatusCode::CREATED, Json(group)).into_response(), Err(err) => { error!("Error creating group in book {}: {}", book_id, err); domain_err_to_response(err) } } } /// Get a single contact group by ID. #[utoipa::path( get, path = "/api/address-books/{book_id}/groups/{group_id}", params( ("book_id" = String, Path, description = "Address book UUID"), ("group_id" = String, Path, description = "Group UUID"), ), responses( (status = 200, description = "Group details"), (status = 403, description = "Access denied"), (status = 404, description = "Group not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn get_group( State(state): State, auth_user: AuthUser, Path((book_id, group_id)): Path<(String, String)>, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "System address book has no groups" })), ) .into_response(); } match state .contact_service .get_group(&group_id, auth_user.id) .await { Ok(group) => (StatusCode::OK, Json(group)).into_response(), Err(err) => { error!("Error fetching group {}: {}", group_id, err); domain_err_to_response(err) } } } /// Rename a contact group. #[utoipa::path( put, path = "/api/address-books/{book_id}/groups/{group_id}", params( ("book_id" = String, Path, description = "Address book UUID"), ("group_id" = String, Path, description = "Group UUID"), ), responses( (status = 200, description = "Group updated"), (status = 403, description = "Access denied or read-only book"), (status = 404, description = "Group not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn update_group( State(state): State, auth_user: AuthUser, Path((book_id, group_id)): Path<(String, String)>, Json(body): Json, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return system_book_readonly(); } let dto = UpdateContactGroupDto { name: body.name, user_id: auth_user.id.to_string(), }; match state.contact_service.update_group(&group_id, dto).await { Ok(group) => (StatusCode::OK, Json(group)).into_response(), Err(err) => { error!("Error updating group {}: {}", group_id, err); domain_err_to_response(err) } } } /// Delete a contact group. #[utoipa::path( delete, path = "/api/address-books/{book_id}/groups/{group_id}", params( ("book_id" = String, Path, description = "Address book UUID"), ("group_id" = String, Path, description = "Group UUID"), ), responses( (status = 204, description = "Group deleted"), (status = 403, description = "Access denied or read-only book"), (status = 404, description = "Group not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn delete_group( State(state): State, auth_user: AuthUser, Path((book_id, group_id)): Path<(String, String)>, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return system_book_readonly(); } match state .contact_service .delete_group(&group_id, auth_user.id) .await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(err) => { error!("Error deleting group {}: {}", group_id, err); domain_err_to_response(err) } } } // ── Group membership ────────────────────────────────────────────────────────── /// List contacts that belong to a group. #[utoipa::path( get, path = "/api/address-books/{book_id}/groups/{group_id}/contacts", params( ("book_id" = String, Path, description = "Address book UUID"), ("group_id" = String, Path, description = "Group UUID"), ), responses( (status = 200, description = "Contacts in the group"), (status = 403, description = "Access denied"), (status = 404, description = "Group not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn list_contacts_in_group( State(state): State, auth_user: AuthUser, Path((book_id, group_id)): Path<(String, String)>, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "System address book has no groups" })), ) .into_response(); } match state .contact_service .list_contacts_in_group(&group_id, auth_user.id) .await { Ok(contacts) => (StatusCode::OK, Json(contacts)).into_response(), Err(err) => { error!("Error listing contacts in group {}: {}", group_id, err); domain_err_to_response(err) } } } /// Add a contact to a group. #[utoipa::path( post, path = "/api/address-books/{book_id}/groups/{group_id}/contacts", params( ("book_id" = String, Path, description = "Address book UUID"), ("group_id" = String, Path, description = "Group UUID"), ), responses( (status = 204, description = "Contact added to group"), (status = 400, description = "Invalid contact ID"), (status = 403, description = "Access denied or read-only book"), (status = 404, description = "Group or contact not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn add_contact_to_group( State(state): State, auth_user: AuthUser, Path((book_id, group_id)): Path<(String, String)>, Json(body): Json, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return system_book_readonly(); } let dto = GroupMembershipDto { group_id: group_id.clone(), contact_id: body.contact_id, }; match state .contact_service .add_contact_to_group(dto, auth_user.id) .await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(err) => { error!("Error adding contact to group {}: {}", group_id, err); domain_err_to_response(err) } } } /// Remove a contact from a group. #[utoipa::path( delete, path = "/api/address-books/{book_id}/groups/{group_id}/contacts/{contact_id}", params( ("book_id" = String, Path, description = "Address book UUID"), ("group_id" = String, Path, description = "Group UUID"), ("contact_id" = String, Path, description = "Contact UUID"), ), responses( (status = 204, description = "Contact removed from group"), (status = 403, description = "Access denied or read-only book"), (status = 404, description = "Group or contact not found"), ), security(("bearerAuth" = [])), tag = "contacts" )] pub async fn remove_contact_from_group( State(state): State, auth_user: AuthUser, Path((book_id, group_id, contact_id)): Path<(String, String, String)>, ) -> impl IntoResponse { if book_id == SYSTEM_BOOK_ID { return system_book_readonly(); } let dto = GroupMembershipDto { group_id: group_id.clone(), contact_id, }; match state .contact_service .remove_contact_from_group(dto, auth_user.id) .await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(err) => { error!("Error removing contact from group {}: {}", group_id, err); domain_err_to_response(err) } } }