diff --git a/src/application/dtos/contact_dto.rs b/src/application/dtos/contact_dto.rs index bd988afd..88203436 100644 --- a/src/application/dtos/contact_dto.rs +++ b/src/application/dtos/contact_dto.rs @@ -1,8 +1,9 @@ use crate::domain::entities::contact::{Address, Contact, ContactGroup, Email, Phone}; use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct EmailDto { pub email: String, pub r#type: String, @@ -19,7 +20,7 @@ impl From for EmailDto { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PhoneDto { pub number: String, pub r#type: String, @@ -36,7 +37,7 @@ impl From for PhoneDto { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AddressDto { pub street: Option, pub city: Option, @@ -61,7 +62,7 @@ impl From
for AddressDto { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ContactDto { pub id: String, pub address_book_id: String, @@ -181,7 +182,7 @@ pub struct CreateContactVCardDto { pub user_id: String, // User creating the contact } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ContactGroupDto { pub id: String, pub address_book_id: String, diff --git a/src/infrastructure/repositories/pg/address_book_pg_repository.rs b/src/infrastructure/repositories/pg/address_book_pg_repository.rs index 2d348b37..0d2191f7 100644 --- a/src/infrastructure/repositories/pg/address_book_pg_repository.rs +++ b/src/infrastructure/repositories/pg/address_book_pg_repository.rs @@ -42,10 +42,11 @@ impl AddressBookRepository for AddressBookPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to create address book: {}", e)))?; + let owner_id: Uuid = row.get("owner_id"); Ok(AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -79,10 +80,11 @@ impl AddressBookRepository for AddressBookPgRepository { DomainError::database_error(format!("Failed to update address book: {}", e)) })?; + let owner_id: Uuid = row.get("owner_id"); Ok(AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -127,10 +129,11 @@ impl AddressBookRepository for AddressBookPgRepository { })?; let result = maybe_row.map(|row| { + let owner_id: Uuid = row.get("owner_id"); AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -164,10 +167,11 @@ impl AddressBookRepository for AddressBookPgRepository { let result = rows .into_iter() .map(|row| { + let owner_id: Uuid = row.get("owner_id"); AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -201,10 +205,11 @@ impl AddressBookRepository for AddressBookPgRepository { let result = rows .into_iter() .map(|row| { + let owner_id: Uuid = row.get("owner_id"); AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -235,10 +240,11 @@ impl AddressBookRepository for AddressBookPgRepository { let result = rows .into_iter() .map(|row| { + let owner_id: Uuid = row.get("owner_id"); AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -317,7 +323,10 @@ impl AddressBookRepository for AddressBookPgRepository { let result = rows .into_iter() - .map(|row| (row.get("user_id"), row.get("can_write"))) + .map(|row| { + let user_id: Uuid = row.get("user_id"); + (user_id.to_string(), row.get("can_write")) + }) .collect(); Ok(result) diff --git a/src/interfaces/api/handlers/contacts_handler.rs b/src/interfaces/api/handlers/contacts_handler.rs new file mode 100644 index 00000000..13ce917e --- /dev/null +++ b/src/interfaces/api/handlers/contacts_handler.rs @@ -0,0 +1,1036 @@ +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::domain::errors::ErrorKind; +use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter; +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>, +} + +/// 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. +#[derive(Deserialize)] +pub struct ListQuery { + #[serde(default = "default_limit")] + limit: i64, + #[serde(default)] + offset: i64, +} + +fn default_limit() -> i64 { + 100 +} + +// ── 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. +fn user_to_contact(user: UserDto) -> ContactDto { + ContactDto { + id: user.id.clone(), + address_book_id: SYSTEM_BOOK_ID.to_string(), + uid: format!("{}@oxicloud", user.id), + full_name: Some(user.username.clone()), + first_name: None, + last_name: None, + 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: None, + 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"), + ), + 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(); + + if state.auth_service.is_some() { + 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"), + ), + 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"), + ), + 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"), + ), + 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 (default 100)"), + ("offset" = Option, Query, description = "Pagination offset (default 0)"), + ), + responses( + (status = 200, description = "List of contacts"), + (status = 403, description = "Access denied"), + (status = 404, description = "Address book not found"), + ), + 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 { + let Some(auth_service) = &state.auth_service else { + return system_book_unavailable(); + }; + let caller_id = auth_user.id.to_string(); + match auth_service.list_users(params.limit, params.offset).await { + Ok(users) => { + let contacts: Vec = users + .into_iter() + .filter(|u| 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, 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"), + ), + 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"), + ), + 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 { + let Some(auth_service) = &state.auth_service else { + return system_book_unavailable(); + }; + 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"), + ), + 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"), + ), + 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"), + ), + 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"), + ), + 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"), + ), + 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"), + ), + 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"), + ), + 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"), + ), + 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"), + ), + 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"), + ), + 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) + } + } +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 9681657e..39a0ecfd 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -5,6 +5,7 @@ pub mod batch_handler; pub mod caldav_handler; pub mod carddav_handler; pub mod chunked_upload_handler; +pub mod contacts_handler; pub mod dedup_handler; pub mod device_auth_handler; pub mod favorites_handler; diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index fd4bfbbf..3f0b07c9 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -8,6 +8,9 @@ pub use routes::create_public_api_routes; use utoipa::OpenApi; +use crate::application::dtos::contact_dto::{ + AddressDto, ContactDto, ContactGroupDto, EmailDto, PhoneDto, +}; use crate::application::dtos::favorites_dto::{ BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, }; @@ -41,6 +44,10 @@ use crate::application::ports::chunked_upload_ports::{ use crate::interfaces::api::handlers::chunked_upload_handler::{ CompleteUploadResponse, CreateUploadRequest, }; +use crate::interfaces::api::handlers::contacts_handler::{ + AddMemberRequest, AddressBookResponse, CreateAddressBookRequest, CreateContactRequest, + GroupNameRequest, UpdateAddressBookRequest, UpdateContactRequest, +}; use crate::interfaces::api::handlers::dedup_handler::{ DedupUploadResponse, HashCheckResponse, StatsResponse, }; @@ -147,6 +154,24 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::music_handler::remove_share, handlers::music_handler::get_playlist_shares, handlers::music_handler::get_audio_metadata, + // Contacts / address-book handlers (free functions) + handlers::contacts_handler::list_address_books, + handlers::contacts_handler::create_address_book, + handlers::contacts_handler::update_address_book, + handlers::contacts_handler::delete_address_book, + handlers::contacts_handler::list_contacts, + handlers::contacts_handler::create_contact, + handlers::contacts_handler::get_contact, + handlers::contacts_handler::update_contact, + handlers::contacts_handler::delete_contact, + handlers::contacts_handler::list_groups, + handlers::contacts_handler::create_group, + handlers::contacts_handler::get_group, + handlers::contacts_handler::update_group, + handlers::contacts_handler::delete_group, + handlers::contacts_handler::list_contacts_in_group, + handlers::contacts_handler::add_contact_to_group, + handlers::contacts_handler::remove_contact_from_group, // Admin handlers (pub free functions) handlers::admin_handler::get_dashboard_stats, handlers::admin_handler::list_users, @@ -231,6 +256,19 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; HashCheckResponse, DedupUploadResponse, StatsResponse, + // Contacts / address-book schemas + AddressBookResponse, + CreateAddressBookRequest, + UpdateAddressBookRequest, + ContactDto, + ContactGroupDto, + EmailDto, + PhoneDto, + AddressDto, + CreateContactRequest, + UpdateContactRequest, + GroupNameRequest, + AddMemberRequest, ) ), tags( @@ -247,6 +285,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; (name = "dedup", description = "Content deduplication endpoints"), (name = "batch", description = "Batch operation endpoints"), (name = "playlists", description = "Music playlist endpoints"), + (name = "contacts", description = "Address books, contacts, and groups endpoints"), (name = "admin", description = "Admin management endpoints"), ), info( diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index d729b114..0c2e8b57 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -392,6 +392,67 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { tracing::info!("Music routes initialized"); } + // REST browse API for CardDAV contacts, groups, and OxiCloud users. + // Write operations and protocol sync remain on the /carddav endpoint. + if let Some(contact_service) = app_state.contact_use_case.clone() { + use crate::interfaces::api::handlers::contacts_handler::{self, ContactsApiState}; + + let auth_svc = app_state + .auth_service + .as_ref() + .map(|s| s.auth_application_service.clone()); + + let contacts_state = ContactsApiState { + contact_service, + auth_service: auth_svc, + }; + + let contacts_router = Router::new() + .route( + "/", + get(contacts_handler::list_address_books) + .post(contacts_handler::create_address_book), + ) + .route( + "/{book_id}", + put(contacts_handler::update_address_book) + .delete(contacts_handler::delete_address_book), + ) + .route( + "/{book_id}/contacts", + get(contacts_handler::list_contacts).post(contacts_handler::create_contact), + ) + .route( + "/{book_id}/contacts/{contact_id}", + get(contacts_handler::get_contact) + .put(contacts_handler::update_contact) + .delete(contacts_handler::delete_contact), + ) + .route( + "/{book_id}/groups", + get(contacts_handler::list_groups).post(contacts_handler::create_group), + ) + .route( + "/{book_id}/groups/{group_id}", + get(contacts_handler::get_group) + .put(contacts_handler::update_group) + .delete(contacts_handler::delete_group), + ) + .route( + "/{book_id}/groups/{group_id}/contacts", + get(contacts_handler::list_contacts_in_group) + .post(contacts_handler::add_contact_to_group), + ) + .route( + "/{book_id}/groups/{group_id}/contacts/{contact_id}", + delete(contacts_handler::remove_contact_from_group), + ) + .with_state(contacts_state); + + router = router.nest("/address-books", contacts_router); + tracing::info!("Contacts REST API routes initialized"); + } + // NOTE: WebDAV routes are mounted at top-level (/webdav) in main.rs // for client compatibility, NOT under /api.