diff --git a/backend/src/bin/admin.rs b/backend/src/bin/admin.rs index 1e02fe9..771e914 100644 --- a/backend/src/bin/admin.rs +++ b/backend/src/bin/admin.rs @@ -26,7 +26,7 @@ use papercrate::{ }, storage::{ObjectStorage, S3Storage, TenantStorage}, tenants::TenantService, - utils::tracing::init_tracing, + utils::{text::normalize_identifier, tracing::init_tracing}, }; #[derive(Parser)] @@ -162,20 +162,26 @@ async fn main() -> Result<()> { } fn create_user(pool: &PgPool, username: &str) -> Result<()> { - if username.trim().is_empty() { - bail!("username must not be empty"); - } + let username = normalize_identifier( + username, + 100, + "username must not be empty", + "username must not exceed 100 characters", + Some("username may only contain printable characters"), + |ch| !ch.is_control(), + ) + .map_err(|err| anyhow!("{:?}", err))?; let mut conn = pool.get().context("failed to get database connection")?; let exists: bool = - select(exists(users::table.filter(users::username.eq(username)))).get_result(&mut conn)?; + select(exists(users::table.filter(users::username.eq(&username)))).get_result(&mut conn)?; if exists { bail!("user '{}' already exists", username); } let new_user = NewUser { id: Uuid::new_v4(), - username: username.to_string(), + username: username.clone(), }; diesel::insert_into(users::table) diff --git a/backend/src/documents/mod.rs b/backend/src/documents/mod.rs index 452bf1f..97e08cb 100644 --- a/backend/src/documents/mod.rs +++ b/backend/src/documents/mod.rs @@ -3,6 +3,7 @@ pub mod correspondents; pub mod folders; pub mod metadata; pub mod ordering; +pub mod relations; pub mod search; pub mod tags; diff --git a/backend/src/documents/relations.rs b/backend/src/documents/relations.rs new file mode 100644 index 0000000..22ae92a --- /dev/null +++ b/backend/src/documents/relations.rs @@ -0,0 +1,33 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::documents::correspondents::{ + load_correspondents_for_documents, DocumentCorrespondentResponse, +}; +use crate::documents::tags::load_tags_for_documents; +use crate::error::AppResult; +use crate::models::Tag; +use crate::state::PgPooledConnection; + +/// Loads tags and correspondents for the provided documents in a single pass. +pub fn load_tags_and_correspondents( + conn: &mut PgPooledConnection, + document_ids: &[Uuid], +) -> AppResult, Vec)>> { + if document_ids.is_empty() { + return Ok(HashMap::new()); + } + + let tags_map = load_tags_for_documents(conn, document_ids)?; + let mut correspondents_map = load_correspondents_for_documents(conn, document_ids)?; + + let mut result = HashMap::with_capacity(document_ids.len()); + for id in document_ids { + let tags = tags_map.get(id).cloned().unwrap_or_default(); + let correspondents = correspondents_map.remove(id).unwrap_or_default(); + result.insert(*id, (tags, correspondents)); + } + + Ok(result) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index f2aa7e6..7f2d135 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -10,6 +10,7 @@ pub mod openapi; pub mod routes; pub mod s3; pub mod schema; +pub mod services; pub mod state; pub mod storage; pub mod tenants; diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index e15e58b..d4c2b00 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -74,35 +74,43 @@ pub mod schemas { }; pub use crate::documents::correspondents::DocumentCorrespondentResponse; pub use crate::models::ApiCapability; - pub use crate::routes::auth::{ - ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants, - SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse, - TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, - }; - pub use crate::routes::capability_sets::{ - CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest, - }; pub use crate::routes::correspondents::{ CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest, UpdateCorrespondentRequest, }; pub use crate::routes::documents::{ - AssetObjectsQuery, AssetRequestQuery, AssignCorrespondentsRequest, AssignTagsRequest, - BulkCorrespondentAction, BulkCorrespondentResponse, BulkCorrespondentsRequest, + AssetObjectsQuery, AssetRequestQuery, DocumentCheckQuery, MoveDocumentRequest, + RestoreDocumentRequest, UploadDocumentForm, + }; + pub use crate::routes::folders::FolderContentsResponse; + pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest}; + pub use crate::services::auth::{ + ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants, + SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse, + TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, + }; + pub use crate::services::capability_sets::{ + CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest, + }; + pub use crate::services::correspondents::{ + AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse, + BulkCorrespondentsRequest, CorrespondentAssignmentInput, + }; + pub use crate::services::documents::{ BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest, - BulkTagAction, BulkTagRequest, BulkTagResponse, CorrespondentAssignmentInput, - DocumentCheckQuery, DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, - DocumentMetadataUpdate, DocumentResponse, DocumentStatusFilter, MoveDocumentRequest, - RestoreDocumentRequest, TagResponse, UpdateDocumentRequest, UploadDocumentForm, + DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate, + DocumentResponse, DocumentStatusFilter, TagResponse, UpdateDocumentRequest, }; - pub use crate::routes::folders::{ - CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderContentsResponse, - FolderInfo, FolderResponse, UpdateFolderRequest, + pub use crate::services::folders::{ + CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderInfo, + UpdateFolderRequest, }; - pub use crate::routes::profile::{ + pub use crate::services::profile::{ ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery, }; - pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest}; + pub use crate::services::tags::{ + AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse, + }; } #[cfg(test)] diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 1f497f8..600c66e 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -1,26 +1,17 @@ use axum::{ extract::State, - http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, - response::{IntoResponse, Response}, + http::{HeaderMap, StatusCode}, + response::Response, Json, }; use axum_extra::{ headers::{authorization::Bearer, Authorization, Cookie}, typed_header::TypedHeader, }; -use chrono::{Duration as ChronoDuration, Utc}; -use diesel::{pg::PgConnection, prelude::*}; -use rand::{rngs::OsRng, RngCore}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use utoipa::{OpenApi, ToSchema}; -use uuid::Uuid; +use utoipa::OpenApi; use crate::{ auth::{ - api_tokens::{find_active_token_by_secret, touch_api_token}, - capability_sets::load_capability_set, - jwt::{AccessTokenContext, PrincipalKind}, passkeys::{ AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, @@ -28,105 +19,15 @@ use crate::{ AuthenticatedUser, }, error::{AppError, AppResult}, - http::responders::{ok_json, JsonResponse}, - models::{ - MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership, - UserSession, - }, - schema::{ - magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl, - user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, user_sessions, - users::dsl, + http::responders::JsonResponse, + services::auth::{ + ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants, + SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse, + TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME, }, state::AppState, - tenants::{ - apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc, - clear_user_session_hash, - }, }; -use crate::schema::user_sessions::dsl as session_dsl; -use webauthn_rs::prelude::RegisterPublicKeyCredential; - -const SESSION_COOKIE_NAME: &str = "refresh_token"; - -#[derive(Deserialize, ToSchema)] -pub struct LoginRequest { - #[serde(default)] - pub username: String, - #[serde(default)] - #[schema(nullable)] - pub password: Option, - #[serde(default)] - #[schema(nullable)] - pub magic_token: Option, - #[serde(default)] - #[schema(nullable)] - pub preferred_tenant_id: Option, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct ApiTokenExchangeRequest { - pub api_token: String, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct LoginResponse { - pub access_token: String, - pub token_type: String, - pub expires_in: i64, - pub tenant: TenantSnippet, -} - -#[derive(Serialize, Deserialize, ToSchema)] -pub struct TenantSnippet { - pub id: Uuid, - pub name: String, -} - -#[derive(Serialize, Deserialize, ToSchema)] -pub struct TenantSelectionResponse { - pub access_token: String, - pub tenants: Vec, -} - -#[derive(Serialize, Deserialize, ToSchema)] -pub struct TenantListResponse { - pub tenants: Vec, -} - -#[derive(Deserialize, ToSchema)] -pub struct TenantSelectionRequest { - pub tenant_id: Uuid, -} - -#[derive(Deserialize, ToSchema)] -pub struct SignupStartRequest { - pub username: String, -} - -#[derive(Serialize, Deserialize, ToSchema)] -pub struct SignupStartResponse { - pub signup_token: String, - pub challenge: RegistrationChallengeResponse, -} - -#[derive(Deserialize, ToSchema)] -pub struct SignupFinishRequest { - pub signup_token: String, - #[schema(value_type = Object)] - pub credential: RegisterPublicKeyCredential, - #[schema(nullable)] - pub nickname: Option, -} - -#[derive(Serialize, Deserialize, ToSchema)] -#[serde(untagged)] -pub enum LoginResponseVariants { - Token(LoginResponse), - Selection(TenantSelectionResponse), -} - #[derive(OpenApi)] #[openapi( paths( @@ -182,37 +83,7 @@ pub async fn login( State(state): State, Json(payload): Json, ) -> AppResult { - let magic_token = payload - .magic_token - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()); - - if magic_token.is_none() { - if payload.password.is_some() { - return Err(AppError::bad_request( - "password authentication is no longer supported", - )); - } - - return Err(AppError::bad_request( - "magic_token is required for passwordless login", - )); - } - - let token_value = magic_token.unwrap(); - - let mut conn = state.db_unscoped()?; - let username_hint = payload.username.trim(); - let preferred_tenant_id = payload.preferred_tenant_id; - - magic_token_login( - &state, - &mut conn, - token_value, - (!username_hint.is_empty()).then_some(username_hint), - preferred_tenant_id, - ) + AuthService::new(&state).login(payload) } #[utoipa::path( @@ -226,71 +97,7 @@ pub async fn api_token_exchange( State(state): State, Json(payload): Json, ) -> AppResult> { - let secret = payload.api_token.trim(); - if secret.is_empty() { - return Err(AppError::bad_request("api_token must not be empty")); - } - - let mut conn = state.db_unscoped()?; - - let token = find_active_token_by_secret(&mut conn, None, secret, None)? - .ok_or_else(AppError::unauthorized)?; - - let user: User = dsl::users.find(token.user_id).first(&mut conn)?; - - apply_user_guc(&mut conn, user.id)?; - let membership = memberships_dsl::user_memberships - .filter(memberships_dsl::user_id.eq(user.id)) - .filter(memberships_dsl::tenant_id.eq(token.tenant_id)) - .first::(&mut conn) - .optional()?; - clear_user_guc(&mut conn)?; - - let membership = membership.ok_or_else(AppError::unauthorized)?; - - let membership_capability_set = membership.capability_set_id.ok_or_else(|| { - AppError::new( - StatusCode::FORBIDDEN, - "membership has no capability set assigned", - ) - })?; - - let token_capability_set = load_capability_set(&mut conn, token.capability_set_id)?; - let _membership_set = load_capability_set(&mut conn, membership_capability_set)?; - - apply_tenant_guc(&mut conn, token.tenant_id)?; - touch_api_token(&mut conn, token.id)?; - - let access_token = state - .jwt - .generate_token(AccessTokenContext { - user_id: user.id, - tenant_id: token.tenant_id, - username: user.username.clone(), - principal_kind: PrincipalKind::ApiToken, - principal_id: token.id, - capability_set_id: token_capability_set.id, - cap_version: token_capability_set.cap_version, - }) - .map_err(AppError::from)?; - - let tenant_name: String = tenant_dsl::tenants - .find(token.tenant_id) - .select(tenant_dsl::name) - .first(&mut conn) - .map_err(AppError::from)?; - - let response = LoginResponse { - access_token, - token_type: "Bearer".to_string(), - expires_in: state.config.jwt_expiry_minutes * 60, - tenant: TenantSnippet { - id: token.tenant_id, - name: tenant_name, - }, - }; - - ok_json(response) + AuthService::new(&state).exchange_api_token(payload) } #[utoipa::path( @@ -308,37 +115,7 @@ pub async fn signup_start( State(state): State, Json(payload): Json, ) -> AppResult> { - let username = payload.username.trim(); - if username.is_empty() { - return Err(AppError::bad_request("username must not be empty")); - } - - let mut conn = state.db_unscoped()?; - let exists: bool = dsl::users - .filter(dsl::username.eq(username)) - .first::(&mut conn) - .optional()? - .is_some(); - if exists { - return Err(AppError::conflict("username already exists")); - } - - let user_id = Uuid::new_v4(); - let challenge = state - .passkeys - .as_ref() - .ok_or_else(|| AppError::bad_request("passkey support is disabled"))? - .start_signup_registration(&mut conn, user_id, username)?; - - let signup_token = state - .jwt - .generate_signup_token(user_id, challenge.challenge_id, username.to_owned()) - .map_err(AppError::from)?; - - ok_json(SignupStartResponse { - signup_token, - challenge, - }) + AuthService::new(&state).signup_start(payload) } #[utoipa::path( @@ -356,57 +133,7 @@ pub async fn signup_finish( State(state): State, Json(payload): Json, ) -> AppResult { - let claims = state - .jwt - .verify_signup_token(&payload.signup_token) - .map_err(|_| AppError::unauthorized())?; - - let mut conn = state.db_unscoped()?; - - let exists: bool = dsl::users - .filter(dsl::username.eq(&claims.username)) - .first::(&mut conn) - .optional()? - .is_some(); - if exists { - return Err(AppError::conflict("username already exists")); - } - - let service = state - .passkeys - .as_ref() - .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; - - let prepared_passkey = - service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?; - - let state_clone = state.clone(); - let response = conn.transaction::(|conn| { - insert_user(conn, claims.sub, &claims.username)?; - - let tenant = state_clone.tenants.create_tenant_with_conn( - conn, - &claims.username, - None, - None, - TenantStatus::Creating, - &[claims.sub], - Some(claims.sub), - )?; - - let passkey_insert = - prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone()); - - diesel::insert_into(passkey_dsl::user_passkeys) - .values(&passkey_insert) - .execute(conn) - .map_err(AppError::from)?; - - let user: User = dsl::users.find(claims.sub).first(conn)?; - issue_session(&state_clone, conn, &user, tenant.id) - })?; - - Ok(response) + AuthService::new(&state).signup_finish(payload) } #[utoipa::path( @@ -427,53 +154,7 @@ pub async fn refresh( .get(SESSION_COOKIE_NAME) .ok_or_else(AppError::unauthorized)?; - let hashed = hash_session_token(refresh_value); - let mut conn = state.db_unscoped()?; - let now = Utc::now(); - let now_naive = now.naive_utc(); - - apply_user_session_hash(&mut conn, &hashed)?; - let token = match session_dsl::user_sessions - .filter(session_dsl::token_hash.eq(&hashed)) - .filter(session_dsl::revoked_at.is_null()) - .filter(session_dsl::expires_at.gt(now_naive)) - .first::(&mut conn) - { - Ok(token) => token, - Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()), - Err(err) => return Err(AppError::from(err)), - }; - - clear_user_session_hash(&mut conn)?; - apply_tenant_guc(&mut conn, token.tenant_id)?; - clear_user_guc(&mut conn)?; - - diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id))) - .set(( - session_dsl::revoked_at.eq(now_naive), - session_dsl::updated_at.eq(now_naive), - )) - .execute(&mut conn)?; - - let user: User = dsl::users - .find(token.user_id) - .first(&mut conn) - .map_err(AppError::from)?; - - issue_session(&state, &mut conn, &user, token.tenant_id) -} - -fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> { - let new_user = NewUser { - id, - username: username.to_string(), - }; - - diesel::insert_into(dsl::users) - .values(&new_user) - .execute(conn) - .map(|_| ()) - .map_err(AppError::from) + AuthService::new(&state).refresh(refresh_value) } #[utoipa::path( @@ -488,37 +169,7 @@ pub async fn select_tenant( TypedHeader(Authorization(bearer)): TypedHeader>, Json(payload): Json, ) -> AppResult { - let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) { - Ok(claims) => claims.sub, - Err(_) => state - .jwt - .verify_token(bearer.token()) - .map(|claims| claims.sub) - .map_err(|_| AppError::unauthorized())?, - }; - - let mut conn = state.db_unscoped()?; - apply_user_guc(&mut conn, user_id)?; - - let membership_exists = memberships_dsl::user_memberships - .filter(memberships_dsl::user_id.eq(user_id)) - .filter(memberships_dsl::tenant_id.eq(payload.tenant_id)) - .select(memberships_dsl::tenant_id) - .first::(&mut conn) - .optional()?; - - clear_user_guc(&mut conn)?; - - if membership_exists.is_none() { - return Err(AppError::unauthorized()); - } - - let user: User = dsl::users - .find(user_id) - .first(&mut conn) - .map_err(AppError::from)?; - - issue_session(&state, &mut conn, &user, payload.tenant_id) + AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id) } #[utoipa::path( @@ -532,50 +183,18 @@ pub async fn logout( user: AuthenticatedUser, jar: Option>, ) -> AppResult<(HeaderMap, StatusCode)> { - let mut conn = state.db_for_tenant(user.tenant_id)?; - let now = Utc::now().naive_utc(); - let mut rows_affected = 0; - - if let Some(cookies) = jar { - if let Some(value) = cookies.get(SESSION_COOKIE_NAME) { - let hashed = hash_session_token(value); - rows_affected = diesel::update( - session_dsl::user_sessions - .filter(session_dsl::token_hash.eq(hashed)) - .filter(session_dsl::user_id.eq(user.user_id)) - .filter(session_dsl::revoked_at.is_null()), - ) - .set(( - session_dsl::revoked_at.eq(now), - session_dsl::updated_at.eq(now), - )) - .execute(&mut conn) - .unwrap_or(0); - } - } - - if rows_affected == 0 { - let _ = diesel::update( - session_dsl::user_sessions - .filter(session_dsl::user_id.eq(user.user_id)) - .filter(session_dsl::revoked_at.is_null()), - ) - .set(( - session_dsl::revoked_at.eq(now), - session_dsl::updated_at.eq(now), - )) - .execute(&mut conn); - } - - let mut headers = HeaderMap::new(); - headers.insert(SET_COOKIE, build_clear_session_cookie(&state)); - Ok((headers, StatusCode::NO_CONTENT)) + let refresh_cookie = jar.as_ref().and_then(|cookies| { + cookies + .get(SESSION_COOKIE_NAME) + .map(|value| value.to_owned()) + }); + AuthService::new(&state).logout(user, refresh_cookie.as_deref()) } #[utoipa::path( get, path = "/api/auth/me", - responses((status = 200, description = "Authenticated principal", body = AuthenticatedUser)), + responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)), tag = "Auth" )] pub async fn me(user: AuthenticatedUser) -> Json { @@ -585,81 +204,34 @@ pub async fn me(user: AuthenticatedUser) -> Json { #[utoipa::path( get, path = "/api/auth/tenants", - responses((status = 200, description = "Available tenants", body = TenantListResponse)), + responses((status = 200, description = "List of tenants", body = TenantListResponse)), tag = "Auth" )] pub async fn list_tenants( State(state): State, - auth: Option>>, + user: AuthenticatedUser, ) -> AppResult> { - let bearer = auth.ok_or_else(AppError::unauthorized)?; - let token = bearer.token(); - - let user_id = match state.jwt.verify_token(token) { - Ok(claims) => claims.sub, - Err(_) => { - let claims = state - .jwt - .verify_tenant_selector_token(token) - .map_err(|_| AppError::unauthorized())?; - claims.sub - } - }; - - let mut conn = state.db_unscoped()?; - apply_user_guc(&mut conn, user_id)?; - - let tenant_ids: Vec = memberships_dsl::user_memberships - .filter(memberships_dsl::user_id.eq(user_id)) - .select(memberships_dsl::tenant_id) - .load(&mut conn)?; - - clear_user_guc(&mut conn)?; - drop(conn); - - let mut tenants = Vec::with_capacity(tenant_ids.len()); - for tenant_id in tenant_ids { - let mut tenant_conn = state.db_for_tenant(tenant_id)?; - let name: String = tenant_dsl::tenants - .find(tenant_id) - .select(tenant_dsl::name) - .first(&mut tenant_conn) - .map_err(AppError::from)?; - tenants.push(TenantSnippet { - id: tenant_id, - name, - }); - } - - ok_json(TenantListResponse { tenants }) + AuthService::new(&state).list_tenants(user) } #[utoipa::path( post, path = "/api/auth/passkeys/register/start", - responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)), + responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)), tag = "Auth" )] pub async fn passkey_register_start( State(state): State, user: AuthenticatedUser, ) -> AppResult> { - let service = state - .passkeys - .as_ref() - .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; - - let mut conn = state.db_unscoped()?; - let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; - let challenge = service.start_registration(&mut conn, ¤t_user)?; - ok_json(challenge) + AuthService::new(&state).passkey_register_start(user) } #[utoipa::path( post, path = "/api/auth/passkeys/register/finish", - request_body = PasskeyRegistrationFinishPayload, - responses((status = 200, description = "Passkey registered", body = PasskeySummary)), + request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload, + responses((status = 201, body = crate::auth::passkeys::PasskeySummary)), tag = "Auth" )] pub async fn passkey_register_finish( @@ -667,65 +239,27 @@ pub async fn passkey_register_finish( user: AuthenticatedUser, Json(payload): Json, ) -> AppResult> { - let service = state - .passkeys - .as_ref() - .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; - - let mut conn = state.db_unscoped()?; - let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; - - let PasskeyRegistrationFinishPayload { - challenge_id, - credential, - nickname, - } = payload; - - let passkey = service.finish_registration( - &mut conn, - ¤t_user, - challenge_id, - credential, - nickname, - )?; - - ok_json(PasskeySummary::from(passkey)) + AuthService::new(&state).passkey_register_finish(user, payload) } #[utoipa::path( post, path = "/api/auth/passkeys/login/start", - request_body = PasskeyLoginStartPayload, - responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)), + request_body = crate::auth::passkeys::PasskeyLoginStartPayload, + responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)), tag = "Auth" )] pub async fn passkey_login_start( State(state): State, Json(payload): Json, ) -> AppResult> { - let service = state - .passkeys - .as_ref() - .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; - - let username = payload.username.trim(); - if username.is_empty() { - return Err(AppError::bad_request("username must not be empty")); - } - - let mut conn = state.db_unscoped()?; - let user: User = dsl::users - .filter(dsl::username.eq(username)) - .first(&mut conn)?; - - let challenge = service.start_authentication(&mut conn, &user)?; - ok_json(challenge) + AuthService::new(&state).passkey_login_start(&payload.username) } #[utoipa::path( post, path = "/api/auth/passkeys/login/finish", - request_body = PasskeyLoginFinishPayload, + request_body = crate::auth::passkeys::PasskeyLoginFinishPayload, responses( (status = 200, description = "Passkey login successful", body = LoginResponseVariants), (status = 401, description = "Authentication failed") @@ -736,269 +270,5 @@ pub async fn passkey_login_finish( State(state): State, Json(payload): Json, ) -> AppResult { - let service = state - .passkeys - .as_ref() - .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; - - let mut conn = state.db_unscoped()?; - let (user, _passkey, auth_result) = - service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?; - - if !auth_result.user_verified() { - return Err(AppError::unauthorized()); - } - - complete_login(&state, &mut conn, &user, None) -} - -fn complete_login( - state: &AppState, - conn: &mut PgConnection, - user: &User, - preferred_tenant_id: Option, -) -> AppResult { - apply_user_guc(conn, user.id)?; - let tenant_ids: Vec = memberships_dsl::user_memberships - .filter(memberships_dsl::user_id.eq(user.id)) - .select(memberships_dsl::tenant_id) - .load(conn)?; - clear_user_guc(conn)?; - - tracing::debug!(user_id = %user.id, tenants = tenant_ids.len(), "passkey login memberships"); - - if tenant_ids.is_empty() { - return Err(AppError::unauthorized()); - } - - if let Some(preferred_id) = preferred_tenant_id { - if tenant_ids.iter().any(|id| *id == preferred_id) { - return issue_session(state, conn, user, preferred_id); - } - } - - if tenant_ids.len() == 1 { - return issue_session(state, conn, user, tenant_ids[0]); - } - - let selection_token = state - .jwt - .generate_tenant_selector_token(user.id) - .map_err(AppError::from)?; - - let mut tenants = Vec::with_capacity(tenant_ids.len()); - for tenant_id in tenant_ids { - let mut tenant_conn = state.db_for_tenant(tenant_id)?; - let name: String = tenant_dsl::tenants - .find(tenant_id) - .select(tenant_dsl::name) - .first(&mut tenant_conn) - .map_err(AppError::from)?; - tenants.push(TenantSnippet { - id: tenant_id, - name, - }); - } - - let response = ok_json(TenantSelectionResponse { - access_token: selection_token, - tenants, - })?; - - Ok(response.into_response()) -} - -fn magic_token_login( - state: &AppState, - conn: &mut PgConnection, - token_value: &str, - username_hint: Option<&str>, - preferred_tenant_id: Option, -) -> AppResult { - if token_value.is_empty() { - return Err(AppError::bad_request("magic_token must not be empty")); - } - - let token_hash = hash_magic_token(token_value); - let now = Utc::now(); - let now_naive = now.naive_utc(); - - conn.transaction::(|conn| { - let magic = magic_dsl::magic_tokens - .filter(magic_dsl::token_hash.eq(&token_hash)) - .filter(magic_dsl::expires_at.gt(now_naive)) - .first::(conn) - .map_err(|err| match err { - diesel::result::Error::NotFound => AppError::unauthorized(), - _ => AppError::from(err), - })?; - - if let Some(limit) = magic.max_uses { - if magic.used_count >= limit { - return Err(AppError::unauthorized()); - } - } - - match magic.kind { - MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {} - } - - let user: User = dsl::users - .find(magic.user_id) - .first(conn) - .map_err(AppError::from)?; - - if let Some(expected) = username_hint { - if expected != user.username { - return Err(AppError::unauthorized()); - } - } - - diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id))) - .set(( - magic_dsl::used_count.eq(magic.used_count + 1), - magic_dsl::last_used_at.eq(Some(now_naive)), - )) - .execute(conn)?; - - complete_login(state, conn, &user, preferred_tenant_id) - }) -} - -fn issue_session( - state: &AppState, - conn: &mut PgConnection, - user: &User, - tenant_id: Uuid, -) -> AppResult { - apply_tenant_guc(conn, tenant_id)?; - clear_user_guc(conn)?; - clear_user_session_hash(conn)?; - - let membership: UserMembership = memberships_dsl::user_memberships - .filter(memberships_dsl::user_id.eq(user.id)) - .filter(memberships_dsl::tenant_id.eq(tenant_id)) - .first(conn)?; - - let capability_set_id = membership.capability_set_id.ok_or_else(|| { - AppError::new( - StatusCode::FORBIDDEN, - "membership has no capability set assigned", - ) - })?; - - let capability_set = load_capability_set(conn, capability_set_id)?; - - let now = Utc::now(); - let session_id = Uuid::new_v4(); - let access_token = state - .jwt - .generate_token(AccessTokenContext { - user_id: user.id, - tenant_id, - username: user.username.clone(), - principal_kind: PrincipalKind::UserSession, - principal_id: session_id, - capability_set_id, - cap_version: capability_set.cap_version, - }) - .map_err(AppError::from)?; - - let tenant_name: String = tenant_dsl::tenants - .find(tenant_id) - .select(tenant_dsl::name) - .first(conn) - .map_err(AppError::from)?; - - let session_value = generate_session_token(); - let session_hash = hash_session_token(&session_value); - let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); - - let new_session = NewUserSession { - id: session_id, - user_id: user.id, - token_hash: session_hash, - issued_at: now.naive_utc(), - expires_at: refresh_expires_at.naive_utc(), - tenant_id, - }; - - diesel::insert_into(user_sessions::table) - .values(&new_session) - .execute(conn)?; - - let json = ok_json(LoginResponse { - access_token, - token_type: "Bearer".to_string(), - expires_in: state.config.jwt_expiry_minutes * 60, - tenant: TenantSnippet { - id: tenant_id, - name: tenant_name, - }, - })?; - - let mut response = json.into_response(); - - response.headers_mut().insert( - SET_COOKIE, - build_session_cookie(state, &session_value, refresh_expires_at), - ); - - Ok(response) -} - -fn hash_session_token(token: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - hex::encode(hasher.finalize()) -} - -fn hash_magic_token(token: &str) -> String { - hash_session_token(token) -} - -fn generate_session_token() -> String { - let mut bytes = [0u8; 32]; - OsRng.fill_bytes(&mut bytes); - hex::encode(bytes) -} - -fn build_session_cookie( - state: &AppState, - token: &str, - expires_at: chrono::DateTime, -) -> HeaderValue { - let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds(); - - let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token)]; - parts.push("Path=/".into()); - parts.push("HttpOnly".into()); - parts.push("SameSite=Strict".into()); - parts.push(format!("Max-Age={}", max_age)); - parts.push(format!("Expires={}", expires_at.to_rfc2822())); - if state.config.refresh_cookie_secure { - parts.push("Secure".into()); - } - if let Some(domain) = &state.config.refresh_cookie_domain { - parts.push(format!("Domain={}", domain)); - } - - HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie") -} - -fn build_clear_session_cookie(state: &AppState) -> HeaderValue { - let mut parts = vec![format!("{}=", SESSION_COOKIE_NAME)]; - parts.push("Path=/".into()); - parts.push("HttpOnly".into()); - parts.push("SameSite=Strict".into()); - parts.push("Max-Age=0".into()); - parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into()); - if state.config.refresh_cookie_secure { - parts.push("Secure".into()); - } - if let Some(domain) = &state.config.refresh_cookie_domain { - parts.push(format!("Domain={}", domain)); - } - - HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie") + AuthService::new(&state).passkey_login_finish(payload) } diff --git a/backend/src/routes/capability_sets.rs b/backend/src/routes/capability_sets.rs index 55ec4cd..74ab95b 100644 --- a/backend/src/routes/capability_sets.rs +++ b/backend/src/routes/capability_sets.rs @@ -1,93 +1,17 @@ use axum::{extract::Path, http::StatusCode, Json}; -use chrono::Utc; -use diesel::prelude::*; -use serde::{Deserialize, Serialize}; +use utoipa::OpenApi; use uuid::Uuid; use crate::{ - auth::capability_sets::{ - compute_slug, create_capability_set as create_capability_set_record, is_system_slug, - load_capabilities_for_set, normalize_capabilities, refresh_capability_set, - }, auth::TenantScopedConn, - error::{AppError, AppResult}, - http::responders::{ - created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt, - }, - models::{ApiCapability, CapabilitySet}, - schema::{ - api_tokens, - capability_sets::{self, dsl as cs_dsl}, - user_memberships, + error::AppResult, + http::responders::JsonResponse, + services::capability_sets::{ + CapabilitySetResponse, CapabilitySetService, CreateCapabilitySetRequest, + UpdateCapabilitySetRequest, }, }; -#[derive(Serialize, utoipa::ToSchema)] -pub struct CapabilitySetResponse { - pub id: Uuid, - pub slug: String, - pub is_system: bool, - pub cap_version: i32, - pub capabilities: Vec, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct CreateCapabilitySetRequest { - #[serde(default)] - #[serde(rename = "slug")] - pub slug: Option, - pub capabilities: Vec, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UpdateCapabilitySetRequest { - #[serde(default)] - #[serde(rename = "slug")] - pub slug: Option, - #[serde(default)] - pub capabilities: Option>, -} - -fn normalize_slug(value: &str) -> AppResult { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(AppError::bad_request("slug must not be empty")); - } - - if trimmed.len() > 64 { - return Err(AppError::bad_request("slug must not exceed 64 characters")); - } - - let mut normalized = String::with_capacity(trimmed.len()); - for ch in trimmed.chars() { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - normalized.push(ch.to_ascii_lowercase()); - } else if ch.is_whitespace() { - normalized.push('-'); - } else { - return Err(AppError::bad_request( - "slug may only contain alphanumeric characters, hyphen, or underscore", - )); - } - } - - if normalized.is_empty() { - return Err(AppError::bad_request("slug must not be empty")); - } - - Ok(normalized) -} - -fn to_response(set: CapabilitySet, capabilities: Vec) -> CapabilitySetResponse { - CapabilitySetResponse { - id: set.id, - slug: set.slug, - is_system: set.is_system, - cap_version: set.cap_version, - capabilities, - } -} - #[utoipa::path( get, path = "/api/capability-sets", @@ -101,34 +25,19 @@ pub async fn list_capability_sets( .. }: TenantScopedConn, ) -> AppResult>> { - let sets = cs_dsl::capability_sets - .filter(cs_dsl::tenant_id.eq(tenant_id)) - .order(cs_dsl::slug.asc()) - .load::(&mut conn)?; - - let mut responses = Vec::with_capacity(sets.len()); - for set in sets { - let capabilities = load_capabilities_for_set(&mut conn, set.id)?; - responses.push(to_response(set, capabilities)); - } - - ok_json(responses) + CapabilitySetService::new().list(&mut conn, tenant_id) } #[utoipa::path( get, path = "/api/capabilities", - responses((status = 200, body = [ApiCapability])), + responses((status = 200, body = [crate::models::ApiCapability])), tag = "Capability Sets" )] pub async fn list_capabilities( TenantScopedConn { .. }: TenantScopedConn, -) -> AppResult>> { - let capabilities = ApiCapability::variants() - .iter() - .map(|value| value.parse::().expect("valid capability")) - .collect(); - ok_json(capabilities) +) -> AppResult>> { + CapabilitySetService::new().list_capabilities() } #[utoipa::path( @@ -146,14 +55,7 @@ pub async fn get_capability_set( }: TenantScopedConn, Path(id): Path, ) -> AppResult> { - let set = cs_dsl::capability_sets - .filter(cs_dsl::tenant_id.eq(tenant_id)) - .find(id) - .first::(&mut conn) - .into_app_result()?; - - let capabilities = load_capabilities_for_set(&mut conn, set.id)?; - ok_json(to_response(set, capabilities)) + CapabilitySetService::new().get(&mut conn, tenant_id, id) } #[utoipa::path( @@ -171,32 +73,7 @@ pub async fn create_capability_set( }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { - let original_caps = payload.capabilities; - let normalized_caps = normalize_capabilities(original_caps.clone())?; - if normalized_caps.is_empty() { - return Err(AppError::bad_request("at least one capability is required")); - } - - let slug = if let Some(raw) = payload.slug { - let normalized = normalize_slug(&raw)?; - if is_system_slug(&normalized) { - return Err(AppError::conflict("slug is reserved")); - } - normalized - } else { - let generated = compute_slug(&normalized_caps); - if is_system_slug(&generated) { - return Err(AppError::conflict( - "capabilities match a reserved system capability set", - )); - } - generated - }; - - let set = create_capability_set_record(&mut conn, tenant_id, &slug, original_caps)?; - let response = to_response(set, normalized_caps); - - created_json(response) + CapabilitySetService::new().create(&mut conn, tenant_id, payload) } #[utoipa::path( @@ -216,72 +93,7 @@ pub async fn update_capability_set( Path(id): Path, Json(payload): Json, ) -> AppResult> { - let set = cs_dsl::capability_sets - .filter(cs_dsl::tenant_id.eq(tenant_id)) - .find(id) - .first::(&mut conn) - .into_app_result()?; - - if set.is_system { - if payload.slug.is_some() || payload.capabilities.is_some() { - return Err(AppError::conflict( - "system capability sets cannot be modified", - )); - } - let capabilities = load_capabilities_for_set(&mut conn, set.id)?; - return ok_json(to_response(set, capabilities)); - } - - let set = conn.transaction::(|conn| { - let mut working = set.clone(); - - if let Some(slug) = &payload.slug { - let normalized = normalize_slug(slug)?; - if is_system_slug(&normalized) { - return Err(AppError::conflict("slug is reserved")); - } - - if cs_dsl::capability_sets - .filter(cs_dsl::tenant_id.eq(tenant_id)) - .filter(cs_dsl::slug.eq(&normalized)) - .filter(cs_dsl::id.ne(working.id)) - .first::(conn) - .optional() - .into_app_result()? - .is_some() - { - return Err(AppError::conflict("slug already exists")); - } - - diesel::update(cs_dsl::capability_sets.find(working.id)) - .set(( - cs_dsl::slug.eq(&normalized), - cs_dsl::updated_at.eq(Utc::now().naive_utc()), - )) - .execute(conn) - .into_app_result()?; - - working.slug = normalized; - } - - if let Some(capabilities) = &payload.capabilities { - let normalized = normalize_capabilities(capabilities.clone())?; - if normalized.is_empty() { - return Err(AppError::bad_request("at least one capability is required")); - } - - let updated = refresh_capability_set(conn, &working, &normalized)?; - working = updated; - } - - capability_sets::table - .find(working.id) - .first::(conn) - .into_app_result() - })?; - - let capabilities = load_capabilities_for_set(&mut conn, set.id)?; - ok_json(to_response(set, capabilities)) + CapabilitySetService::new().update(&mut conn, tenant_id, id, payload) } #[utoipa::path( @@ -299,49 +111,10 @@ pub async fn delete_capability_set( }: TenantScopedConn, Path(id): Path, ) -> AppResult { - let set = cs_dsl::capability_sets - .filter(cs_dsl::tenant_id.eq(tenant_id)) - .find(id) - .first::(&mut conn) - .into_app_result()?; - - if set.is_system { - return Err(AppError::conflict( - "system capability sets cannot be deleted", - )); - } - - let in_use_memberships: i64 = user_memberships::table - .filter(user_memberships::capability_set_id.eq(Some(set.id))) - .count() - .get_result(&mut conn)?; - - if in_use_memberships > 0 { - return Err(AppError::conflict( - "capability set is assigned to user memberships", - )); - } - - let in_use_tokens: i64 = api_tokens::table - .filter(api_tokens::capability_set_id.eq(set.id)) - .count() - .get_result(&mut conn)?; - - if in_use_tokens > 0 { - return Err(AppError::conflict( - "capability set is assigned to API tokens", - )); - } - - diesel::delete(cs_dsl::capability_sets.find(set.id)) - .execute(&mut conn) - .into_app_result()? - .or_not_found()?; - - no_content() + CapabilitySetService::new().delete(&mut conn, tenant_id, id) } -#[derive(utoipa::OpenApi)] +#[derive(OpenApi)] #[openapi( paths( crate::routes::capability_sets::list_capability_sets, @@ -353,9 +126,9 @@ pub async fn delete_capability_set( ), components(schemas( crate::models::ApiCapability, - crate::routes::capability_sets::CapabilitySetResponse, - crate::routes::capability_sets::CreateCapabilitySetRequest, - crate::routes::capability_sets::UpdateCapabilitySetRequest, + crate::services::capability_sets::CapabilitySetResponse, + crate::services::capability_sets::CreateCapabilitySetRequest, + crate::services::capability_sets::UpdateCapabilitySetRequest, )) )] pub struct CapabilitySetsApiDoc; diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 9b63163..51ba3fd 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -4,93 +4,40 @@ use axum::extract::{Json, Multipart, Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use chrono::{DateTime, NaiveDateTime, Utc}; -use diesel::dsl::{exists, not, sql}; -use diesel::{prelude::*, result::DatabaseErrorKind, select, sql_types::Text, OptionalExtension}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; -use tracing::{debug, error, info, warn}; +use diesel::dsl::exists; +use diesel::{prelude::*, select}; +use serde::Deserialize; +use serde_json::Value; +use tracing::{error, info}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; -use super::folders::gather_descendant_folder_ids; use crate::auth::TenantScopedConn; -use crate::documents::{ - asset::{ - build_download_path, derive_document_title, filename_with_retained_extension, - load_asset_responses, load_primary_assets, to_asset_detail_response, - to_asset_object_response, to_version_response, DocumentAssetDetailResponse, - DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse, - }, - correspondents::{ - insert_document_correspondents, load_correspondents_for_documents, - normalize_correspondent_ids, DocumentCorrespondentResponse, - }, - folders::ensure_folder_exists_on_conn, - metadata::merge_document_metadata, - ordering::{ordering_clauses, DocumentSortField, SortDirection}, - search::quickwit_search, - tags::{assign_tags as assign_tags_to_document, load_tags_for_documents}, +use crate::documents::asset::{ + DocumentAssetDetailResponse, DocumentAssetResponse, DocumentVersionDetailResponse, + DocumentVersionResponse, }; use crate::error::{AppError, AppResult}; use crate::http::responders::{accepted_json, created_json, no_content, ok_json, JsonResponse}; -use crate::jobs::{ - enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT, JOB_PURGE_DOCUMENT, +use crate::models::{Document, DocumentVersion}; +use crate::schema::{document_versions, documents, user_sessions::dsl as session_dsl}; +use crate::services::correspondents::{ + AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse, + BulkCorrespondentsRequest, CorrespondentAssignmentInput, CorrespondentsService, }; -use crate::models::{ - Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentTag, - NewDocumentVersion, Tag, +use crate::services::documents::{ + BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest, + DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate, + DocumentResponse, DocumentUploadOutcome, DocumentUploadRequest, DocumentsService, }; -use crate::schema::{ - document_asset_objects, document_assets, document_correspondents, document_tags, - document_versions, documents, folders, tags, user_sessions::dsl as session_dsl, +use crate::services::tags::{ + AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse, TagsService, }; use crate::state::AppState; -use crate::utils::{ - db::validate_bulk_ids, - error::StorageResultExt, - http::inline_content_disposition, - json::{classify_nullable, NullableValue}, - storage_paths::document_version_object_key, - time::to_iso, -}; +use crate::utils::{error::StorageResultExt, http::inline_content_disposition}; const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; -#[derive(Deserialize, IntoParams, ToSchema)] -#[into_params(parameter_in = Query)] -pub struct DocumentListQuery { - #[schema(nullable)] - pub folder_id: Option, - #[serde(default)] - #[schema(nullable)] - pub include_descendants: Option, - pub query: Option, - pub tags: Option, - pub correspondents: Option, - #[serde(default = "default_document_status_filter")] - #[schema(default = "active")] - pub status: DocumentStatusFilter, - #[serde(default)] - #[schema(default = "title")] - pub sort: DocumentSortField, - #[serde(default)] - #[schema(default = "asc")] - pub dir: SortDirection, -} - -fn default_document_status_filter() -> DocumentStatusFilter { - DocumentStatusFilter::Active -} - -#[derive(Clone, Copy, Deserialize, Serialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum DocumentStatusFilter { - Active, - Deleted, - All, -} - #[derive(Deserialize, IntoParams, ToSchema)] #[into_params(parameter_in = Query)] pub struct AssetRequestQuery { @@ -105,213 +52,6 @@ pub struct DocumentCheckQuery { pub checksum: String, } -#[derive(Serialize, ToSchema)] -pub struct DocumentCheckResponse { - pub exists: bool, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable)] - pub document_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable)] - pub filename: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable)] - pub version_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable)] - pub version_number: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable)] - pub created_at: Option, -} - -#[derive(Serialize, ToSchema)] -pub struct TagResponse { - pub id: Uuid, - pub label: String, - #[schema(nullable)] - pub color: Option, -} - -impl From for TagResponse { - fn from(tag: Tag) -> Self { - Self { - id: tag.id, - label: tag.label, - color: tag.color, - } - } -} - -#[derive(Serialize, ToSchema)] -pub struct DocumentResponse { - pub id: Uuid, - pub filename: String, - pub title: String, - pub original_name: String, - #[schema(nullable)] - pub content_type: Option, - #[schema(nullable)] - pub folder_id: Option, - pub created_at: String, - pub updated_at: String, - #[schema(nullable)] - pub deleted_at: Option, - #[schema(nullable)] - pub issued_at: Option, - #[schema(value_type = Object)] - pub metadata: Value, - pub tags: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub correspondents: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable)] - pub current_version: Option, -} -#[derive(Serialize, ToSchema)] -pub struct DocumentDetailResponse { - pub document: DocumentResponse, -} - -#[derive(Serialize, ToSchema)] -pub struct BulkReanalyzeResponse { - pub queued: usize, -} - -#[derive(Deserialize, ToSchema)] -pub struct BulkMoveRequest { - pub document_ids: Vec, - #[schema(nullable)] - pub folder_id: Option, -} - -#[derive(Deserialize, ToSchema)] -pub struct DocumentMetadataUpdate { - #[schema(value_type = Object)] - pub value: Value, - #[serde(default)] - #[schema(default = false)] - pub replace: bool, -} - -#[derive(Deserialize, ToSchema)] -pub struct UpdateDocumentRequest { - #[serde(default)] - pub title: Option, - #[serde(default)] - #[schema(nullable, value_type = Option)] - pub issued_at: Option, - #[serde(default)] - #[schema(nullable, value_type = Object)] - pub metadata: Option, -} - -#[derive(Serialize, ToSchema)] -pub struct BulkMoveResponse { - pub updated: usize, -} - -#[derive(Deserialize, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum BulkTagAction { - Add, - Remove, -} - -#[derive(Deserialize, ToSchema)] -pub struct BulkTagRequest { - pub document_ids: Vec, - pub tag_ids: Vec, - pub action: BulkTagAction, -} - -#[derive(Serialize, ToSchema)] -pub struct BulkTagResponse { - pub added: usize, - pub removed: usize, -} - -#[derive(Serialize, ToSchema)] -pub struct BulkCorrespondentResponse { - pub assigned: usize, - pub removed: usize, -} - -#[derive(Deserialize, ToSchema)] -pub struct CorrespondentAssignmentInput { - pub correspondent_id: Uuid, -} - -#[derive(Deserialize, ToSchema)] -pub struct AssignCorrespondentsRequest { - pub assignments: Vec, - #[serde(default)] - #[schema(default = false)] - pub replace: bool, -} - -#[derive(Default, AsChangeset)] -#[diesel(table_name = documents)] -struct DocumentUpdateChangeset { - title: Option, - filename: Option, - issued_at: Option>, - metadata: Option, - updated_at: Option, -} - -#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum BulkCorrespondentAction { - Add, - Remove, -} - -fn default_bulk_correspondent_action() -> BulkCorrespondentAction { - BulkCorrespondentAction::Add -} - -#[derive(Deserialize, ToSchema)] -pub struct BulkCorrespondentsRequest { - pub document_ids: Vec, - pub assignments: Vec, - #[serde(default = "default_bulk_correspondent_action")] - pub action: BulkCorrespondentAction, -} - -#[derive(Deserialize, ToSchema)] -pub struct BulkReanalyzeSelectionRequest { - pub document_ids: Vec, - #[serde(default = "default_true")] - #[schema(default = true)] - pub force: bool, -} - -fn default_true() -> bool { - true -} - -struct UploadRequest { - bytes: Vec, - original_name: String, - content_type: Option, - folder_id: Option, - metadata: Value, - title_override: Option, - tag_ids: Vec, - correspondents: Vec, - issued_at_override: Option, - skip_if_existing: bool, -} - -enum UploadOutcome { - Created(DocumentDetailResponse), - Reused(DocumentDetailResponse), -} - #[derive(ToSchema)] pub struct UploadDocumentForm { #[schema(value_type = String, format = Binary)] @@ -343,11 +83,6 @@ pub struct RestoreDocumentRequest { pub folder_id: Option, } -#[derive(Deserialize, ToSchema)] -pub struct AssignTagsRequest { - pub tag_ids: Vec, -} - #[derive(Deserialize, Default, IntoParams, ToSchema)] #[into_params(parameter_in = Query)] pub struct AssetObjectsQuery { @@ -374,265 +109,11 @@ pub async fn list_documents( .. }: TenantScopedConn, ) -> AppResult>> { - let DocumentListQuery { - folder_id, - include_descendants, - query, - tags, - correspondents, - status, - sort, - dir, - } = params; - let sort_field = sort; - let sort_direction = dir; - - let mut docs_query = documents::table - .filter(documents::tenant_id.eq(tenant_id)) - .into_boxed(); - - match status { - DocumentStatusFilter::Active => { - docs_query = docs_query.filter(documents::deleted_at.is_null()); - } - DocumentStatusFilter::Deleted => { - docs_query = docs_query.filter(documents::deleted_at.is_not_null()); - } - DocumentStatusFilter::All => {} - } - - let search_text = query - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_owned()); - let tags_param = tags - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_owned()); - let correspondents_param = correspondents - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_owned()); - let include_descendants = include_descendants.unwrap_or(true); - - match (folder_id, include_descendants) { - (Some(folder_id), true) => { - let descendant_ids = gather_descendant_folder_ids(&mut conn, tenant_id, folder_id)?; - docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids)); - } - (Some(folder_id), false) => { - docs_query = docs_query.filter(documents::folder_id.eq(Some(folder_id))); - } - (None, false) => { - docs_query = docs_query.filter(documents::folder_id.is_null()); - } - (None, true) => {} - } - - let mut filter_ids: Option> = None; - let mut quickwit_order: Option> = None; - - if let Some(query_str) = search_text.as_ref() { - debug!(query = %query_str, "performing quickwit document search"); - let endpoint = state - .config - .quickwit_endpoint - .as_ref() - .ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?; - let tenant = state.tenants.get_by_id(tenant_id)?; - let index = tenant - .quickwit_index - .as_ref() - .ok_or_else(|| AppError::internal("quickwit index not configured for tenant"))?; - - let ids = quickwit_search(endpoint, index, tenant_id, query_str) - .await - .map_err(|err| { - error!(error = ?err, "quickwit search failed"); - AppError::internal("quickwit search failed") - })?; - - if ids.is_empty() { - return ok_json(Vec::::new()); - } - - quickwit_order = Some(ids.clone()); - let set: HashSet = ids.into_iter().collect(); - filter_ids = Some(match &filter_ids { - Some(existing) => existing.intersection(&set).copied().collect(), - None => set, - }); - } - - if let Some(tags_param) = tags_param.as_ref() { - if tags_param.trim().eq_ignore_ascii_case("none") { - let docs_without_tags: Vec = documents::table - .filter(documents::tenant_id.eq(tenant_id)) - .filter(documents::deleted_at.is_null()) - .filter(not(exists( - document_tags::table.filter(document_tags::document_id.eq(documents::id)), - ))) - .select(documents::id) - .load(&mut conn)?; - - let docs_set: HashSet = docs_without_tags.into_iter().collect(); - - if docs_set.is_empty() { - return ok_json(Vec::::new()); - } - - let new_filter = match &filter_ids { - Some(existing) => existing.intersection(&docs_set).copied().collect(), - None => docs_set, - }; - - filter_ids = Some(new_filter); - } else { - let tag_ids: Result, _> = tags_param - .split(',') - .map(|s| Uuid::parse_str(s.trim())) - .collect(); - - if let Ok(ids) = tag_ids { - if !ids.is_empty() { - let mut doc_id_set: Option> = None; - for tag_id in &ids { - let docs_for_tag: Vec = document_tags::table - .filter(document_tags::tag_id.eq(*tag_id)) - .select(document_tags::document_id) - .load(&mut conn)?; - let docs_set: HashSet = docs_for_tag.into_iter().collect(); - doc_id_set = Some(match doc_id_set { - Some(existing) => existing.intersection(&docs_set).cloned().collect(), - None => docs_set, - }); - - if let Some(ref set) = doc_id_set { - if set.is_empty() { - break; - } - } - } - - let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); - - if matching_doc_ids.is_empty() { - return ok_json(Vec::::new()); - } - - let new_filter = match &filter_ids { - Some(existing) => { - existing.intersection(&matching_doc_ids).copied().collect() - } - None => matching_doc_ids.clone(), - }; - - filter_ids = Some(new_filter); - } - } - } - } - - if let Some(correspondents_param) = correspondents_param.as_ref() { - let correspondent_ids: Result, _> = correspondents_param - .split(',') - .map(|s| Uuid::parse_str(s.trim())) - .collect(); - - if let Ok(ids) = correspondent_ids { - if !ids.is_empty() { - let mut doc_id_set: Option> = None; - for correspondent_id in &ids { - let docs_for_correspondent: Vec = document_correspondents::table - .filter(document_correspondents::correspondent_id.eq(*correspondent_id)) - .select(document_correspondents::document_id) - .load(&mut conn)?; - - let docs_set: HashSet = docs_for_correspondent.into_iter().collect(); - doc_id_set = Some(match doc_id_set { - Some(existing) => existing.intersection(&docs_set).cloned().collect(), - None => docs_set, - }); - - if let Some(ref set) = doc_id_set { - if set.is_empty() { - break; - } - } - } - - let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); - - if matching_doc_ids.is_empty() { - return ok_json(Vec::::new()); - } - - let new_filter = match &filter_ids { - Some(existing) => existing.intersection(&matching_doc_ids).copied().collect(), - None => matching_doc_ids.clone(), - }; - - filter_ids = Some(new_filter); - } - } - } - - if let Some(ref set) = filter_ids { - if set.is_empty() { - return ok_json(Vec::::new()); - } - - let ids_vec: Vec = set.iter().copied().collect(); - docs_query = docs_query.filter(documents::id.eq_any(ids_vec)); - } - - let (primary_sql, secondary_sql) = ordering_clauses(sort_field, sort_direction); - docs_query = docs_query.order(sql::(primary_sql)); - if let Some(second) = secondary_sql { - docs_query = docs_query.then_order_by(sql::(second)); - } - - let docs: Vec = if let Some(order_ids) = quickwit_order.as_ref() { - let relevant_ids: Vec = if let Some(filter_set) = filter_ids.as_ref() { - order_ids - .iter() - .copied() - .filter(|id| filter_set.contains(id)) - .collect() - } else { - order_ids.clone() - }; - - if relevant_ids.is_empty() { - return ok_json(Vec::::new()); - } - - let mut fetched: Vec = docs_query.load(&mut conn)?; - let mut ordered = Vec::with_capacity(fetched.len()); - - for id in relevant_ids { - if let Some(pos) = fetched.iter().position(|doc| doc.id == id) { - ordered.push(fetched.remove(pos)); - } - } - - // Any documents not referenced by the search results remain in the - // order supplied by the database (title ASC or the chosen sorting). - if !fetched.is_empty() { - ordered.extend(fetched); - } - - ordered - } else { - docs_query.load(&mut conn)? - }; - - let response = hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?; - - ok_json(response) + let service = DocumentsService::new(&state); + let documents = service + .list_documents(&mut conn, tenant_id, user_id, params) + .await?; + ok_json(documents) } #[utoipa::path( @@ -643,6 +124,7 @@ pub async fn list_documents( tag = "Documents" )] pub async fn check_document( + State(state): State, Query(query): Query, TenantScopedConn { mut conn, @@ -662,37 +144,9 @@ pub async fn check_document( )); } - let record: Option<(Document, DocumentVersion)> = documents::table - .inner_join( - document_versions::table.on(document_versions::id.eq(documents::current_version_id)), - ) - .filter(documents::tenant_id.eq(tenant_id)) - .filter(document_versions::checksum.eq(&checksum)) - .select((documents::all_columns, document_versions::all_columns)) - .first(&mut conn) - .optional()?; - - if let Some((document, version)) = record { - ok_json(DocumentCheckResponse { - exists: true, - document_id: Some(document.id), - title: Some(document.title.clone()), - filename: Some(document.filename.clone()), - version_id: Some(version.id), - version_number: Some(version.version_number), - created_at: Some(to_iso(document.created_at)), - }) - } else { - ok_json(DocumentCheckResponse { - exists: false, - document_id: None, - title: None, - filename: None, - version_id: None, - version_number: None, - created_at: None, - }) - } + let service = DocumentsService::new(&state); + let response = service.check_document(&mut conn, tenant_id, &checksum)?; + ok_json(response) } #[utoipa::path( @@ -712,36 +166,11 @@ pub async fn get_document( .. }: TenantScopedConn, ) -> AppResult> { - let doc: Document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - if doc.deleted_at.is_some() { - return Err(AppError::not_found()); - } - - let current_version: DocumentVersion = document_versions::table - .find(doc.current_version_id) - .first(&mut conn)?; - - let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; - let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?; - let version_id = current_version.id; - drop(conn); - - let assets = load_asset_responses(&state, tenant_id, version_id).await?; - let version_response = to_version_response(current_version); - - ok_json(DocumentDetailResponse { - document: to_document_response( - &state, - user_id, - doc, - tags_map.get(&document_id).cloned(), - correspondents_map.remove(&document_id).unwrap_or_default(), - Some((version_response, assets)), - )?, - }) + let service = DocumentsService::new(&state); + let detail = service + .get_document_detail(&mut conn, tenant_id, user_id, document_id) + .await?; + ok_json(detail) } #[utoipa::path( @@ -909,7 +338,7 @@ pub async fn upload_document( })?; let original_name_for_log = original_name.clone(); - let request = UploadRequest { + let request = DocumentUploadRequest { bytes: file_bytes, original_name, content_type, @@ -922,7 +351,8 @@ pub async fn upload_document( skip_if_existing, }; - let outcome = match process_upload(&state, request, tenant_id, user_id).await { + let service = DocumentsService::new(&state); + let outcome = match service.upload_document(tenant_id, user_id, request).await { Ok(outcome) => outcome, Err(err) => { error!(error = ?err, original_name = %original_name_for_log, "document upload failed"); @@ -931,7 +361,7 @@ pub async fn upload_document( }; let response = match outcome { - UploadOutcome::Created(detail) => { + DocumentUploadOutcome::Created(detail) => { info!( document_id = %detail.document.id, original_name = %detail.document.original_name, @@ -941,7 +371,7 @@ pub async fn upload_document( ); created_json(detail)? } - UploadOutcome::Reused(detail) => { + DocumentUploadOutcome::Reused(detail) => { info!( document_id = %detail.document.id, original_name = %detail.document.original_name, @@ -964,6 +394,7 @@ pub async fn upload_document( tag = "Assets" )] pub async fn request_document_assets( + State(state): State, Path(document_id): Path, Query(query): Query, TenantScopedConn { @@ -972,30 +403,8 @@ pub async fn request_document_assets( .. }: TenantScopedConn, ) -> AppResult { - let document: Document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - if document.deleted_at.is_some() { - return Err(AppError::not_found()); - } - - enqueue_job( - &mut conn, - tenant_id, - JOB_ANALYZE_DOCUMENT, - json!({ - "document_id": document_id, - "document_version_id": document.current_version_id, - "force": query.force, - }), - None, - ) - .map_err(|err| { - error!(error = ?err, "failed to enqueue analyze job"); - AppError::internal("failed to enqueue analyze job") - })?; - + let service = DocumentsService::new(&state); + service.request_document_assets(&mut conn, tenant_id, document_id, query.force)?; Ok(StatusCode::ACCEPTED) } @@ -1007,6 +416,7 @@ pub async fn request_document_assets( tag = "Documents" )] pub async fn reanalyze_selected_documents( + State(state): State, TenantScopedConn { mut conn, tenant_id, @@ -1019,41 +429,8 @@ pub async fn reanalyze_selected_documents( force, } = payload; - validate_bulk_ids(&mut document_ids, "document_ids")?; - - let targets: Vec<(Uuid, Uuid)> = documents::table - .filter(documents::id.eq_any(&document_ids)) - .filter(documents::deleted_at.is_null()) - .filter(documents::tenant_id.eq(tenant_id)) - .select((documents::id, documents::current_version_id)) - .load(&mut conn)?; - - if targets.len() != document_ids.len() { - return Err(AppError::bad_request( - "one or more documents do not exist or are inaccessible", - )); - } - - let mut queued = 0usize; - for (document_id, version_id) in targets { - enqueue_job( - &mut conn, - tenant_id, - JOB_ANALYZE_DOCUMENT, - json!({ - "document_id": document_id, - "document_version_id": version_id, - "force": force, - }), - None, - ) - .map_err(|err| { - error!(error = ?err, "failed to enqueue analyze job"); - AppError::internal("failed to enqueue analyze job") - })?; - queued += 1; - } - + let service = DocumentsService::new(&state); + let queued = service.reanalyze_documents(&mut conn, tenant_id, &mut document_ids, force)?; accepted_json(BulkReanalyzeResponse { queued }) } @@ -1073,18 +450,10 @@ pub async fn list_document_assets( .. }: TenantScopedConn, ) -> AppResult>> { - let document: Document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - if document.deleted_at.is_some() { - return Err(AppError::not_found()); - } - - let version_id = document.current_version_id; - drop(conn); - - let assets = load_asset_responses(&state, tenant_id, version_id).await?; + let service = DocumentsService::new(&state); + let assets = service + .list_document_assets(&mut conn, tenant_id, document_id) + .await?; ok_json(assets) } @@ -1105,16 +474,6 @@ pub async fn get_document_asset( .. }: TenantScopedConn, ) -> AppResult> { - let asset: DocumentAsset = match document_assets::table - .find(asset_id) - .filter(document_assets::tenant_id.eq(tenant_id)) - .first(&mut conn) - .optional()? - { - Some(asset) => asset, - None => return Err(AppError::not_found()), - }; - let start = query.start.unwrap_or(1); let limit = query.limit.unwrap_or(1); if start < 1 { @@ -1123,61 +482,11 @@ pub async fn get_document_asset( if limit < 1 { return Err(AppError::bad_request("limit must be at least 1")); } - - let end = start - .checked_add(limit - 1) - .ok_or_else(|| AppError::bad_request("requested range is too large"))?; - - let objects: Vec = document_asset_objects::table - .filter(document_asset_objects::asset_id.eq(asset_id)) - .filter(document_asset_objects::tenant_id.eq(tenant_id)) - .filter(document_asset_objects::ordinal.ge(start)) - .filter(document_asset_objects::ordinal.le(end)) - .order(document_asset_objects::ordinal.asc()) - .load(&mut conn)?; - - drop(conn); - - let expires_at = Utc::now() - .timestamp_millis() - .checked_add((PRESIGNED_URL_EXPIRY_SECONDS as i64) * 1000) - .ok_or_else(|| AppError::internal("failed to compute expiry timestamp"))?; - - let storage = state.storage_for_tenant(tenant_id)?; - - let mut object_responses = Vec::with_capacity(objects.len()); - for object in objects { - let response_disposition = presign_disposition_for_asset(&asset, &object); - - let url = storage - .presign_get_object( - &object.s3_key, - Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), - response_disposition.as_deref(), - ) - .await - .storage_context("failed to generate asset URL")?; - - object_responses.push(to_asset_object_response( - object, - Some(url), - Some(expires_at), - )); - } - - if object_responses.is_empty() { - return Err(AppError::not_found()); - } - - ok_json(to_asset_detail_response(asset, object_responses)) -} - -fn presign_disposition_for_asset( - asset: &DocumentAsset, - object: &DocumentAssetObject, -) -> Option { - let filename = format!("{}-{}", asset.asset_type, object.ordinal); - inline_content_disposition(&filename) + let service = DocumentsService::new(&state); + let detail = service + .get_document_asset(&mut conn, tenant_id, asset_id, start, limit) + .await?; + ok_json(detail) } #[utoipa::path( @@ -1188,6 +497,7 @@ fn presign_disposition_for_asset( tag = "Documents" )] pub async fn list_document_versions( + State(state): State, Path(document_id): Path, TenantScopedConn { mut conn, @@ -1195,24 +505,8 @@ pub async fn list_document_versions( .. }: TenantScopedConn, ) -> AppResult>> { - let document: Document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - - if document.deleted_at.is_some() { - return Err(AppError::not_found()); - } - - let versions: Vec = document_versions::table - .filter(document_versions::document_id.eq(document_id)) - .filter(document_versions::tenant_id.eq(tenant_id)) - .order(document_versions::version_number.asc()) - .load(&mut conn)?; - - let versions: Vec = - versions.into_iter().map(to_version_response).collect(); - + let service = DocumentsService::new(&state); + let versions = service.list_document_versions(&mut conn, tenant_id, document_id)?; ok_json(versions) } @@ -1236,32 +530,11 @@ pub async fn get_document_version( .. }: TenantScopedConn, ) -> AppResult> { - let document: Document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - - if document.deleted_at.is_some() { - return Err(AppError::not_found()); - } - - let version: DocumentVersion = document_versions::table - .find(version_id) - .filter(document_versions::document_id.eq(document_id)) - .filter(document_versions::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - - drop(conn); - - let assets = load_asset_responses(&state, tenant_id, version.id).await?; - let download_path = build_download_path(&state, &document, user_id)?; - let version_core = to_version_response(version); - - ok_json(DocumentVersionDetailResponse { - version: version_core, - assets, - download_path, - }) + let service = DocumentsService::new(&state); + let detail = service + .get_document_version(&mut conn, tenant_id, user_id, document_id, version_id) + .await?; + ok_json(detail) } #[utoipa::path( @@ -1334,6 +607,7 @@ pub async fn download_with_token( tag = "Documents" )] pub async fn trash_document( + State(state): State, Path(document_id): Path, TenantScopedConn { mut conn, @@ -1341,17 +615,8 @@ pub async fn trash_document( .. }: TenantScopedConn, ) -> AppResult { - let now = Utc::now().naive_utc(); - diesel::update( - documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)), - ) - .set(( - documents::deleted_at.eq(Some(now)), - documents::updated_at.eq(now), - )) - .execute(&mut conn)?; + let service = DocumentsService::new(&state); + service.trash_document(&mut conn, tenant_id, document_id)?; Ok(StatusCode::NO_CONTENT) } @@ -1363,6 +628,7 @@ pub async fn trash_document( tag = "Documents" )] pub async fn delete_document( + State(state): State, Path(document_id): Path, TenantScopedConn { mut conn, @@ -1370,31 +636,8 @@ pub async fn delete_document( .. }: TenantScopedConn, ) -> AppResult { - conn.transaction::<(), AppError, _>(|conn| { - let document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .for_update() - .first::(conn) - .optional()? - .ok_or_else(AppError::not_found)?; - - if document.deleted_at.is_none() { - return Err(AppError::conflict( - "document must be trashed before permanent deletion", - )); - } - - let payload = json!({ "document_id": document_id }); - match enqueue_job(conn, tenant_id, JOB_PURGE_DOCUMENT, payload, None) { - Ok(_) => Ok(()), - Err(JobQueueError::Database(diesel::result::Error::DatabaseError( - DatabaseErrorKind::UniqueViolation, - _, - ))) => Ok(()), - Err(JobQueueError::Database(err)) => Err(AppError::from(err)), - } - })?; + let service = DocumentsService::new(&state); + service.delete_document(&mut conn, tenant_id, document_id)?; Ok(StatusCode::ACCEPTED) } @@ -1418,165 +661,11 @@ pub async fn update_document( }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { - let mut document: Document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - if document.deleted_at.is_some() { - return Err(AppError::not_found()); - } - - let payload_obj = payload - .as_object() - .ok_or_else(|| AppError::bad_request("request body must be a JSON object"))?; - - let title = match payload_obj.get("title") { - None | Some(Value::Null) => None, - Some(Value::String(value)) => Some(value.clone()), - Some(_) => return Err(AppError::bad_request("title must be a string")), - }; - - let issued_at_class = - classify_nullable(payload_obj.get("issued_at")).map_err(AppError::bad_request)?; - - let metadata = match payload_obj.get("metadata") { - None | Some(Value::Null) => None, - Some(value) => Some( - serde_json::from_value::(value.clone()) - .map_err(|err| AppError::bad_request(format!("invalid metadata payload: {err}")))?, - ), - }; - - let mut changes = DocumentUpdateChangeset::default(); - let mut has_changes = false; - let mut title_changed = false; - - if let Some(ref candidate) = title { - let trimmed = candidate.trim(); - if trimmed.is_empty() { - return Err(AppError::bad_request("title must not be empty")); - } - if trimmed != document.title { - let new_title = trimmed.to_string(); - let new_filename = filename_with_retained_extension(&new_title, &document.filename); - changes.title = Some(new_title); - if new_filename != document.filename { - changes.filename = Some(new_filename); - } - has_changes = true; - title_changed = true; - } - } - - match issued_at_class { - NullableValue::Omitted => {} - NullableValue::Null => { - if document.issued_at.is_some() { - changes.issued_at = Some(None); - has_changes = true; - } - } - NullableValue::String(raw) => { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(AppError::bad_request("issued_at must not be empty")); - } - let parsed = DateTime::parse_from_rfc3339(trimmed).map_err(|err| { - let msg = format!("issued_at must be an RFC3339 timestamp: {err}"); - AppError::bad_request(msg) - })?; - let normalized = Some(parsed.naive_utc()); - if document.issued_at != normalized { - changes.issued_at = Some(normalized); - has_changes = true; - } - } - } - - if let Some(metadata_update) = metadata { - let next_metadata = if metadata_update.replace { - metadata_update.value - } else { - merge_document_metadata(document.metadata.clone(), metadata_update.value)? - }; - - if document.metadata != next_metadata { - changes.metadata = Some(next_metadata); - has_changes = true; - } - } - - if !has_changes { - return Err(AppError::bad_request("no changes provided")); - } - - let now = Utc::now().naive_utc(); - changes.updated_at = Some(now); - - let target = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)); - - let update_result = diesel::update(target).set(&changes); - - match update_result.execute(&mut conn) { - Ok(_) => {} - Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { - return Err(AppError::conflict( - "another document in this folder already uses that filename", - ) - .with_code("duplicate_filename")); - } - Err(err) => return Err(AppError::from(err)), - } - - document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - - let current_version: DocumentVersion = document_versions::table - .find(document.current_version_id) - .first(&mut conn)?; - - if title_changed { - if let Err(err) = enqueue_job( - &mut conn, - tenant_id, - JOB_INDEX_DOCUMENT_TEXT, - json!({ - "document_id": document.id, - "document_version_id": current_version.id, - }), - None, - ) { - warn!( - document_id = %document.id, - version_id = %current_version.id, - error = %err, - "failed to enqueue reindex job after title change" - ); - } - } - - let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; - let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?; - let version_id = current_version.id; - drop(conn); - - let assets = load_asset_responses(&state, tenant_id, version_id).await?; - let version_response = to_version_response(current_version); - - ok_json(DocumentDetailResponse { - document: to_document_response( - &state, - user_id, - document, - tags_map.get(&document_id).cloned(), - correspondents_map.remove(&document_id).unwrap_or_default(), - Some((version_response, assets)), - )?, - }) + let service = DocumentsService::new(&state); + let detail = service + .update_document(&mut conn, tenant_id, user_id, document_id, payload) + .await?; + ok_json(detail) } #[utoipa::path( @@ -1588,6 +677,7 @@ pub async fn update_document( tag = "Documents" )] pub async fn restore_document( + State(state): State, Path(document_id): Path, TenantScopedConn { mut conn, @@ -1596,44 +686,8 @@ pub async fn restore_document( }: TenantScopedConn, Json(payload): Json, ) -> AppResult { - let mut document: Document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - - if document.deleted_at.is_none() { - return Ok(StatusCode::NO_CONTENT); - } - - if let Some(folder_id) = payload.folder_id { - ensure_folder_exists_on_conn(&mut conn, tenant_id, folder_id)?; - document.folder_id = Some(folder_id); - } else if let Some(folder_id) = document.folder_id { - let exists: bool = diesel::select(exists( - folders::table - .filter(folders::id.eq(folder_id)) - .filter(folders::tenant_id.eq(tenant_id)), - )) - .get_result(&mut conn)?; - - if !exists { - document.folder_id = None; - } - } - - let now = Utc::now().naive_utc(); - diesel::update( - documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)), - ) - .set(( - documents::deleted_at.eq::>(None), - documents::folder_id.eq(document.folder_id), - documents::updated_at.eq(now), - )) - .execute(&mut conn)?; - + let service = DocumentsService::new(&state); + service.restore_document(&mut conn, tenant_id, document_id, payload.folder_id)?; Ok(StatusCode::NO_CONTENT) } @@ -1646,6 +700,7 @@ pub async fn restore_document( tag = "Documents" )] pub async fn move_document( + State(state): State, Path(document_id): Path, TenantScopedConn { mut conn, @@ -1654,21 +709,8 @@ pub async fn move_document( }: TenantScopedConn, Json(payload): Json, ) -> AppResult { - if let Some(folder_id) = payload.folder_id { - ensure_folder_exists_on_conn(&mut conn, tenant_id, folder_id)?; - } - - let now = Utc::now().naive_utc(); - diesel::update( - documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)), - ) - .set(( - documents::folder_id.eq(payload.folder_id), - documents::updated_at.eq(now), - )) - .execute(&mut conn)?; + let service = DocumentsService::new(&state); + service.move_document(&mut conn, tenant_id, document_id, payload.folder_id)?; Ok(StatusCode::NO_CONTENT) } @@ -1681,6 +723,7 @@ pub async fn move_document( tag = "Documents" )] pub async fn bulk_move_documents( + State(state): State, TenantScopedConn { mut conn, tenant_id, @@ -1689,78 +732,13 @@ pub async fn bulk_move_documents( Json(payload): Json, ) -> AppResult> { let BulkMoveRequest { - mut document_ids, + document_ids, folder_id, } = payload; - if document_ids.is_empty() { - return Err(AppError::bad_request("document_ids must not be empty")); - } - - document_ids.sort(); - document_ids.dedup(); - - if let Some(target_folder) = folder_id { - ensure_folder_exists_on_conn(&mut conn, tenant_id, target_folder)?; - } - - let existing: Vec<(Uuid, Option)> = documents::table - .filter(documents::id.eq_any(&document_ids)) - .filter(documents::tenant_id.eq(tenant_id)) - .select((documents::id, documents::deleted_at)) - .load(&mut conn)?; - - if existing.len() != document_ids.len() { - return Err(AppError::bad_request( - "one or more documents do not exist or are inaccessible", - )); - } - - if existing.iter().any(|(_, deleted)| deleted.is_some()) { - return Err(AppError::bad_request("cannot move deleted documents")); - } - - let now = Utc::now().naive_utc(); - let updated = match diesel::update( - documents::table - .filter(documents::id.eq_any(&document_ids)) - .filter(documents::tenant_id.eq(tenant_id)), - ) - .set(( - documents::folder_id.eq(folder_id), - documents::updated_at.eq(now), - )) - .execute(&mut conn) - { - Ok(value) => value, - Err(diesel::result::Error::DatabaseError(kind, info)) => { - error!( - ?kind, - detail = ?info.details(), - constraint = info.constraint_name(), - tenant_id = %tenant_id, - target_folder = folder_id.map(|id| id.to_string()), - "bulk move update failed" - ); - let message = info - .constraint_name() - .map(|name| format!("constraint {name} prevented moving documents")) - .unwrap_or_else(|| "unable to move documents due to a constraint".to_string()); - return Err(AppError::conflict(message)); - } - Err(err) => { - error!( - ?err, - tenant_id = %tenant_id, - target_folder = folder_id.map(|id| id.to_string()), - "bulk move update failed" - ); - return Err(AppError::from(err)); - } - }; - - let body = BulkMoveResponse { updated }; - ok_json(body) + let service = DocumentsService::new(&state); + let updated = service.bulk_move_documents(&mut conn, tenant_id, document_ids, folder_id)?; + ok_json(BulkMoveResponse { updated }) } #[utoipa::path( @@ -1772,6 +750,7 @@ pub async fn bulk_move_documents( tag = "Documents" )] pub async fn assign_correspondents( + State(state): State, Path(document_id): Path, TenantScopedConn { mut conn, @@ -1781,74 +760,8 @@ pub async fn assign_correspondents( }: TenantScopedConn, Json(payload): Json, ) -> AppResult { - if payload.assignments.is_empty() { - return Err(AppError::bad_request("assignments must not be empty")); - } - - let raw_correspondent_ids: Vec = payload - .assignments - .iter() - .map(|assignment| assignment.correspondent_id) - .collect(); - let correspondent_ids = normalize_correspondent_ids(&raw_correspondent_ids)?; - let replace = payload.replace; - - conn.transaction::<(), AppError, _>(|conn| { - let document: Document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(conn)?; - if document.deleted_at.is_some() { - return Err(AppError::not_found()); - } - - let mut updated = false; - if replace { - use diesel::dsl::not; - - let base = document_correspondents::table - .filter(document_correspondents::document_id.eq(document_id)) - .filter(document_correspondents::tenant_id.eq(tenant_id)); - - let removed = if correspondent_ids.is_empty() { - diesel::delete(base).execute(conn)? - } else { - diesel::delete(base.filter(not( - document_correspondents::correspondent_id.eq_any(&correspondent_ids), - ))) - .execute(conn)? - }; - - if removed > 0 { - updated = true; - } - } - - let inserted = insert_document_correspondents( - conn, - tenant_id, - document.id, - user_id, - &correspondent_ids, - )?; - - if inserted > 0 { - updated = true; - } - - if updated && inserted == 0 { - diesel::update( - documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)), - ) - .set(documents::updated_at.eq(Utc::now().naive_utc())) - .execute(conn)?; - } - - Ok(()) - })?; - + let service = CorrespondentsService::new(&state); + service.assign_to_document(&mut conn, tenant_id, user_id, document_id, &payload)?; no_content() } @@ -1860,6 +773,7 @@ pub async fn assign_correspondents( tag = "Documents" )] pub async fn bulk_assign_correspondents( + State(state): State, TenantScopedConn { mut conn, tenant_id, @@ -1868,87 +782,9 @@ pub async fn bulk_assign_correspondents( }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { - if payload.assignments.is_empty() { - return Err(AppError::bad_request("assignments must not be empty")); - } - - let mut document_ids = payload.document_ids; - validate_bulk_ids(&mut document_ids, "document_ids")?; - - let raw_correspondent_ids: Vec = payload - .assignments - .iter() - .map(|assignment| assignment.correspondent_id) - .collect(); - let correspondent_ids = normalize_correspondent_ids(&raw_correspondent_ids)?; - let action = payload.action; - let user_id_val = user_id; - let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| { - let docs: Vec<(Uuid, Option)> = documents::table - .filter(documents::id.eq_any(&document_ids)) - .filter(documents::tenant_id.eq(tenant_id)) - .select((documents::id, documents::deleted_at)) - .load(conn)?; - - if docs.len() != document_ids.len() { - return Err(AppError::bad_request( - "one or more documents do not exist or are inaccessible", - )); - } - - if docs.iter().any(|(_, deleted)| deleted.is_some()) { - return Err(AppError::bad_request( - "cannot assign correspondents to deleted documents", - )); - } - - match action { - BulkCorrespondentAction::Add => { - let mut assigned_total = 0; - for (doc_id, _) in &docs { - assigned_total += insert_document_correspondents( - conn, - tenant_id, - *doc_id, - user_id_val, - &correspondent_ids, - )?; - } - - Ok((assigned_total, 0)) - } - BulkCorrespondentAction::Remove => { - if correspondent_ids.is_empty() { - return Ok((0, 0)); - } - - let removed = diesel::delete( - document_correspondents::table - .filter(document_correspondents::document_id.eq_any(&document_ids)) - .filter(document_correspondents::tenant_id.eq(tenant_id)) - .filter( - document_correspondents::correspondent_id.eq_any(&correspondent_ids), - ), - ) - .execute(conn)?; - - if removed > 0 { - diesel::update( - documents::table - .filter(documents::id.eq_any(&document_ids)) - .filter(documents::tenant_id.eq(tenant_id)), - ) - .set(documents::updated_at.eq(Utc::now().naive_utc())) - .execute(conn)?; - } - - Ok((0, removed)) - } - } - })?; - - let body = BulkCorrespondentResponse { assigned, removed }; - ok_json(body) + let service = CorrespondentsService::new(&state); + let response = service.bulk_update(&mut conn, tenant_id, user_id, payload)?; + ok_json(response) } #[utoipa::path( @@ -1962,6 +798,7 @@ pub async fn bulk_assign_correspondents( tag = "Documents" )] pub async fn remove_correspondent( + State(state): State, Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>, TenantScopedConn { mut conn, @@ -1969,34 +806,8 @@ pub async fn remove_correspondent( .. }: TenantScopedConn, ) -> AppResult { - let document: Document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - if document.deleted_at.is_some() { - return Err(AppError::not_found()); - } - - let deleted = diesel::delete( - document_correspondents::table - .filter(document_correspondents::document_id.eq(document_id)) - .filter(document_correspondents::tenant_id.eq(tenant_id)) - .filter(document_correspondents::correspondent_id.eq(correspondent_id)), - ) - .execute(&mut conn)?; - - if deleted == 0 { - return Err(AppError::not_found()); - } - - diesel::update( - documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)), - ) - .set(documents::updated_at.eq(Utc::now().naive_utc())) - .execute(&mut conn)?; - + let service = CorrespondentsService::new(&state); + service.remove_from_document(&mut conn, tenant_id, document_id, correspondent_id)?; no_content() } @@ -2009,6 +820,7 @@ pub async fn remove_correspondent( tag = "Documents" )] pub async fn assign_tags( + State(state): State, Path(document_id): Path, TenantScopedConn { mut conn, @@ -2018,23 +830,8 @@ pub async fn assign_tags( }: TenantScopedConn, Json(payload): Json, ) -> AppResult { - if payload.tag_ids.is_empty() { - return Err(AppError::bad_request("tag_ids must not be empty")); - } - - let document: Document = documents::table - .find(document_id) - .filter(documents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - - assign_tags_to_document( - &mut conn, - tenant_id, - &document, - &payload.tag_ids, - Some(user_id), - )?; - + let service = TagsService::new(&state); + service.assign_to_document(&mut conn, tenant_id, user_id, document_id, &payload.tag_ids)?; Ok(StatusCode::NO_CONTENT) } @@ -2046,6 +843,7 @@ pub async fn assign_tags( tag = "Documents" )] pub async fn bulk_update_tags( + State(state): State, TenantScopedConn { mut conn, tenant_id, @@ -2054,79 +852,8 @@ pub async fn bulk_update_tags( }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { - let BulkTagRequest { - mut document_ids, - mut tag_ids, - action, - } = payload; - - validate_bulk_ids(&mut document_ids, "document_ids")?; - validate_bulk_ids(&mut tag_ids, "tag_ids")?; - - let docs: Vec<(Uuid, Option)> = documents::table - .filter(documents::id.eq_any(&document_ids)) - .filter(documents::tenant_id.eq(tenant_id)) - .select((documents::id, documents::deleted_at)) - .load(&mut conn)?; - - if docs.len() != document_ids.len() { - return Err(AppError::bad_request( - "one or more documents do not exist or are inaccessible", - )); - } - - if docs.iter().any(|(_, deleted)| deleted.is_some()) { - return Err(AppError::bad_request( - "cannot assign or remove tags from deleted documents", - )); - } - - let existing_tags: Vec = tags::table - .filter(tags::id.eq_any(&tag_ids)) - .filter(tags::tenant_id.eq(tenant_id)) - .load(&mut conn)?; - if existing_tags.len() != tag_ids.len() { - return Err(AppError::bad_request("one or more tags do not exist")); - } - - let response = match action { - BulkTagAction::Add => { - let mut inserts = Vec::with_capacity(document_ids.len() * tag_ids.len()); - for doc_id in &document_ids { - for tag_id in &tag_ids { - inserts.push(NewDocumentTag { - document_id: *doc_id, - tag_id: *tag_id, - assigned_by: Some(user_id), - tenant_id, - }); - } - } - - let added = if inserts.is_empty() { - 0 - } else { - diesel::insert_into(document_tags::table) - .values(&inserts) - .on_conflict_do_nothing() - .execute(&mut conn)? - }; - - BulkTagResponse { added, removed: 0 } - } - BulkTagAction::Remove => { - let removed = diesel::delete( - document_tags::table - .filter(document_tags::document_id.eq_any(&document_ids)) - .filter(document_tags::tenant_id.eq(tenant_id)) - .filter(document_tags::tag_id.eq_any(&tag_ids)), - ) - .execute(&mut conn)?; - - BulkTagResponse { added: 0, removed } - } - }; - + let service = TagsService::new(&state); + let response = service.bulk_update(&mut conn, tenant_id, user_id, payload)?; ok_json(response) } @@ -2142,372 +869,18 @@ pub async fn bulk_update_tags( )] pub async fn remove_tag( Path((document_id, tag_id)): Path<(Uuid, Uuid)>, + State(state): State, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { - diesel::delete( - document_tags::table - .filter(document_tags::document_id.eq(document_id)) - .filter(document_tags::tenant_id.eq(tenant_id)) - .filter(document_tags::tag_id.eq(tag_id)), - ) - .execute(&mut conn)?; - + let service = TagsService::new(&state); + service.remove_from_document(&mut conn, tenant_id, document_id, tag_id)?; no_content() } -async fn process_upload( - state: &AppState, - request: UploadRequest, - tenant_id: Uuid, - user_id: Uuid, -) -> AppResult { - let UploadRequest { - bytes, - original_name, - content_type, - folder_id, - metadata, - title_override, - tag_ids, - correspondents, - issued_at_override, - skip_if_existing, - } = request; - - if let Some(folder) = folder_id { - let mut conn = state.db_for_tenant(tenant_id)?; - ensure_folder_exists_on_conn(&mut conn, tenant_id, folder)?; - } - - let doc_id = Uuid::new_v4(); - let version_id = Uuid::new_v4(); - let version_number = 1; - let derived_title = title_override - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()) - .unwrap_or_else(|| derive_document_title(&original_name)); - let stored_filename = filename_with_retained_extension(&derived_title, &original_name); - - let checksum = Sha256::digest(&bytes); - let checksum_hex = hex::encode(checksum); - let size_bytes = bytes.len() as i64; - let s3_key = document_version_object_key(doc_id, version_number, version_id); - - { - let mut conn = state.db_for_tenant(tenant_id)?; - - let existing = documents::table - .inner_join( - document_versions::table - .on(document_versions::id.eq(documents::current_version_id)), - ) - .filter(documents::tenant_id.eq(tenant_id)) - .filter(document_versions::checksum.eq(&checksum_hex)) - .select((documents::all_columns, document_versions::all_columns)) - .first::<(Document, DocumentVersion)>(&mut conn) - .optional()?; - - if let Some((mut document, version)) = existing { - if skip_if_existing { - info!( - document_id = %document.id, - checksum = %checksum_hex, - "upload rejected because document already exists", - ); - return Err( - AppError::conflict("a document with the same contents already exists") - .with_code("duplicate_document") - .with_details(json!({ - "conflict_document_id": document.id, - })), - ); - } - - if let Some(issued_at) = issued_at_override { - if document.issued_at != Some(issued_at) { - diesel::update( - documents::table - .find(document.id) - .filter(documents::tenant_id.eq(tenant_id)), - ) - .set(( - documents::issued_at.eq(Some(issued_at)), - documents::updated_at.eq(Utc::now().naive_utc()), - )) - .execute(&mut conn)?; - document.issued_at = Some(issued_at); - } - } - - assign_tags_to_document(&mut conn, tenant_id, &document, &tag_ids, Some(user_id))?; - - if !correspondents.is_empty() { - let raw_ids: Vec = correspondents - .iter() - .map(|assignment| assignment.correspondent_id) - .collect(); - let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; - insert_document_correspondents( - &mut conn, - tenant_id, - document.id, - user_id, - &correspondent_ids, - )?; - } - - if document.deleted_at.is_some() { - let now = Utc::now().naive_utc(); - diesel::update(documents::table.find(document.id)) - .set(( - documents::deleted_at.eq(None::), - documents::updated_at.eq(now), - )) - .execute(&mut conn)?; - document.deleted_at = None; - document.updated_at = now; - } - - let tags_map = load_tags_for_documents(&mut conn, &[document.id])?; - let mut correspondents_map = - load_correspondents_for_documents(&mut conn, &[document.id])?; - let tags = tags_map.get(&document.id).cloned(); - let correspondents = correspondents_map.remove(&document.id).unwrap_or_default(); - drop(conn); - let assets = load_asset_responses(state, tenant_id, version.id).await?; - let version_response = to_version_response(version.clone()); - - info!( - document_id = %document.id, - checksum = %checksum_hex, - "upload deduplicated existing document" - ); - - return Ok(UploadOutcome::Reused(DocumentDetailResponse { - document: to_document_response( - state, - user_id, - document, - tags, - correspondents, - Some((version_response, assets)), - )?, - })); - } - } - - let content_disposition = inline_content_disposition(&stored_filename); - - let storage = state.storage_for_tenant(tenant_id)?; - - storage - .put_object( - &s3_key, - bytes.clone(), - content_type.clone(), - content_disposition.clone(), - ) - .await - .storage_context("failed to store document")?; - - let metadata_value = if metadata.is_null() { - Value::Object(Default::default()) - } else { - metadata - }; - - let (document, version) = { - let mut conn = state.db_for_tenant(tenant_id)?; - let transaction_result = conn.transaction(|conn| { - let new_document = NewDocument { - id: doc_id, - filename: stored_filename.clone(), - original_name: original_name.clone(), - content_type: content_type.clone(), - folder_id, - current_version_id: version_id, - issued_at: issued_at_override, - title: derived_title.clone(), - metadata: metadata_value.clone(), - tenant_id, - }; - diesel::insert_into(documents::table) - .values(&new_document) - .execute(conn)?; - - let new_version = NewDocumentVersion { - id: version_id, - document_id: doc_id, - version_number, - s3_key: s3_key.clone(), - size_bytes, - checksum: checksum_hex.clone(), - metadata: Value::Object(Default::default()), - tenant_id, - }; - - diesel::insert_into(document_versions::table) - .values(&new_version) - .execute(conn)?; - - let document: Document = documents::table.find(doc_id).first(conn)?; - let version: DocumentVersion = document_versions::table.find(version_id).first(conn)?; - - Ok::<_, diesel::result::Error>((document, version)) - }); - - match transaction_result { - Ok(result) => result, - Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { - return Err(AppError::conflict( - "another document in this folder already uses that filename", - ) - .with_code("duplicate_filename")) - } - Err(err) => return Err(AppError::from(err)), - } - }; - - let detail = { - let mut conn = state.db_for_tenant(tenant_id)?; - - assign_tags_to_document(&mut conn, tenant_id, &document, &tag_ids, Some(user_id))?; - - if !correspondents.is_empty() { - let raw_ids: Vec = correspondents - .iter() - .map(|assignment| assignment.correspondent_id) - .collect(); - let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; - insert_document_correspondents( - &mut conn, - tenant_id, - document.id, - user_id, - &correspondent_ids, - )?; - } - - let tags_map = load_tags_for_documents(&mut conn, &[doc_id])?; - let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[doc_id])?; - let tags = tags_map.get(&doc_id).cloned(); - let correspondents = correspondents_map.remove(&doc_id).unwrap_or_default(); - drop(conn); - - DocumentDetailResponse { - document: to_document_response( - state, - user_id, - document, - tags, - correspondents, - Some((to_version_response(version.clone()), Vec::new())), - )?, - } - }; - - if let Ok(mut conn) = state.db_for_tenant(tenant_id) { - if let Err(err) = enqueue_job( - &mut conn, - tenant_id, - JOB_ANALYZE_DOCUMENT, - json!({ - "document_id": doc_id, - "document_version_id": version.id, - "force": false, - }), - None, - ) { - warn!(document_id = %doc_id, error = %err, "failed to enqueue analyze job"); - } - } else { - warn!(document_id = %doc_id, "failed to enqueue analyze job due to pool error"); - } - - Ok(UploadOutcome::Created(detail)) -} - -pub(crate) fn to_document_response( - state: &AppState, - user_id: Uuid, - doc: Document, - tags: Option>, - correspondents: Vec, - current_version: Option<(DocumentVersionResponse, Vec)>, -) -> AppResult { - let current_version = if let Some((version, assets)) = current_version { - let download_path = build_download_path(state, &doc, user_id)?; - Some(DocumentVersionDetailResponse { - version, - assets, - download_path, - }) - } else { - None - }; - - Ok(DocumentResponse { - id: doc.id, - filename: doc.filename, - title: doc.title, - original_name: doc.original_name, - content_type: doc.content_type, - folder_id: doc.folder_id, - created_at: to_iso(doc.created_at), - updated_at: to_iso(doc.updated_at), - deleted_at: doc.deleted_at.map(to_iso), - issued_at: doc.issued_at.map(to_iso), - metadata: doc.metadata, - tags: tags - .unwrap_or_default() - .into_iter() - .map(TagResponse::from) - .collect(), - correspondents, - current_version, - }) -} - -pub(crate) fn hydrate_documents( - state: &AppState, - conn: &mut PgConnection, - tenant_id: Uuid, - user_id: Uuid, - docs: Vec, -) -> AppResult> { - if docs.is_empty() { - return Ok(Vec::new()); - } - - let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); - let tags_map = load_tags_for_documents(conn, &doc_ids)?; - let mut correspondents_map = load_correspondents_for_documents(conn, &doc_ids)?; - - let primary_versions = load_primary_assets(state, tenant_id, &docs)?; - let mut responses = Vec::with_capacity(doc_ids.len()); - for doc in docs { - let tags = tags_map.get(&doc.id).cloned(); - let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default(); - let current_version = primary_versions.get(&doc.id).cloned(); - responses.push(to_document_response( - state, - user_id, - doc, - tags, - correspondents, - current_version, - )?); - } - - Ok(responses) -} - #[derive(utoipa::OpenApi)] #[openapi( paths( @@ -2536,15 +909,15 @@ pub(crate) fn hydrate_documents( crate::routes::documents::get_document_version, ), components(schemas( - crate::routes::documents::DocumentListQuery, - crate::routes::documents::DocumentStatusFilter, + crate::services::documents::DocumentListQuery, + crate::services::documents::DocumentStatusFilter, crate::routes::documents::AssetRequestQuery, crate::routes::documents::DocumentCheckQuery, crate::routes::documents::DocumentCheckResponse, - crate::routes::documents::DocumentResponse, - crate::routes::documents::DocumentDetailResponse, + crate::services::documents::DocumentResponse, + crate::services::documents::DocumentDetailResponse, crate::routes::documents::DocumentMetadataUpdate, - crate::routes::documents::TagResponse, + crate::services::documents::TagResponse, crate::routes::documents::CorrespondentAssignmentInput, crate::routes::documents::AssignCorrespondentsRequest, crate::routes::documents::BulkCorrespondentAction, diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index 2015237..2a374d2 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -2,52 +2,28 @@ use axum::{ extract::{Json, Path, Query, State}, http::StatusCode, }; -use diesel::{ - dsl::{exists, sql}, - prelude::*, - sql_types::Text, - PgConnection, -}; -use serde::{Deserialize, Serialize}; -use utoipa::{IntoParams, ToSchema}; +use utoipa::OpenApi; use uuid::Uuid; -use crate::models::{Document, Folder, NewFolder}; -use crate::schema::{documents, folders}; -use crate::state::AppState; use crate::{ auth::TenantScopedConn, error::{AppError, AppResult}, - http::responders::{ - created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt, + http::responders::{created_json, no_content, ok_json, JsonResponse}, + services::folders::{ + CreateFolderRequest, EnsureFolderPathRequest, FolderContentsData, FolderContentsQuery, + FolderInfo, FolderService, FolderTreeNode, UpdateFolderRequest, }, + state::AppState, }; -use super::documents::{hydrate_documents, DocumentResponse}; -use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection}; -use crate::utils::{json::deserialize_patch_field, time::to_iso}; -use std::collections::HashMap; +use crate::services::documents::DocumentResponse; -#[derive(Deserialize, ToSchema)] -pub struct CreateFolderRequest { - pub name: String, - #[schema(nullable)] - pub parent_id: Option, -} - -#[derive(Deserialize, ToSchema)] -pub struct EnsureFolderPathRequest { - #[schema(nullable)] - pub parent_id: Option, - pub segments: Vec, -} - -#[derive(Serialize, ToSchema)] +#[derive(utoipa::ToSchema, serde::Serialize)] pub struct FolderResponse { pub folder: FolderInfo, } -#[derive(Serialize, ToSchema)] +#[derive(utoipa::ToSchema, serde::Serialize)] pub struct FolderContentsResponse { #[schema(nullable)] pub folder: Option, @@ -55,81 +31,6 @@ pub struct FolderContentsResponse { pub documents: Vec, } -#[derive(Clone, Serialize, ToSchema)] -pub struct FolderTreeNode { - pub id: Uuid, - pub name: String, - #[schema(nullable)] - pub parent_id: Option, - pub created_at: String, - pub updated_at: String, - #[serde(default)] - pub children: Vec, -} - -#[derive(Deserialize, IntoParams, ToSchema)] -#[into_params(parameter_in = Query)] -pub struct FolderContentsQuery { - #[serde(default = "default_include_documents")] - #[schema(default = true)] - pub include_documents: bool, - #[serde(default)] - #[schema(default = "title")] - pub sort: DocumentSortField, - #[serde(default)] - #[schema(default = "asc")] - pub dir: SortDirection, -} - -const fn default_include_documents() -> bool { - true -} - -#[derive(Serialize, ToSchema)] -pub struct FolderInfo { - pub id: Uuid, - pub name: String, - #[schema(nullable)] - pub parent_id: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Default, Deserialize, ToSchema)] -pub struct UpdateFolderRequest { - #[serde(default, deserialize_with = "deserialize_patch_field")] - #[schema(nullable, value_type = Option)] - pub parent_id: Option>, - #[serde(default, deserialize_with = "deserialize_patch_field")] - #[schema(nullable)] - pub name: Option>, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn update_folder_request_deserializes_null_parent() { - let req: UpdateFolderRequest = - serde_json::from_value(json!({ "parent_id": null })).unwrap(); - assert!(matches!(req.parent_id, Some(None))); - } - - #[test] - fn update_folder_request_deserializes_absent_parent() { - let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap(); - assert!(req.parent_id.is_none()); - } - - #[test] - fn update_folder_request_deserializes_null_name() { - let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap(); - assert!(matches!(req.name, Some(None))); - } -} - #[utoipa::path( get, path = "/api/folders/{id}", @@ -138,6 +39,7 @@ mod tests { tag = "Folders" )] pub async fn get_folder( + State(state): State, Path(folder_id): Path, TenantScopedConn { mut conn, @@ -145,14 +47,9 @@ pub async fn get_folder( .. }: TenantScopedConn, ) -> AppResult> { - let folder: Folder = folders::table - .find(folder_id) - .filter(folders::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - - ok_json(FolderResponse { - folder: folder_to_info(folder), - }) + let service = FolderService::new(&state); + let folder = service.get_folder(&mut conn, tenant_id, folder_id)?; + ok_json(FolderResponse { folder }) } #[utoipa::path( @@ -163,6 +60,7 @@ pub async fn get_folder( tag = "Folders" )] pub async fn ensure_folder_path( + State(state): State, TenantScopedConn { mut conn, tenant_id, @@ -170,82 +68,9 @@ pub async fn ensure_folder_path( }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { - if payload.segments.is_empty() { - return Err(AppError::bad_request("segments must not be empty")); - } - - let target_folder = conn.transaction::(|conn| { - let mut current_parent = payload.parent_id; - let mut last_folder: Option = None; - - for raw_name in &payload.segments { - let name = raw_name.trim(); - if name.is_empty() { - return Err(AppError::bad_request("folder names must not be empty")); - } - - let existing: Option = if let Some(parent_id) = current_parent { - folders::table - .filter(folders::tenant_id.eq(tenant_id)) - .filter(folders::parent_id.eq(Some(parent_id))) - .filter(folders::name.eq(name)) - .first(conn) - .optional()? - } else { - folders::table - .filter(folders::tenant_id.eq(tenant_id)) - .filter(folders::parent_id.is_null()) - .filter(folders::name.eq(name)) - .first(conn) - .optional()? - }; - - let folder = if let Some(folder) = existing { - folder - } else { - let new_folder = NewFolder { - id: Uuid::new_v4(), - name: name.to_string(), - parent_id: current_parent, - tenant_id, - }; - - let inserted_id: Option = diesel::insert_into(folders::table) - .values(&new_folder) - .on_conflict_do_nothing() - .returning(folders::id) - .get_result(conn) - .optional()?; - - if let Some(id) = inserted_id { - folders::table - .find(id) - .filter(folders::tenant_id.eq(tenant_id)) - .first(conn)? - } else if let Some(parent_id) = current_parent { - folders::table - .filter(folders::tenant_id.eq(tenant_id)) - .filter(folders::parent_id.eq(Some(parent_id))) - .filter(folders::name.eq(name)) - .first(conn)? - } else { - folders::table - .filter(folders::tenant_id.eq(tenant_id)) - .filter(folders::parent_id.is_null()) - .filter(folders::name.eq(name)) - .first(conn)? - } - }; - current_parent = Some(folder.id); - last_folder = Some(folder); - } - - last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path")) - })?; - - ok_json(FolderResponse { - folder: folder_to_info(target_folder), - }) + let service = FolderService::new(&state); + let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?; + ok_json(FolderResponse { folder }) } #[utoipa::path( @@ -259,6 +84,7 @@ pub async fn ensure_folder_path( tag = "Folders" )] pub async fn create_folder( + State(state): State, TenantScopedConn { mut conn, tenant_id, @@ -266,78 +92,9 @@ pub async fn create_folder( }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { - if payload.name.trim().is_empty() { - return Err(AppError::bad_request("name must not be empty")); - } - - let name = payload.name.trim(); - - let existing: Option = if let Some(parent_id) = payload.parent_id { - folders::table - .filter(folders::tenant_id.eq(tenant_id)) - .filter(folders::parent_id.eq(Some(parent_id))) - .filter(folders::name.eq(name)) - .first(&mut conn) - .optional()? - } else { - folders::table - .filter(folders::tenant_id.eq(tenant_id)) - .filter(folders::parent_id.is_null()) - .filter(folders::name.eq(name)) - .first(&mut conn) - .optional()? - }; - - let (folder, created): (Folder, bool) = if let Some(folder) = existing { - (folder, false) - } else { - let new_folder = NewFolder { - id: Uuid::new_v4(), - name: name.to_string(), - parent_id: payload.parent_id, - tenant_id, - }; - - let inserted_id: Option = diesel::insert_into(folders::table) - .values(&new_folder) - .on_conflict_do_nothing() - .returning(folders::id) - .get_result(&mut conn) - .optional()?; - - if let Some(id) = inserted_id { - ( - folders::table - .find(id) - .filter(folders::tenant_id.eq(tenant_id)) - .first(&mut conn)?, - true, - ) - } else if let Some(parent_id) = payload.parent_id { - ( - folders::table - .filter(folders::tenant_id.eq(tenant_id)) - .filter(folders::parent_id.eq(Some(parent_id))) - .filter(folders::name.eq(name)) - .first(&mut conn)?, - false, - ) - } else { - ( - folders::table - .filter(folders::tenant_id.eq(tenant_id)) - .filter(folders::parent_id.is_null()) - .filter(folders::name.eq(name)) - .first(&mut conn)?, - false, - ) - } - }; - - let response = FolderResponse { - folder: folder_to_info(folder), - }; - + let service = FolderService::new(&state); + let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?; + let response = FolderResponse { folder }; if created { created_json(response) } else { @@ -348,7 +105,7 @@ pub async fn create_folder( #[utoipa::path( get, path = "/api/folders/{id}/contents", - params(("id" = Uuid, Path, description = "Folder ID"), FolderContentsQuery), + params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery), responses((status = 200, description = "Folder contents", body = FolderContentsResponse)), tag = "Folders" )] @@ -378,54 +135,22 @@ pub async fn list_folder_contents( ) }; - let folder = match folder_id { - Some(id) => Some(folder_to_info( - folders::table - .find(id) - .filter(folders::tenant_id.eq(tenant_id)) - .first::(&mut conn)?, - )), - None => None, - }; - - let child_folders: Vec = if let Some(parent_id) = folder_id { - folders::table - .filter(folders::parent_id.eq(parent_id)) - .filter(folders::tenant_id.eq(tenant_id)) - .order(sql::("name COLLATE \"unicode_ci\" ASC")) - .load(&mut conn)? - } else { - folders::table - .filter(folders::parent_id.is_null()) - .filter(folders::tenant_id.eq(tenant_id)) - .order(sql::("name COLLATE \"unicode_ci\" ASC")) - .load(&mut conn)? - }; - let subfolders = child_folders.into_iter().map(folder_to_info).collect(); + let service = FolderService::new(&state); + let FolderContentsData { + folder, + subfolders, + documents, + } = service.list_folder_contents( + &mut conn, + tenant_id, + folder_id, + sort, + dir, + include_documents, + )?; let documents = if include_documents { - let mut docs_query = documents::table - .filter(documents::deleted_at.is_null()) - .filter(documents::tenant_id.eq(tenant_id)) - .into_boxed(); - - let (primary_sql, secondary_sql) = ordering_clauses(sort, dir); - docs_query = docs_query.order(sql::(primary_sql)); - if let Some(second) = secondary_sql { - docs_query = docs_query.then_order_by(sql::(second)); - } - - let docs: Vec = if let Some(current_folder) = folder_id { - docs_query - .filter(documents::folder_id.eq(current_folder)) - .load(&mut conn)? - } else { - docs_query - .filter(documents::folder_id.is_null()) - .load(&mut conn)? - }; - - hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)? + service.hydrate_documents(&mut conn, tenant_id, user_id, documents)? } else { Vec::new() }; @@ -444,64 +169,15 @@ pub async fn list_folder_contents( tag = "Folders" )] pub async fn list_folder_tree( + State(state): State, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult>> { - let folders: Vec = folders::table - .filter(folders::tenant_id.eq(tenant_id)) - .order(sql::("name COLLATE \"unicode_ci\" ASC")) - .load(&mut conn)?; - - let mut node_map: HashMap = HashMap::with_capacity(folders.len()); - let mut children_map: HashMap> = HashMap::new(); - let mut roots: Vec = Vec::new(); - - for folder in folders { - let id = folder.id; - let parent_id = folder.parent_id; - let node = FolderTreeNode { - id, - name: folder.name, - parent_id, - created_at: to_iso(folder.created_at), - updated_at: to_iso(folder.updated_at), - children: Vec::new(), - }; - - if let Some(parent) = parent_id { - children_map.entry(parent).or_default().push(id); - } else { - roots.push(id); - } - - node_map.insert(id, node); - } - - fn build_node( - id: Uuid, - nodes: &HashMap, - child_map: &HashMap>, - ) -> FolderTreeNode { - let mut node = nodes.get(&id).cloned().expect("folder node must exist"); - - if let Some(children) = child_map.get(&id) { - node.children = children - .iter() - .map(|child_id| build_node(*child_id, nodes, child_map)) - .collect(); - } - - node - } - - let tree = roots - .iter() - .map(|root_id| build_node(*root_id, &node_map, &children_map)) - .collect(); - + let service = FolderService::new(&state); + let tree = service.list_folder_tree(&mut conn, tenant_id)?; ok_json(tree) } @@ -513,6 +189,7 @@ pub async fn list_folder_tree( tag = "Folders" )] pub async fn delete_folder( + State(state): State, Path(folder_id): Path, TenantScopedConn { mut conn, @@ -520,51 +197,7 @@ pub async fn delete_folder( .. }: TenantScopedConn, ) -> AppResult { - conn.transaction::<_, AppError, _>(|conn| { - folders::table - .find(folder_id) - .filter(folders::tenant_id.eq(tenant_id)) - .first::(conn)?; - - let has_child_folders: bool = diesel::select(exists( - folders::table - .filter(folders::parent_id.eq(Some(folder_id))) - .filter(folders::tenant_id.eq(tenant_id)), - )) - .get_result(conn)?; - - if has_child_folders { - return Err(AppError::bad_request( - "folder must be empty before deletion", - )); - } - - let has_documents: bool = diesel::select(exists( - documents::table - .filter(documents::folder_id.eq(Some(folder_id))) - .filter(documents::tenant_id.eq(tenant_id)) - .filter(documents::deleted_at.is_null()), - )) - .get_result(conn)?; - - if has_documents { - return Err(AppError::bad_request( - "folder must be empty before deletion", - )); - } - - diesel::delete( - folders::table - .filter(folders::id.eq(folder_id)) - .filter(folders::tenant_id.eq(tenant_id)), - ) - .execute(conn) - .into_app_result()? - .or_not_found()?; - - Ok(()) - })?; - + FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?; no_content() } @@ -577,6 +210,7 @@ pub async fn delete_folder( tag = "Folders" )] pub async fn update_folder( + State(state): State, Path(folder_id): Path, TenantScopedConn { mut conn, @@ -585,145 +219,11 @@ pub async fn update_folder( }: TenantScopedConn, Json(payload): Json, ) -> AppResult { - conn.transaction::<(), AppError, _>(|conn| { - let folder: Folder = folders::table - .find(folder_id) - .filter(folders::tenant_id.eq(tenant_id)) - .first(conn)?; - - let mut next_parent = folder.parent_id; - let mut parent_changed = false; - match payload.parent_id { - None => {} - Some(None) => { - if folder.parent_id.is_some() { - parent_changed = true; - } - next_parent = None; - } - Some(Some(parent_id)) => { - if parent_id == folder_id { - return Err(AppError::bad_request("folder cannot be its own parent")); - } - - let _parent: Folder = folders::table - .find(parent_id) - .filter(folders::tenant_id.eq(tenant_id)) - .first(conn)?; - - if folder.parent_id != Some(parent_id) { - let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?; - if descendant_ids.contains(&parent_id) { - return Err(AppError::bad_request( - "cannot move folder into itself or a descendant", - )); - } - parent_changed = true; - } - - next_parent = Some(parent_id); - } - } - - let mut new_name = folder.name.clone(); - let mut name_changed = false; - match payload.name { - None => {} - Some(None) => { - return Err(AppError::bad_request("name cannot be null")); - } - Some(Some(value)) => { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(AppError::bad_request("name must not be empty")); - } - - if trimmed != folder.name { - new_name = trimmed.to_string(); - name_changed = true; - } - } - } - - if !parent_changed && !name_changed { - return Ok(()); - } - - let conflict = if let Some(parent_id) = next_parent { - folders::table - .filter(folders::parent_id.eq(Some(parent_id))) - .filter(folders::name.eq(&new_name)) - .filter(folders::id.ne(folder_id)) - .filter(folders::tenant_id.eq(tenant_id)) - .first::(conn) - .optional()? - } else { - folders::table - .filter(folders::parent_id.is_null()) - .filter(folders::name.eq(&new_name)) - .filter(folders::id.ne(folder_id)) - .filter(folders::tenant_id.eq(tenant_id)) - .first::(conn) - .optional()? - }; - - if conflict.is_some() { - return Err(AppError::bad_request( - "a folder with the same name already exists in the target", - )); - } - - diesel::update( - folders::table - .find(folder_id) - .filter(folders::tenant_id.eq(tenant_id)), - ) - .set(( - folders::parent_id.eq(next_parent), - folders::name.eq(&new_name), - )) - .execute(conn) - .into_app_result()? - .or_not_found()?; - - Ok(()) - })?; - + FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?; no_content() } -fn folder_to_info(folder: Folder) -> FolderInfo { - FolderInfo { - id: folder.id, - name: folder.name, - parent_id: folder.parent_id, - created_at: to_iso(folder.created_at), - updated_at: to_iso(folder.updated_at), - } -} - -pub(super) fn gather_descendant_folder_ids( - conn: &mut PgConnection, - tenant_id: Uuid, - folder_id: Uuid, -) -> AppResult> { - let mut ids = vec![folder_id]; - let mut queue = vec![folder_id]; - - while let Some(current) = queue.pop() { - let child_ids: Vec = folders::table - .filter(folders::parent_id.eq(Some(current))) - .filter(folders::tenant_id.eq(tenant_id)) - .select(folders::id) - .load(conn)?; - queue.extend(child_ids.iter().copied()); - ids.extend(child_ids); - } - - Ok(ids) -} - -#[derive(utoipa::OpenApi)] +#[derive(OpenApi)] #[openapi( paths( crate::routes::folders::create_folder, @@ -735,14 +235,14 @@ pub(super) fn gather_descendant_folder_ids( crate::routes::folders::update_folder ), components(schemas( - crate::routes::folders::CreateFolderRequest, - crate::routes::folders::EnsureFolderPathRequest, + crate::services::folders::CreateFolderRequest, + crate::services::folders::EnsureFolderPathRequest, crate::routes::folders::FolderResponse, - crate::routes::folders::FolderInfo, - crate::routes::folders::FolderContentsQuery, + crate::services::folders::FolderInfo, + crate::services::folders::FolderContentsQuery, crate::routes::folders::FolderContentsResponse, - crate::routes::folders::FolderTreeNode, - crate::routes::folders::UpdateFolderRequest + crate::services::folders::FolderTreeNode, + crate::services::folders::UpdateFolderRequest )) )] pub struct FoldersApiDoc; diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs index 5921fb7..f023079 100644 --- a/backend/src/routes/profile.rs +++ b/backend/src/routes/profile.rs @@ -3,63 +3,19 @@ use axum::{ http::StatusCode, Json, }; -use chrono::{DateTime, NaiveDateTime}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::OpenApi; use uuid::Uuid; -use crate::auth::{ - api_tokens::{ - create_api_token as issue_token, list_api_tokens as load_tokens, - regenerate_api_token as rotate_token, revoke_api_token as revoke_token, +use crate::{ + auth::{passkeys::PasskeySummary, TenantScopedConn}, + error::AppResult, + http::responders::JsonResponse, + services::profile::{ + ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, ProfileService, + RevokePasskeyQuery, }, - capability_sets::load_capability_set, - passkeys::PasskeySummary, - TenantScopedConn, + state::AppState, }; -use crate::error::{AppError, AppResult}; -use crate::http::responders::{created_json, no_content, ok_json, JsonResponse}; -use crate::models::ApiToken; -use crate::state::{AppState, PgPooledConnection}; -use crate::utils::time::to_iso; - -#[derive(Debug, Serialize, ToSchema)] -pub struct ApiTokenResponse { - pub id: Uuid, - pub tenant_id: Uuid, - #[schema(nullable)] - pub label: Option, - pub capability_set_id: Uuid, - pub created_at: String, - #[schema(nullable)] - pub last_used_at: Option, - #[schema(nullable)] - pub expires_at: Option, - #[schema(nullable)] - pub revoked_at: Option, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ApiTokenCreatedResponse { - pub token: String, - pub token_info: ApiTokenResponse, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreateApiTokenRequest { - #[schema(nullable)] - pub label: Option, - #[schema(nullable)] - pub expires_at: Option, - pub capability_set_id: Uuid, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct RevokePasskeyQuery { - #[serde(default)] - #[schema(nullable)] - pub reason: Option, -} #[utoipa::path( get, @@ -73,13 +29,7 @@ pub async fn list_passkeys( mut conn, user_id, .. }: TenantScopedConn, ) -> AppResult>> { - let service = state - .passkeys - .as_ref() - .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; - - let passkeys = service.list_for_user(&mut conn, user_id)?; - ok_json(passkeys) + ProfileService::new(&state).list_passkeys(&mut conn, user_id) } #[utoipa::path( @@ -89,6 +39,7 @@ pub async fn list_passkeys( tag = "Profile" )] pub async fn list_api_tokens( + State(state): State, TenantScopedConn { mut conn, tenant_id, @@ -96,9 +47,7 @@ pub async fn list_api_tokens( .. }: TenantScopedConn, ) -> AppResult>> { - let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?; - let responses = tokens.into_iter().map(api_token_to_response).collect(); - ok_json(responses) + ProfileService::new(&state).list_api_tokens(&mut conn, tenant_id, user_id) } #[utoipa::path( @@ -109,6 +58,7 @@ pub async fn list_api_tokens( tag = "Profile" )] pub async fn create_api_token( + State(state): State, TenantScopedConn { mut conn, tenant_id, @@ -117,31 +67,7 @@ pub async fn create_api_token( }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { - let expires_at = match payload.expires_at { - Some(ref value) => Some(parse_timestamp(value)?), - None => None, - }; - - let capability_set_id = - validate_capability_set(&mut conn, tenant_id, payload.capability_set_id)?; - - let issued = issue_token( - &mut conn, - user_id, - tenant_id, - payload.label.clone(), - expires_at, - capability_set_id, - )?; - - let token_info = api_token_to_response(issued.record); - - let response = ApiTokenCreatedResponse { - token: issued.token, - token_info, - }; - - created_json(response) + ProfileService::new(&state).create_api_token(&mut conn, tenant_id, user_id, payload) } #[utoipa::path( @@ -152,6 +78,7 @@ pub async fn create_api_token( tag = "Profile" )] pub async fn regenerate_api_token( + State(state): State, TenantScopedConn { mut conn, tenant_id, @@ -160,14 +87,7 @@ pub async fn regenerate_api_token( }: TenantScopedConn, Path(token_id): Path, ) -> AppResult> { - let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?; - let token_info = api_token_to_response(issued.record); - let response = ApiTokenCreatedResponse { - token: issued.token, - token_info, - }; - - ok_json(response) + ProfileService::new(&state).regenerate_api_token(&mut conn, tenant_id, user_id, token_id) } #[utoipa::path( @@ -178,13 +98,13 @@ pub async fn regenerate_api_token( tag = "Profile" )] pub async fn delete_api_token( + State(state): State, TenantScopedConn { mut conn, user_id, .. }: TenantScopedConn, Path(token_id): Path, ) -> AppResult { - revoke_token(&mut conn, token_id, user_id)?; - no_content() + ProfileService::new(&state).delete_api_token(&mut conn, user_id, token_id) } #[utoipa::path( @@ -205,68 +125,10 @@ pub async fn delete_passkey( Path(passkey_id): Path, Query(query): Query, ) -> AppResult { - let service = state - .passkeys - .as_ref() - .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; - - let active_count = service.active_passkey_count(&mut conn, user_id)?; - if active_count <= 1 { - return Err(AppError::bad_request( - "cannot revoke the last remaining passkey", - )); - } - - service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?; - no_content() + ProfileService::new(&state).delete_passkey(&mut conn, user_id, passkey_id, query.reason) } -fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { - let ApiToken { - id, - tenant_id, - label, - created_at, - last_used_at, - expires_at, - revoked_at, - capability_set_id, - .. - } = token; - - ApiTokenResponse { - id, - tenant_id, - label, - capability_set_id, - created_at: to_iso(created_at), - last_used_at: last_used_at.map(to_iso), - expires_at: expires_at.map(to_iso), - revoked_at: revoked_at.map(to_iso), - } -} - -fn parse_timestamp(value: &str) -> AppResult { - let dt = DateTime::parse_from_rfc3339(value) - .map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?; - Ok(dt.naive_utc()) -} - -fn validate_capability_set( - conn: &mut PgPooledConnection, - tenant_id: Uuid, - capability_set_id: Uuid, -) -> AppResult { - let set = load_capability_set(conn, capability_set_id)?; - if set.tenant_id != tenant_id { - return Err(AppError::bad_request( - "capability set does not belong to the tenant", - )); - } - Ok(set.id) -} - -#[derive(utoipa::OpenApi)] +#[derive(OpenApi)] #[openapi( paths( crate::routes::profile::list_api_tokens, @@ -278,10 +140,10 @@ fn validate_capability_set( ), components(schemas( crate::models::ApiCapability, - crate::routes::profile::ApiTokenResponse, - crate::routes::profile::ApiTokenCreatedResponse, - crate::routes::profile::CreateApiTokenRequest, - crate::routes::profile::RevokePasskeyQuery, + crate::services::profile::ApiTokenResponse, + crate::services::profile::ApiTokenCreatedResponse, + crate::services::profile::CreateApiTokenRequest, + crate::services::profile::RevokePasskeyQuery, crate::auth::passkeys::PasskeySummary )) )] diff --git a/backend/src/services/auth.rs b/backend/src/services/auth.rs new file mode 100644 index 0000000..496d68e --- /dev/null +++ b/backend/src/services/auth.rs @@ -0,0 +1,840 @@ +use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use chrono::{Duration as ChronoDuration, TimeZone, Utc}; +use diesel::{pg::PgConnection, prelude::*, OptionalExtension}; +use rand::{rngs::OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use utoipa::ToSchema; +use uuid::Uuid; +use webauthn_rs::prelude::RegisterPublicKeyCredential; + +use crate::auth::{ + api_tokens::{find_active_token_by_secret, touch_api_token}, + capability_sets::load_capability_set, + jwt::{AccessTokenContext, PrincipalKind}, + passkeys::{ + AuthenticationChallengeResponse, PasskeyLoginFinishPayload, + PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, + }, + AuthenticatedUser, +}; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{ok_json, JsonResponse}; +use crate::models::{ + MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership, + UserSession, +}; +use crate::schema::{ + magic_tokens::dsl as magic_dsl, + tenants::dsl as tenant_dsl, + user_memberships::dsl as memberships_dsl, + user_passkeys::dsl as passkey_dsl, + user_sessions::{self, dsl as session_dsl}, + users::dsl, +}; +use crate::services::ServiceContext; +use crate::state::AppState; +use crate::tenants::{ + apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc, + clear_user_session_hash, +}; +use crate::utils::text::normalize_identifier; + +pub const SESSION_COOKIE_NAME: &str = "refresh_token"; + +#[derive(Deserialize, ToSchema)] +pub struct LoginRequest { + #[serde(default)] + pub username: String, + #[serde(default)] + #[schema(nullable)] + pub password: Option, + #[serde(default)] + #[schema(nullable)] + pub magic_token: Option, + #[serde(default)] + #[schema(nullable)] + pub preferred_tenant_id: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ApiTokenExchangeRequest { + pub api_token: String, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct LoginResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: i64, + pub tenant: TenantSnippet, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct TenantSnippet { + pub id: Uuid, + pub name: String, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct TenantSelectionResponse { + pub access_token: String, + pub tenants: Vec, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct TenantListResponse { + pub tenants: Vec, +} + +#[derive(Deserialize, ToSchema)] +pub struct TenantSelectionRequest { + pub tenant_id: Uuid, +} + +#[derive(Deserialize, ToSchema)] +pub struct SignupStartRequest { + pub username: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SignupStartResponse { + pub signup_token: String, + pub challenge: RegistrationChallengeResponse, +} + +#[derive(Deserialize, ToSchema)] +pub struct SignupFinishRequest { + pub signup_token: String, + #[schema(value_type = Object)] + pub credential: RegisterPublicKeyCredential, + #[schema(nullable)] + pub nickname: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(untagged)] +pub enum LoginResponseVariants { + Token(LoginResponse), + Selection(TenantSelectionResponse), +} + +pub struct AuthService<'a> { + state: &'a AppState, + ctx: ServiceContext<'a>, +} + +impl<'a> AuthService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { + state, + ctx: ServiceContext::new(state), + } + } + + pub fn login(&self, payload: LoginRequest) -> AppResult { + let magic_token = payload + .magic_token + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + + if magic_token.is_none() { + if payload.password.is_some() { + return Err(AppError::bad_request( + "password authentication is no longer supported", + )); + } + + return Err(AppError::bad_request( + "magic_token is required for passwordless login", + )); + } + + let token_value = magic_token.unwrap(); + + let mut conn = self.ctx.db_conn()?; + let username_hint = payload.username.trim(); + let preferred_tenant_id = payload.preferred_tenant_id; + + self.magic_token_login( + &mut conn, + token_value, + (!username_hint.is_empty()).then_some(username_hint), + preferred_tenant_id, + ) + } + + pub fn exchange_api_token( + &self, + payload: ApiTokenExchangeRequest, + ) -> AppResult> { + let secret = payload.api_token.trim(); + if secret.is_empty() { + return Err(AppError::bad_request("api_token must not be empty")); + } + + let mut conn = self.ctx.db_conn()?; + + let token = find_active_token_by_secret(&mut conn, None, secret, None)? + .ok_or_else(AppError::unauthorized)?; + + let user: User = dsl::users.find(token.user_id).first(&mut conn)?; + + apply_user_guc(&mut conn, user.id)?; + let membership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(token.tenant_id)) + .first::(&mut conn) + .optional()?; + clear_user_guc(&mut conn)?; + + let membership = membership.ok_or_else(AppError::unauthorized)?; + + let membership_capability_set = membership.capability_set_id.ok_or_else(|| { + AppError::new( + StatusCode::FORBIDDEN, + "membership has no capability set assigned", + ) + })?; + + let token_capability_set = load_capability_set(&mut conn, token.capability_set_id)?; + let _membership_set = load_capability_set(&mut conn, membership_capability_set)?; + + apply_tenant_guc(&mut conn, token.tenant_id)?; + touch_api_token(&mut conn, token.id)?; + + let access_token = self + .state + .jwt + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id: token.tenant_id, + username: user.username.clone(), + principal_kind: PrincipalKind::ApiToken, + principal_id: token.id, + capability_set_id: token_capability_set.id, + cap_version: token_capability_set.cap_version, + }) + .map_err(AppError::from)?; + + let tenant_name: String = tenant_dsl::tenants + .find(token.tenant_id) + .select(tenant_dsl::name) + .first(&mut conn) + .map_err(AppError::from)?; + + ok_json(LoginResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: self.state.config.jwt_expiry_minutes * 60, + tenant: TenantSnippet { + id: token.tenant_id, + name: tenant_name, + }, + }) + } + + pub fn signup_start( + &self, + payload: SignupStartRequest, + ) -> AppResult> { + let username = normalize_username(&payload.username)?; + + let mut conn = self.ctx.db_conn()?; + let exists: bool = dsl::users + .filter(dsl::username.eq(&username)) + .first::(&mut conn) + .optional()? + .is_some(); + if exists { + return Err(AppError::conflict("username already exists")); + } + + let user_id = Uuid::new_v4(); + let challenge = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))? + .start_signup_registration(&mut conn, user_id, username.as_str())?; + + let signup_token = self + .state + .jwt + .generate_signup_token(user_id, challenge.challenge_id, username.clone()) + .map_err(AppError::from)?; + + ok_json(SignupStartResponse { + signup_token, + challenge, + }) + } + + pub fn signup_finish(&self, payload: SignupFinishRequest) -> AppResult { + let claims = self + .state + .jwt + .verify_signup_token(&payload.signup_token) + .map_err(|_| AppError::unauthorized())?; + + let mut conn = self.ctx.db_conn()?; + + let exists: bool = dsl::users + .filter(dsl::username.eq(&claims.username)) + .first::(&mut conn) + .optional()? + .is_some(); + if exists { + return Err(AppError::conflict("username already exists")); + } + + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let prepared_passkey = service.consume_signup_challenge( + &mut conn, + claims.challenge_id, + &payload.credential, + )?; + + let state_clone = self.state.clone(); + let response = self.ctx.tx(&mut conn, |conn| { + insert_user(conn, claims.sub, &claims.username)?; + + let tenant = state_clone.tenants.create_tenant_with_conn( + conn, + &claims.username, + None, + None, + TenantStatus::Creating, + &[claims.sub], + Some(claims.sub), + )?; + + let passkey_insert = + prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone()); + + diesel::insert_into(passkey_dsl::user_passkeys) + .values(&passkey_insert) + .execute(conn) + .map_err(AppError::from)?; + + let user: User = dsl::users.find(claims.sub).first(conn)?; + self.issue_session(conn, &user, tenant.id) + })?; + + Ok(response) + } + + pub fn refresh(&self, refresh_value: &str) -> AppResult { + let hashed = hash_session_token(refresh_value); + let mut conn = self.ctx.db_conn()?; + let now = Utc::now(); + let now_naive = now.naive_utc(); + + apply_user_session_hash(&mut conn, &hashed)?; + let token = match session_dsl::user_sessions + .filter(session_dsl::token_hash.eq(&hashed)) + .filter(session_dsl::revoked_at.is_null()) + .filter(session_dsl::expires_at.gt(now_naive)) + .first::(&mut conn) + { + Ok(token) => token, + Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()), + Err(err) => return Err(AppError::from(err)), + }; + + clear_user_session_hash(&mut conn)?; + apply_tenant_guc(&mut conn, token.tenant_id)?; + clear_user_guc(&mut conn)?; + + diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id))) + .set(( + session_dsl::revoked_at.eq(now_naive), + session_dsl::updated_at.eq(now_naive), + )) + .execute(&mut conn)?; + + let user: User = dsl::users + .find(token.user_id) + .first(&mut conn) + .map_err(AppError::from)?; + + self.issue_session(&mut conn, &user, token.tenant_id) + } + + pub fn select_tenant(&self, token: &str, tenant_id: Uuid) -> AppResult { + let user_id = match self.state.jwt.verify_tenant_selector_token(token) { + Ok(claims) => claims.sub, + Err(_) => self + .state + .jwt + .verify_token(token) + .map(|claims| claims.sub) + .map_err(|_| AppError::unauthorized())?, + }; + + let mut conn = self.ctx.db_conn()?; + apply_user_guc(&mut conn, user_id)?; + + let membership_exists = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .select(memberships_dsl::tenant_id) + .first::(&mut conn) + .optional()?; + + clear_user_guc(&mut conn)?; + + if membership_exists.is_none() { + return Err(AppError::unauthorized()); + } + + let user: User = dsl::users + .find(user_id) + .first(&mut conn) + .map_err(AppError::from)?; + + self.issue_session(&mut conn, &user, tenant_id) + } + + pub fn logout( + &self, + user: AuthenticatedUser, + refresh_cookie: Option<&str>, + ) -> AppResult<(HeaderMap, StatusCode)> { + let mut conn = self.state.db_for_tenant(user.tenant_id)?; + let now = Utc::now().naive_utc(); + + let revoked = if let Some(value) = refresh_cookie { + let hashed = hash_session_token(value); + diesel::update( + session_dsl::user_sessions + .filter(session_dsl::token_hash.eq(hashed)) + .filter(session_dsl::user_id.eq(user.user_id)) + .filter(session_dsl::revoked_at.is_null()), + ) + .set(( + session_dsl::revoked_at.eq(now), + session_dsl::updated_at.eq(now), + )) + .execute(&mut conn)? + } else { + 0 + }; + + if revoked == 0 { + diesel::update( + session_dsl::user_sessions + .filter(session_dsl::user_id.eq(user.user_id)) + .filter(session_dsl::tenant_id.eq(user.tenant_id)) + .filter(session_dsl::revoked_at.is_null()), + ) + .set(( + session_dsl::revoked_at.eq(now), + session_dsl::updated_at.eq(now), + )) + .execute(&mut conn)?; + } + + let mut headers = HeaderMap::new(); + headers.insert(SET_COOKIE, build_clear_session_cookie(self.state)); + Ok((headers, StatusCode::NO_CONTENT)) + } + + pub fn list_tenants( + &self, + user: AuthenticatedUser, + ) -> AppResult> { + let mut conn = self.ctx.db_conn()?; + apply_user_guc(&mut conn, user.user_id)?; + + let tenant_ids: Vec = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.user_id)) + .select(memberships_dsl::tenant_id) + .load(&mut conn)?; + + clear_user_guc(&mut conn)?; + + let mut tenants = Vec::with_capacity(tenant_ids.len()); + for tenant_id in tenant_ids { + let mut tenant_conn = self.state.db_for_tenant(tenant_id)?; + let name: String = tenant_dsl::tenants + .find(tenant_id) + .select(tenant_dsl::name) + .first(&mut tenant_conn) + .map_err(AppError::from)?; + tenants.push(TenantSnippet { + id: tenant_id, + name, + }); + } + + ok_json(TenantListResponse { tenants }) + } + + pub fn passkey_register_start( + &self, + user: AuthenticatedUser, + ) -> AppResult> { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.ctx.db_conn()?; + let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; + let challenge = service.start_registration(&mut conn, ¤t_user)?; + ok_json(challenge) + } + + pub fn passkey_register_finish( + &self, + user: AuthenticatedUser, + payload: PasskeyRegistrationFinishPayload, + ) -> AppResult> { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.ctx.db_conn()?; + let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; + + let PasskeyRegistrationFinishPayload { + challenge_id, + credential, + nickname, + } = payload; + + let passkey = service.finish_registration( + &mut conn, + ¤t_user, + challenge_id, + credential, + nickname, + )?; + + ok_json(PasskeySummary::from(passkey)) + } + + pub fn passkey_login_start( + &self, + username: &str, + ) -> AppResult> { + let username = normalize_username(username)?; + + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.ctx.db_conn()?; + let user: User = dsl::users + .filter(dsl::username.eq(&username)) + .first(&mut conn)?; + + let challenge = service.start_authentication(&mut conn, &user)?; + ok_json(challenge) + } + + pub fn passkey_login_finish(&self, payload: PasskeyLoginFinishPayload) -> AppResult { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.ctx.db_conn()?; + let (user, _passkey, auth_result) = + service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?; + + if !auth_result.user_verified() { + return Err(AppError::unauthorized()); + } + + self.complete_login(&mut conn, &user, None) + } + + fn complete_login( + &self, + conn: &mut PgConnection, + user: &User, + preferred_tenant_id: Option, + ) -> AppResult { + apply_user_guc(conn, user.id)?; + let tenant_ids: Vec = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .select(memberships_dsl::tenant_id) + .load(conn)?; + clear_user_guc(conn)?; + + if tenant_ids.is_empty() { + return Err(AppError::unauthorized()); + } + + if let Some(preferred_id) = preferred_tenant_id { + if tenant_ids.iter().any(|id| *id == preferred_id) { + return self.issue_session(conn, user, preferred_id); + } + } + + if tenant_ids.len() == 1 { + return self.issue_session(conn, user, tenant_ids[0]); + } + + let selection_token = self + .state + .jwt + .generate_tenant_selector_token(user.id) + .map_err(AppError::from)?; + + let mut tenants = Vec::with_capacity(tenant_ids.len()); + for tenant_id in tenant_ids { + let mut tenant_conn = self.state.db_for_tenant(tenant_id)?; + let name: String = tenant_dsl::tenants + .find(tenant_id) + .select(tenant_dsl::name) + .first(&mut tenant_conn) + .map_err(AppError::from)?; + tenants.push(TenantSnippet { + id: tenant_id, + name, + }); + } + + let response = ok_json(LoginResponseVariants::Selection(TenantSelectionResponse { + access_token: selection_token, + tenants, + }))?; + + Ok(response.into_response()) + } + + fn magic_token_login( + &self, + conn: &mut PgConnection, + token_value: &str, + username_hint: Option<&str>, + preferred_tenant_id: Option, + ) -> AppResult { + if token_value.is_empty() { + return Err(AppError::bad_request("magic_token must not be empty")); + } + + let token_hash = hash_magic_token(token_value); + let now = Utc::now(); + let now_naive = now.naive_utc(); + + self.ctx.tx(conn, |conn| { + let magic = magic_dsl::magic_tokens + .filter(magic_dsl::token_hash.eq(&token_hash)) + .filter(magic_dsl::expires_at.gt(now_naive)) + .first::(conn) + .map_err(|err| match err { + diesel::result::Error::NotFound => AppError::unauthorized(), + _ => AppError::from(err), + })?; + + if let Some(limit) = magic.max_uses { + if magic.used_count >= limit { + return Err(AppError::unauthorized()); + } + } + + match magic.kind { + MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {} + } + + let user: User = dsl::users + .find(magic.user_id) + .first(conn) + .map_err(AppError::from)?; + + if let Some(expected) = username_hint { + if expected != user.username { + return Err(AppError::unauthorized()); + } + } + + diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id))) + .set(( + magic_dsl::used_count.eq(magic.used_count + 1), + magic_dsl::last_used_at.eq(Some(now_naive)), + )) + .execute(conn)?; + + self.complete_login(conn, &user, preferred_tenant_id) + }) + } + + fn issue_session( + &self, + conn: &mut PgConnection, + user: &User, + tenant_id: Uuid, + ) -> AppResult { + apply_tenant_guc(conn, tenant_id)?; + clear_user_guc(conn)?; + clear_user_session_hash(conn)?; + + let membership: UserMembership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .first(conn)?; + + let capability_set_id = membership.capability_set_id.ok_or_else(|| { + AppError::new( + StatusCode::FORBIDDEN, + "membership has no capability set assigned", + ) + })?; + + let capability_set = load_capability_set(conn, capability_set_id)?; + + let now = Utc::now(); + let session_id = Uuid::new_v4(); + let access_token = self + .state + .jwt + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id, + username: user.username.clone(), + principal_kind: PrincipalKind::UserSession, + principal_id: session_id, + capability_set_id, + cap_version: capability_set.cap_version, + }) + .map_err(AppError::from)?; + + let tenant_name: String = tenant_dsl::tenants + .find(tenant_id) + .select(tenant_dsl::name) + .first(conn) + .map_err(AppError::from)?; + + let session_value = generate_session_token(); + let session_hash = hash_session_token(&session_value); + let refresh_expires_at = + now + ChronoDuration::days(self.state.config.refresh_token_expiry_days); + + let new_session = NewUserSession { + id: session_id, + user_id: user.id, + token_hash: session_hash, + issued_at: now.naive_utc(), + expires_at: refresh_expires_at.naive_utc(), + tenant_id, + }; + + diesel::insert_into(user_sessions::table) + .values(&new_session) + .execute(conn)?; + + let json = ok_json(LoginResponseVariants::Token(LoginResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: self.state.config.jwt_expiry_minutes * 60, + tenant: TenantSnippet { + id: tenant_id, + name: tenant_name, + }, + }))?; + + let mut response = json.into_response(); + + response.headers_mut().insert( + SET_COOKIE, + build_session_cookie(self.state, &session_value, refresh_expires_at), + ); + + Ok(response) + } +} + +fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> { + let new_user = NewUser { + id, + username: username.to_string(), + }; + + diesel::insert_into(dsl::users) + .values(&new_user) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +fn hash_session_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn hash_magic_token(token: &str) -> String { + hash_session_token(token) +} + +fn generate_session_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +fn build_cookie( + state: &AppState, + token: Option<&str>, + expires_at: Option>, + max_age: i64, +) -> HeaderValue { + let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token.unwrap_or(""))]; + parts.push("Path=/".into()); + parts.push("HttpOnly".into()); + parts.push("SameSite=Strict".into()); + parts.push(format!("Max-Age={}", max_age)); + if let Some(expires) = expires_at { + parts.push(format!("Expires={}", expires.to_rfc2822())); + } + if state.config.refresh_cookie_secure { + parts.push("Secure".into()); + } + if let Some(domain) = &state.config.refresh_cookie_domain { + parts.push(format!("Domain={}", domain)); + } + + HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie") +} + +fn build_session_cookie( + state: &AppState, + token: &str, + expires_at: chrono::DateTime, +) -> HeaderValue { + let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds(); + build_cookie(state, Some(token), Some(expires_at), max_age) +} + +fn build_clear_session_cookie(state: &AppState) -> HeaderValue { + let epoch = Utc.timestamp_opt(0, 0).single().unwrap(); + build_cookie(state, None, Some(epoch), 0) +} + +fn normalize_username(value: &str) -> AppResult { + normalize_identifier( + value, + 100, + "username must not be empty", + "username must not exceed 100 characters", + Some("username may only contain printable characters"), + |ch| !ch.is_control(), + ) +} diff --git a/backend/src/services/capability_sets.rs b/backend/src/services/capability_sets.rs new file mode 100644 index 0000000..33b34d4 --- /dev/null +++ b/backend/src/services/capability_sets.rs @@ -0,0 +1,294 @@ +use axum::http::StatusCode; +use chrono::Utc; +use diesel::{pg::PgConnection, prelude::*, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + auth::capability_sets::{ + compute_slug, create_capability_set as create_capability_set_record, is_system_slug, + load_capabilities_for_set, normalize_capabilities, refresh_capability_set, + }, + error::{AppError, AppResult}, + http::responders::{ + created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt, + }, + models::{ApiCapability, CapabilitySet}, + schema::{ + api_tokens, + capability_sets::{self, dsl as cs_dsl}, + user_memberships, + }, + services::TransactionExt, + utils::text::normalize_identifier, +}; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct CapabilitySetResponse { + pub id: Uuid, + pub slug: String, + pub is_system: bool, + pub cap_version: i32, + pub capabilities: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateCapabilitySetRequest { + #[serde(default)] + #[serde(rename = "slug")] + pub slug: Option, + pub capabilities: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateCapabilitySetRequest { + #[serde(default)] + #[serde(rename = "slug")] + pub slug: Option, + #[serde(default)] + pub capabilities: Option>, +} + +pub struct CapabilitySetService; + +impl CapabilitySetService { + pub fn new() -> Self { + Self + } + + pub fn list( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + ) -> AppResult>> { + let sets = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .order(cs_dsl::slug.asc()) + .load::(conn)?; + + let mut responses = Vec::with_capacity(sets.len()); + for set in sets { + let capabilities = load_capabilities_for_set(conn, set.id)?; + responses.push(to_response(set, capabilities)); + } + + ok_json(responses) + } + + pub fn list_capabilities(&self) -> AppResult>> { + let capabilities = ApiCapability::variants() + .iter() + .map(|value| value.parse::().expect("valid capability")) + .collect(); + ok_json(capabilities) + } + + pub fn get( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + id: Uuid, + ) -> AppResult> { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn) + .into_app_result()?; + + let capabilities = load_capabilities_for_set(conn, set.id)?; + ok_json(to_response(set, capabilities)) + } + + pub fn create( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + payload: CreateCapabilitySetRequest, + ) -> AppResult> { + let original_caps = payload.capabilities; + let normalized_caps = normalize_capabilities(original_caps.clone())?; + if normalized_caps.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let slug = if let Some(raw) = payload.slug { + let normalized = normalize_slug(&raw)?; + if is_system_slug(&normalized) { + return Err(AppError::conflict("slug is reserved")); + } + normalized + } else { + let generated = compute_slug(&normalized_caps); + if is_system_slug(&generated) { + return Err(AppError::conflict( + "capabilities match a reserved system capability set", + )); + } + generated + }; + + let set = create_capability_set_record(conn, tenant_id, &slug, original_caps)?; + let response = to_response(set, normalized_caps); + + created_json(response) + } + + pub fn update( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + id: Uuid, + payload: UpdateCapabilitySetRequest, + ) -> AppResult> { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn) + .into_app_result()?; + + if set.is_system { + if payload.slug.is_some() || payload.capabilities.is_some() { + return Err(AppError::conflict( + "system capability sets cannot be modified", + )); + } + let capabilities = load_capabilities_for_set(conn, set.id)?; + return ok_json(to_response(set, capabilities)); + } + + let set = conn.app_transaction(|conn| { + let mut working = set.clone(); + + if let Some(slug) = &payload.slug { + let normalized = normalize_slug(slug)?; + if is_system_slug(&normalized) { + return Err(AppError::conflict("slug is reserved")); + } + + if cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::slug.eq(&normalized)) + .filter(cs_dsl::id.ne(working.id)) + .first::(conn) + .optional() + .into_app_result()? + .is_some() + { + return Err(AppError::conflict("slug already exists")); + } + + diesel::update(cs_dsl::capability_sets.find(working.id)) + .set(( + cs_dsl::slug.eq(&normalized), + cs_dsl::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .into_app_result()?; + + working.slug = normalized; + } + + if let Some(capabilities) = &payload.capabilities { + let normalized = normalize_capabilities(capabilities.clone())?; + if normalized.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let updated = refresh_capability_set(conn, &working, &normalized)?; + working = updated; + } + + capability_sets::table + .find(working.id) + .first::(conn) + .into_app_result() + })?; + + let capabilities = load_capabilities_for_set(conn, set.id)?; + ok_json(to_response(set, capabilities)) + } + + pub fn delete( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + id: Uuid, + ) -> AppResult { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn) + .into_app_result()?; + + if set.is_system { + return Err(AppError::conflict( + "system capability sets cannot be deleted", + )); + } + + let in_use_memberships: i64 = user_memberships::table + .filter(user_memberships::capability_set_id.eq(Some(set.id))) + .count() + .get_result(conn)?; + + if in_use_memberships > 0 { + return Err(AppError::conflict( + "capability set is assigned to user memberships", + )); + } + + let in_use_tokens: i64 = api_tokens::table + .filter(api_tokens::capability_set_id.eq(set.id)) + .count() + .get_result(conn)?; + + if in_use_tokens > 0 { + return Err(AppError::conflict( + "capability set is assigned to API tokens", + )); + } + + diesel::delete(cs_dsl::capability_sets.find(set.id)) + .execute(conn) + .into_app_result()? + .or_not_found()?; + + no_content() + } +} + +fn normalize_slug(value: &str) -> AppResult { + let base = normalize_identifier( + value, + 64, + "slug must not be empty", + "slug must not exceed 64 characters", + Some("slug may only contain alphanumeric characters, hyphen, underscore, or whitespace"), + |ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch.is_whitespace(), + )?; + + let mut normalized = String::with_capacity(base.len()); + for ch in base.chars() { + if ch.is_whitespace() { + normalized.push('-'); + } else { + normalized.push(ch.to_ascii_lowercase()); + } + } + + if normalized.is_empty() { + return Err(AppError::bad_request("slug must not be empty")); + } + + Ok(normalized) +} + +fn to_response(set: CapabilitySet, capabilities: Vec) -> CapabilitySetResponse { + CapabilitySetResponse { + id: set.id, + slug: set.slug, + is_system: set.is_system, + cap_version: set.cap_version, + capabilities, + } +} diff --git a/backend/src/services/correspondents.rs b/backend/src/services/correspondents.rs new file mode 100644 index 0000000..8df0efb --- /dev/null +++ b/backend/src/services/correspondents.rs @@ -0,0 +1,262 @@ +use chrono::Utc; +use diesel::{dsl::not, prelude::*}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::documents::correspondents::{ + insert_document_correspondents, normalize_correspondent_ids, +}; +use crate::error::{AppError, AppResult}; +use crate::schema::{document_correspondents, documents}; +use crate::services::helpers::load_active_document; +use crate::services::TransactionExt; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::db::validate_bulk_ids; + +#[derive(Serialize, ToSchema)] +pub struct BulkCorrespondentResponse { + pub assigned: usize, + pub removed: usize, +} + +#[derive(Deserialize, ToSchema)] +pub struct CorrespondentAssignmentInput { + pub correspondent_id: Uuid, +} + +#[derive(Deserialize, ToSchema)] +pub struct AssignCorrespondentsRequest { + pub assignments: Vec, + #[serde(default)] + #[schema(default = false)] + pub replace: bool, +} + +#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum BulkCorrespondentAction { + Add, + Remove, +} + +fn default_bulk_correspondent_action() -> BulkCorrespondentAction { + BulkCorrespondentAction::Add +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkCorrespondentsRequest { + pub document_ids: Vec, + pub assignments: Vec, + #[serde(default = "default_bulk_correspondent_action")] + pub action: BulkCorrespondentAction, +} + +pub struct CorrespondentsService<'a> { + _state: &'a AppState, +} + +impl<'a> CorrespondentsService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { _state: state } + } + + pub fn assign_to_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + request: &AssignCorrespondentsRequest, + ) -> AppResult<()> { + if request.assignments.is_empty() { + return Err(AppError::bad_request("assignments must not be empty")); + } + + let raw_ids: Vec = request + .assignments + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + + conn.app_transaction(|conn| { + let document = load_active_document(conn, tenant_id, document_id)?; + + let mut updated = false; + if request.replace { + let base = document_correspondents::table + .filter(document_correspondents::document_id.eq(document_id)) + .filter(document_correspondents::tenant_id.eq(tenant_id)); + + let removed = if correspondent_ids.is_empty() { + diesel::delete(base).execute(conn)? + } else { + diesel::delete(base.filter(not( + document_correspondents::correspondent_id.eq_any(&correspondent_ids), + ))) + .execute(conn)? + }; + + if removed > 0 { + updated = true; + } + } + + let inserted = insert_document_correspondents( + conn, + tenant_id, + document.id, + user_id, + &correspondent_ids, + )?; + + if inserted > 0 { + updated = true; + } + + if updated && inserted == 0 { + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok(()) + }) + } + + pub fn bulk_update( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + mut payload: BulkCorrespondentsRequest, + ) -> AppResult { + if payload.assignments.is_empty() { + return Err(AppError::bad_request("assignments must not be empty")); + } + + validate_bulk_ids(&mut payload.document_ids, "document_ids")?; + + let raw_ids: Vec = payload + .assignments + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + + let action = payload.action; + + conn.app_transaction(|conn| { + let docs: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&payload.document_ids)) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::deleted_at)) + .load(conn)?; + + if docs.len() != payload.document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if docs.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request( + "cannot assign correspondents to deleted documents", + )); + } + + match action { + BulkCorrespondentAction::Add => { + let mut assigned_total = 0; + for (doc_id, _) in &docs { + assigned_total += insert_document_correspondents( + conn, + tenant_id, + *doc_id, + user_id, + &correspondent_ids, + )?; + } + + Ok(BulkCorrespondentResponse { + assigned: assigned_total, + removed: 0, + }) + } + BulkCorrespondentAction::Remove => { + if correspondent_ids.is_empty() { + return Ok(BulkCorrespondentResponse { + assigned: 0, + removed: 0, + }); + } + + let removed = diesel::delete( + document_correspondents::table + .filter( + document_correspondents::document_id.eq_any(&payload.document_ids), + ) + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .filter( + document_correspondents::correspondent_id + .eq_any(&correspondent_ids), + ), + ) + .execute(conn)?; + + if removed > 0 { + diesel::update( + documents::table + .filter(documents::id.eq_any(&payload.document_ids)) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok(BulkCorrespondentResponse { + assigned: 0, + removed, + }) + } + } + }) + } + + pub fn remove_from_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + correspondent_id: Uuid, + ) -> AppResult<()> { + load_active_document(conn, tenant_id, document_id)?; + + let deleted = diesel::delete( + document_correspondents::table + .filter(document_correspondents::document_id.eq(document_id)) + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .filter(document_correspondents::correspondent_id.eq(correspondent_id)), + ) + .execute(conn)?; + + if deleted == 0 { + return Err(AppError::not_found()); + } + + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + + Ok(()) + } +} diff --git a/backend/src/services/documents.rs b/backend/src/services/documents.rs new file mode 100644 index 0000000..7bf9bd5 --- /dev/null +++ b/backend/src/services/documents.rs @@ -0,0 +1,1461 @@ +use std::{ + collections::{HashMap, HashSet}, + time::Duration, +}; + +use chrono::{DateTime, NaiveDateTime, Utc}; +use diesel::{ + dsl::{exists, not, sql}, + prelude::*, + result::DatabaseErrorKind, + sql_types::Text, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use tracing::{debug, error, info, warn}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::documents::{ + asset::{ + build_download_path, derive_document_title, filename_with_retained_extension, + load_asset_responses, load_primary_assets, to_asset_detail_response, + to_asset_object_response, to_version_response, DocumentAssetDetailResponse, + DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse, + }, + correspondents::{ + insert_document_correspondents, normalize_correspondent_ids, DocumentCorrespondentResponse, + }, + folders::ensure_folder_exists_on_conn, + metadata::merge_document_metadata, + ordering::{ordering_clauses, DocumentSortField, SortDirection}, + relations::load_tags_and_correspondents, + search::quickwit_search, + tags::assign_tags as assign_tags_to_document, +}; +use crate::error::{AppError, AppResult}; +use crate::jobs::{ + enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT, JOB_PURGE_DOCUMENT, +}; +use crate::models::{ + Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentVersion, + Tag, +}; +use crate::schema::{ + document_asset_objects, document_assets, document_correspondents, document_tags, + document_versions, documents, folders, +}; +use crate::services::{ + correspondents::CorrespondentAssignmentInput, folders::gather_descendant_folder_ids, + helpers::load_active_document, TransactionExt, +}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::{ + db::validate_bulk_ids, + error::StorageResultExt, + http::inline_content_disposition, + json::{classify_nullable, NullableValue}, + setops::{intersect_option_sets, load_linked_doc_ids}, + storage_paths::document_version_object_key, + time::to_iso, +}; + +const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; + +#[derive(Deserialize, IntoParams, ToSchema, Clone)] +#[into_params(parameter_in = Query)] +pub struct DocumentListQuery { + #[schema(nullable)] + pub folder_id: Option, + #[serde(default)] + #[schema(nullable)] + pub include_descendants: Option, + pub query: Option, + pub tags: Option, + pub correspondents: Option, + #[serde(default = "default_document_status_filter")] + #[schema(default = "active")] + pub status: DocumentStatusFilter, + #[serde(default)] + #[schema(default = "title")] + pub sort: DocumentSortField, + #[serde(default)] + #[schema(default = "asc")] + pub dir: SortDirection, +} + +fn default_document_status_filter() -> DocumentStatusFilter { + DocumentStatusFilter::Active +} + +#[derive(Clone, Copy, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum DocumentStatusFilter { + Active, + Deleted, + All, +} + +#[derive(Serialize, ToSchema)] +pub struct TagResponse { + pub id: Uuid, + pub label: String, + #[schema(nullable)] + pub color: Option, +} + +impl From for TagResponse { + fn from(tag: Tag) -> Self { + Self { + id: tag.id, + label: tag.label, + color: tag.color, + } + } +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentCheckResponse { + pub exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub document_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub version_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub version_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub created_at: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentResponse { + pub id: Uuid, + pub filename: String, + pub title: String, + pub original_name: String, + #[schema(nullable)] + pub content_type: Option, + #[schema(nullable)] + pub folder_id: Option, + pub created_at: String, + pub updated_at: String, + #[schema(nullable)] + pub deleted_at: Option, + #[schema(nullable)] + pub issued_at: Option, + #[schema(value_type = Object)] + pub metadata: Value, + pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub correspondents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub current_version: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentDetailResponse { + pub document: DocumentResponse, +} + +#[derive(Deserialize, ToSchema)] +pub struct DocumentMetadataUpdate { + #[schema(value_type = Object)] + pub value: Value, + #[serde(default)] + #[schema(default = false)] + pub replace: bool, +} + +#[derive(Default, AsChangeset)] +#[diesel(table_name = documents)] +struct DocumentUpdateChangeset { + title: Option, + filename: Option, + issued_at: Option>, + metadata: Option, + updated_at: Option, +} + +struct DocumentUpdatePlan { + changeset: DocumentUpdateChangeset, + title_changed: bool, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateDocumentRequest { + #[serde(default)] + pub title: Option, + #[serde(default)] + #[schema(nullable, value_type = Option)] + pub issued_at: Option, + #[serde(default)] + #[schema(nullable, value_type = Object)] + pub metadata: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkMoveRequest { + pub document_ids: Vec, + #[schema(nullable)] + pub folder_id: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkMoveResponse { + pub updated: usize, +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkReanalyzeSelectionRequest { + pub document_ids: Vec, + #[serde(default = "default_true")] + #[schema(default = true)] + pub force: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkReanalyzeResponse { + pub queued: usize, +} + +fn default_true() -> bool { + true +} + +pub struct DocumentUploadRequest { + pub bytes: Vec, + pub original_name: String, + pub content_type: Option, + pub folder_id: Option, + pub metadata: Value, + pub title_override: Option, + pub tag_ids: Vec, + pub correspondents: Vec, + pub issued_at_override: Option, + pub skip_if_existing: bool, +} + +pub enum DocumentUploadOutcome { + Created(DocumentDetailResponse), + Reused(DocumentDetailResponse), +} + +pub struct DocumentsService<'a> { + state: &'a AppState, +} + +impl<'a> DocumentsService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + fn build_document_update_plan( + document: &Document, + payload: &Map, + ) -> AppResult { + let title = match payload.get("title") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => return Err(AppError::bad_request("title must be a string")), + }; + + let issued_at_class = + classify_nullable(payload.get("issued_at")).map_err(AppError::bad_request)?; + + let metadata = match payload.get("metadata") { + None | Some(Value::Null) => None, + Some(value) => Some( + serde_json::from_value::(value.clone()).map_err(|err| { + AppError::bad_request(format!("invalid metadata payload: {err}")) + })?, + ), + }; + + let mut changes = DocumentUpdateChangeset::default(); + let mut has_changes = false; + let mut title_changed = false; + + if let Some(ref candidate) = title { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("title must not be empty")); + } + if trimmed != document.title { + let new_title = trimmed.to_string(); + let new_filename = filename_with_retained_extension(&new_title, &document.filename); + changes.title = Some(new_title); + if new_filename != document.filename { + changes.filename = Some(new_filename); + } + has_changes = true; + title_changed = true; + } + } + + match issued_at_class { + NullableValue::Omitted => {} + NullableValue::Null => { + if document.issued_at.is_some() { + changes.issued_at = Some(None); + has_changes = true; + } + } + NullableValue::String(raw) => { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("issued_at must not be empty")); + } + let parsed = DateTime::parse_from_rfc3339(trimmed).map_err(|err| { + let msg = format!("issued_at must be an RFC3339 timestamp: {err}"); + AppError::bad_request(msg) + })?; + let normalized = Some(parsed.naive_utc()); + if document.issued_at != normalized { + changes.issued_at = Some(normalized); + has_changes = true; + } + } + } + + if let Some(metadata_update) = metadata { + let next_metadata = if metadata_update.replace { + metadata_update.value + } else { + merge_document_metadata(document.metadata.clone(), metadata_update.value)? + }; + + if document.metadata != next_metadata { + changes.metadata = Some(next_metadata); + has_changes = true; + } + } + + if !has_changes { + return Err(AppError::bad_request("no changes provided")); + } + + Ok(DocumentUpdatePlan { + changeset: changes, + title_changed, + }) + } + + pub async fn get_document_detail( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + ) -> AppResult { + let doc = load_active_document(conn, tenant_id, document_id)?; + + let current_version: DocumentVersion = document_versions::table + .find(doc.current_version_id) + .first(conn)?; + + let tags_and_correspondents = load_tags_and_correspondents(conn, &[document_id])?; + let (tags, correspondents) = tags_and_correspondents + .get(&document_id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + let assets = load_asset_responses(self.state, tenant_id, current_version.id).await?; + let current_version_data = Some((to_version_response(current_version), assets)); + + let response = + self.to_document_response(user_id, doc, tags, correspondents, current_version_data)?; + + Ok(DocumentDetailResponse { document: response }) + } + + pub fn check_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + checksum_hex: &str, + ) -> AppResult { + let record: Option<(Document, DocumentVersion)> = documents::table + .inner_join( + document_versions::table + .on(document_versions::id.eq(documents::current_version_id)), + ) + .filter(documents::tenant_id.eq(tenant_id)) + .filter(document_versions::checksum.eq(checksum_hex)) + .select((documents::all_columns, document_versions::all_columns)) + .first::<(Document, DocumentVersion)>(conn) + .optional()?; + + if let Some((document, version)) = record { + Ok(DocumentCheckResponse { + exists: true, + document_id: Some(document.id), + title: Some(document.title), + filename: Some(document.filename), + version_id: Some(version.id), + version_number: Some(version.version_number), + created_at: Some(to_iso(document.created_at)), + }) + } else { + Ok(DocumentCheckResponse { + exists: false, + document_id: None, + title: None, + filename: None, + version_id: None, + version_number: None, + created_at: None, + }) + } + } + + pub async fn list_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + params: DocumentListQuery, + ) -> AppResult> { + let DocumentListQuery { + folder_id, + include_descendants, + query, + tags, + correspondents, + status, + sort, + dir, + } = params; + + let mut docs_query = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .into_boxed(); + + match status { + DocumentStatusFilter::Active => { + docs_query = docs_query.filter(documents::deleted_at.is_null()); + } + DocumentStatusFilter::Deleted => { + docs_query = docs_query.filter(documents::deleted_at.is_not_null()); + } + DocumentStatusFilter::All => {} + } + + let search_text = query + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let tags_param = tags + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let correspondents_param = correspondents + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let include_descendants = include_descendants.unwrap_or(true); + + match (folder_id, include_descendants) { + (Some(folder_id), true) => { + let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?; + docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids)); + } + (Some(folder_id), false) => { + docs_query = docs_query.filter(documents::folder_id.eq(Some(folder_id))); + } + (None, false) => { + docs_query = docs_query.filter(documents::folder_id.is_null()); + } + (None, true) => {} + } + + let mut filter_ids: Option> = None; + let mut quickwit_order: Option> = None; + + if let Some(query_str) = search_text.as_ref() { + debug!(query = %query_str, "performing quickwit document search"); + let endpoint = self + .state + .config + .quickwit_endpoint + .as_ref() + .ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?; + let tenant = self.state.tenants.get_by_id(tenant_id)?; + let index = tenant + .quickwit_index + .as_ref() + .ok_or_else(|| AppError::internal("quickwit index not configured for tenant"))?; + + let ids = quickwit_search(endpoint, index, tenant_id, query_str) + .await + .map_err(|err| { + error!(error = ?err, "quickwit search failed"); + AppError::internal("quickwit search failed") + })?; + + if ids.is_empty() { + return Ok(Vec::new()); + } + + quickwit_order = Some(ids.clone()); + let set: HashSet = ids.into_iter().collect(); + filter_ids = intersect_option_sets(filter_ids, set); + } + + if let Some(tags_param) = tags_param.as_ref() { + if tags_param.trim().eq_ignore_ascii_case("none") { + let docs_without_tags: Vec = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .filter(documents::deleted_at.is_null()) + .filter(not(exists( + document_tags::table.filter(document_tags::document_id.eq(documents::id)), + ))) + .select(documents::id) + .load(conn)?; + + let docs_set: HashSet = docs_without_tags.into_iter().collect(); + + if docs_set.is_empty() { + return Ok(Vec::new()); + } + + filter_ids = intersect_option_sets(filter_ids, docs_set); + } else { + let tag_ids: Result, _> = tags_param + .split(',') + .map(|s| Uuid::parse_str(s.trim())) + .collect(); + + if let Ok(ids) = tag_ids { + if !ids.is_empty() { + let matching_doc_ids = load_linked_doc_ids(conn, &ids, |conn, tag_id| { + let docs_for_tag: Vec = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .select(document_tags::document_id) + .load(conn) + .map_err(AppError::from)?; + Ok(docs_for_tag.into_iter().collect()) + })?; + + if matching_doc_ids.is_empty() { + return Ok(Vec::new()); + } + + filter_ids = intersect_option_sets(filter_ids, matching_doc_ids); + } + } + } + } + + if let Some(correspondents_param) = correspondents_param.as_ref() { + let correspondent_ids: Result, _> = correspondents_param + .split(',') + .map(|s| Uuid::parse_str(s.trim())) + .collect(); + + if let Ok(ids) = correspondent_ids { + if !ids.is_empty() { + let matching_doc_ids = + load_linked_doc_ids(conn, &ids, |conn, correspondent_id| { + let docs_for_correspondent: Vec = document_correspondents::table + .filter( + document_correspondents::correspondent_id.eq(correspondent_id), + ) + .select(document_correspondents::document_id) + .load(conn) + .map_err(AppError::from)?; + Ok(docs_for_correspondent.into_iter().collect()) + })?; + + if matching_doc_ids.is_empty() { + return Ok(Vec::new()); + } + + filter_ids = intersect_option_sets(filter_ids, matching_doc_ids); + } + } + } + + if let Some(filter_ids) = filter_ids { + docs_query = docs_query.filter(documents::id.eq_any(filter_ids)); + } + + let (primary_sql, secondary_sql) = ordering_clauses(sort, dir); + docs_query = docs_query.order(sql::(primary_sql)); + if let Some(second) = secondary_sql { + docs_query = docs_query.then_order_by(sql::(second)); + } + + let docs: Vec = docs_query.load(conn)?; + let mut responses = self.hydrate_documents(conn, tenant_id, user_id, docs)?; + + if let Some(order) = quickwit_order { + let order_map: HashMap = order + .into_iter() + .enumerate() + .map(|(idx, id)| (id, idx)) + .collect(); + responses.sort_by_key(|doc| order_map.get(&doc.id).copied().unwrap_or(usize::MAX)); + } + + Ok(responses) + } + + pub fn hydrate_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + docs: Vec, + ) -> AppResult> { + if docs.is_empty() { + return Ok(Vec::new()); + } + + let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); + let mut relations = load_tags_and_correspondents(conn, &doc_ids)?; + let primary_versions = load_primary_assets(self.state, tenant_id, &docs)?; + + docs.into_iter() + .map(|doc| { + let (tags, correspondents) = relations + .remove(&doc.id) + .unwrap_or_else(|| (Vec::new(), Vec::new())); + let current_version = primary_versions.get(&doc.id).cloned(); + self.to_document_response(user_id, doc, tags, correspondents, current_version) + }) + .collect() + } + + pub async fn upload_document( + &self, + tenant_id: Uuid, + user_id: Uuid, + request: DocumentUploadRequest, + ) -> AppResult { + let DocumentUploadRequest { + bytes, + original_name, + content_type, + folder_id, + metadata, + title_override, + tag_ids, + correspondents, + issued_at_override, + skip_if_existing, + } = request; + + if let Some(folder) = folder_id { + let mut conn = self.state.db_for_tenant(tenant_id)?; + ensure_folder_exists_on_conn(&mut conn, tenant_id, folder)?; + } + + let doc_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let version_number = 1; + let derived_title = title_override + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .unwrap_or_else(|| derive_document_title(&original_name)); + let stored_filename = filename_with_retained_extension(&derived_title, &original_name); + + let checksum = Sha256::digest(&bytes); + let checksum_hex = hex::encode(checksum); + let size_bytes = bytes.len() as i64; + let s3_key = document_version_object_key(doc_id, version_number, version_id); + + { + let mut conn = self.state.db_for_tenant(tenant_id)?; + + let existing = documents::table + .inner_join( + document_versions::table + .on(document_versions::id.eq(documents::current_version_id)), + ) + .filter(documents::tenant_id.eq(tenant_id)) + .filter(document_versions::checksum.eq(&checksum_hex)) + .select((documents::all_columns, document_versions::all_columns)) + .first::<(Document, DocumentVersion)>(&mut conn) + .optional()?; + + if let Some((mut document, version)) = existing { + if skip_if_existing { + info!( + document_id = %document.id, + checksum = %checksum_hex, + "upload rejected because document already exists", + ); + return Err(AppError::conflict( + "a document with the same contents already exists", + ) + .with_code("duplicate_document") + .with_details(json!({ + "conflict_document_id": document.id, + }))); + } + + if let Some(issued_at) = issued_at_override { + if document.issued_at != Some(issued_at) { + diesel::update( + documents::table + .find(document.id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::issued_at.eq(Some(issued_at)), + documents::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(&mut conn)?; + document.issued_at = Some(issued_at); + } + } + + assign_tags_to_document(&mut conn, tenant_id, &document, &tag_ids, Some(user_id))?; + + if !correspondents.is_empty() { + let raw_ids: Vec = correspondents + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + insert_document_correspondents( + &mut conn, + tenant_id, + document.id, + user_id, + &correspondent_ids, + )?; + } + + if document.deleted_at.is_some() { + let now = Utc::now().naive_utc(); + diesel::update(documents::table.find(document.id)) + .set(( + documents::deleted_at.eq(None::), + documents::updated_at.eq(now), + )) + .execute(&mut conn)?; + document.deleted_at = None; + document.updated_at = now; + } + + let tags_and_correspondents = + load_tags_and_correspondents(&mut conn, &[document.id])?; + let (tags, correspondents) = tags_and_correspondents + .get(&document.id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + let assets = load_asset_responses(self.state, tenant_id, version.id).await?; + let version_response = to_version_response(version.clone()); + + info!( + document_id = %document.id, + checksum = %checksum_hex, + "upload deduplicated existing document" + ); + + return Ok(DocumentUploadOutcome::Reused(DocumentDetailResponse { + document: self.to_document_response( + user_id, + document, + tags, + correspondents, + Some((version_response, assets)), + )?, + })); + } + } + + let content_disposition = inline_content_disposition(&stored_filename); + + let storage = self.state.storage_for_tenant(tenant_id)?; + + storage + .put_object( + &s3_key, + bytes.clone(), + content_type.clone(), + content_disposition.clone(), + ) + .await + .storage_context("failed to store document")?; + + let metadata_value = if metadata.is_null() { + Value::Object(Default::default()) + } else { + metadata + }; + + let (document, version) = { + let mut conn = self.state.db_for_tenant(tenant_id)?; + let transaction_result = conn.transaction(|conn| { + let new_document = NewDocument { + id: doc_id, + filename: stored_filename.clone(), + original_name: original_name.clone(), + content_type: content_type.clone(), + folder_id, + current_version_id: version_id, + metadata: metadata_value.clone(), + issued_at: issued_at_override, + title: derived_title.clone(), + tenant_id, + }; + diesel::insert_into(documents::table) + .values(&new_document) + .execute(conn)?; + + let new_version = NewDocumentVersion { + id: version_id, + document_id: doc_id, + version_number, + s3_key: s3_key.clone(), + size_bytes, + checksum: checksum_hex.clone(), + metadata: Value::Object(Default::default()), + tenant_id, + }; + + diesel::insert_into(document_versions::table) + .values(&new_version) + .execute(conn)?; + + let document: Document = documents::table.find(doc_id).first(conn)?; + let version: DocumentVersion = + document_versions::table.find(version_id).first(conn)?; + + Ok::<_, diesel::result::Error>((document, version)) + }); + + match transaction_result { + Ok(result) => result, + Err(diesel::result::Error::DatabaseError( + DatabaseErrorKind::UniqueViolation, + _, + )) => { + return Err(AppError::conflict( + "another document in this folder already uses that filename", + ) + .with_code("duplicate_filename")) + } + Err(err) => return Err(AppError::from(err)), + } + }; + + let detail = { + let mut conn = self.state.db_for_tenant(tenant_id)?; + + assign_tags_to_document(&mut conn, tenant_id, &document, &tag_ids, Some(user_id))?; + + if !correspondents.is_empty() { + let raw_ids: Vec = correspondents + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + insert_document_correspondents( + &mut conn, + tenant_id, + document.id, + user_id, + &correspondent_ids, + )?; + } + + let tags_and_correspondents = load_tags_and_correspondents(&mut conn, &[doc_id])?; + let (tags, correspondents) = tags_and_correspondents + .get(&doc_id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + DocumentDetailResponse { + document: self.to_document_response( + user_id, + document, + tags, + correspondents, + Some((to_version_response(version.clone()), Vec::new())), + )?, + } + }; + + if let Ok(mut conn) = self.state.db_for_tenant(tenant_id) { + if let Err(err) = enqueue_job( + &mut conn, + tenant_id, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": doc_id, + "document_version_id": version.id, + "force": false, + }), + None, + ) { + warn!(document_id = %doc_id, error = %err, "failed to enqueue analyze job"); + } + } else { + warn!(document_id = %doc_id, "failed to enqueue analyze job due to pool error"); + } + + Ok(DocumentUploadOutcome::Created(detail)) + } + + pub fn request_document_assets( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + force: bool, + ) -> AppResult<()> { + let document = load_active_document(conn, tenant_id, document_id)?; + + enqueue_job( + conn, + tenant_id, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": document_id, + "document_version_id": document.current_version_id, + "force": force, + }), + None, + ) + .map_err(|err| { + error!(error = ?err, "failed to enqueue analyze job"); + AppError::internal("failed to enqueue analyze job") + })?; + + Ok(()) + } + + pub fn reanalyze_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_ids: &mut Vec, + force: bool, + ) -> AppResult { + validate_bulk_ids(document_ids, "document_ids")?; + + let targets: Vec<(Uuid, Uuid)> = documents::table + .filter(documents::id.eq_any(&*document_ids)) + .filter(documents::deleted_at.is_null()) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::current_version_id)) + .load(conn)?; + + if targets.len() != document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + let mut queued = 0usize; + for (document_id, version_id) in targets { + enqueue_job( + conn, + tenant_id, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": document_id, + "document_version_id": version_id, + "force": force, + }), + None, + ) + .map_err(|err| { + error!(error = ?err, "failed to enqueue analyze job"); + AppError::internal("failed to enqueue analyze job") + })?; + queued += 1; + } + + Ok(queued) + } + + pub async fn list_document_assets( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + ) -> AppResult> { + let document = load_active_document(conn, tenant_id, document_id)?; + + let version_id = document.current_version_id; + load_asset_responses(self.state, tenant_id, version_id).await + } + + pub async fn get_document_asset( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + asset_id: Uuid, + start: i32, + limit: i32, + ) -> AppResult { + let asset: DocumentAsset = match document_assets::table + .find(asset_id) + .filter(document_assets::tenant_id.eq(tenant_id)) + .first(conn) + .optional()? + { + Some(asset) => asset, + None => return Err(AppError::not_found()), + }; + + if start < 1 { + return Err(AppError::bad_request("start must be at least 1")); + } + if limit < 1 { + return Err(AppError::bad_request("limit must be at least 1")); + } + + let end = start + .checked_add(limit - 1) + .ok_or_else(|| AppError::bad_request("requested range is too large"))?; + + let objects: Vec = document_asset_objects::table + .filter(document_asset_objects::asset_id.eq(asset_id)) + .filter(document_asset_objects::tenant_id.eq(tenant_id)) + .filter(document_asset_objects::ordinal.ge(start)) + .filter(document_asset_objects::ordinal.le(end)) + .order(document_asset_objects::ordinal.asc()) + .load(conn)?; + + let expires_at = Utc::now() + .timestamp_millis() + .checked_add((PRESIGNED_URL_EXPIRY_SECONDS as i64) * 1000) + .ok_or_else(|| AppError::internal("failed to compute expiry timestamp"))?; + + let storage = self.state.storage_for_tenant(tenant_id)?; + + let mut object_responses = Vec::with_capacity(objects.len()); + for object in objects { + let response_disposition = presign_disposition_for_asset(&asset, &object); + + let url = storage + .presign_get_object( + &object.s3_key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + response_disposition.as_deref(), + ) + .await + .storage_context("failed to generate asset URL")?; + + object_responses.push(to_asset_object_response( + object, + Some(url), + Some(expires_at), + )); + } + + if object_responses.is_empty() { + return Err(AppError::not_found()); + } + + Ok(to_asset_detail_response(asset, object_responses)) + } + + pub fn list_document_versions( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + ) -> AppResult> { + load_active_document(conn, tenant_id, document_id)?; + + let versions: Vec = document_versions::table + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .order(document_versions::version_number.asc()) + .load(conn)?; + + Ok(versions.into_iter().map(to_version_response).collect()) + } + + pub async fn get_document_version( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + version_id: Uuid, + ) -> AppResult { + let document = load_active_document(conn, tenant_id, document_id)?; + + let version: DocumentVersion = document_versions::table + .find(version_id) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .first(conn)?; + + let assets = load_asset_responses(self.state, tenant_id, version.id).await?; + let download_path = build_download_path(self.state, &document, user_id)?; + let version_core = to_version_response(version); + + Ok(DocumentVersionDetailResponse { + version: version_core, + assets, + download_path, + }) + } + + pub fn trash_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + ) -> AppResult<()> { + let now = Utc::now().naive_utc(); + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::deleted_at.eq(Some(now)), + documents::updated_at.eq(now), + )) + .execute(conn)?; + + Ok(()) + } + + pub fn delete_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + ) -> AppResult<()> { + conn.app_transaction(|conn| { + let document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .for_update() + .first::(conn) + .optional()? + .ok_or_else(AppError::not_found)?; + + if document.deleted_at.is_none() { + return Err(AppError::conflict( + "document must be trashed before permanent deletion", + )); + } + + let payload = json!({ "document_id": document_id }); + match enqueue_job(conn, tenant_id, JOB_PURGE_DOCUMENT, payload, None) { + Ok(_) => Ok(()), + Err(JobQueueError::Database(diesel::result::Error::DatabaseError( + DatabaseErrorKind::UniqueViolation, + _, + ))) => Ok(()), + Err(JobQueueError::Database(err)) => Err(AppError::from(err)), + } + }) + } + + pub async fn update_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + payload: Value, + ) -> AppResult { + let mut document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + if document.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + let payload_obj = payload + .as_object() + .ok_or_else(|| AppError::bad_request("request body must be a JSON object"))?; + let DocumentUpdatePlan { + mut changeset, + title_changed, + } = Self::build_document_update_plan(&document, payload_obj)?; + + let now = Utc::now().naive_utc(); + changeset.updated_at = Some(now); + + let target = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)); + + let update_result = diesel::update(target).set(&changeset); + + match update_result.execute(conn) { + Ok(_) => {} + Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { + return Err(AppError::conflict( + "another document in this folder already uses that filename", + ) + .with_code("duplicate_filename")); + } + Err(err) => return Err(AppError::from(err)), + } + + document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + let current_version: DocumentVersion = document_versions::table + .find(document.current_version_id) + .first(conn)?; + + if title_changed { + if let Err(err) = enqueue_job( + conn, + tenant_id, + JOB_INDEX_DOCUMENT_TEXT, + json!({ + "document_id": document.id, + "document_version_id": current_version.id, + }), + None, + ) { + warn!( + document_id = %document.id, + version_id = %current_version.id, + error = %err, + "failed to enqueue reindex job after title change" + ); + } + } + + let tags_and_correspondents = load_tags_and_correspondents(conn, &[document_id])?; + let version_id = current_version.id; + let assets = load_asset_responses(self.state, tenant_id, version_id).await?; + let version_response = to_version_response(current_version); + let (tags, correspondents) = tags_and_correspondents + .get(&document_id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + let document_response = self.to_document_response( + user_id, + document, + tags, + correspondents, + Some((version_response, assets)), + )?; + + Ok(DocumentDetailResponse { + document: document_response, + }) + } + + pub fn restore_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + folder_id: Option, + ) -> AppResult<()> { + let mut document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + if document.deleted_at.is_none() { + return Ok(()); + } + + if let Some(folder_id) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, folder_id)?; + document.folder_id = Some(folder_id); + } else if let Some(existing_folder) = document.folder_id { + let exists: bool = diesel::select(exists( + folders::table + .filter(folders::id.eq(existing_folder)) + .filter(folders::tenant_id.eq(tenant_id)), + )) + .get_result(conn)?; + + if !exists { + document.folder_id = None; + } + } + + let now = Utc::now().naive_utc(); + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::deleted_at.eq::>(None), + documents::folder_id.eq(document.folder_id), + documents::updated_at.eq(now), + )) + .execute(conn)?; + + Ok(()) + } + + pub fn move_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + folder_id: Option, + ) -> AppResult<()> { + if let Some(folder_id) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, folder_id)?; + } + + let now = Utc::now().naive_utc(); + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::folder_id.eq(folder_id), + documents::updated_at.eq(now), + )) + .execute(conn)?; + + Ok(()) + } + + pub fn bulk_move_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + mut document_ids: Vec, + folder_id: Option, + ) -> AppResult { + if document_ids.is_empty() { + return Err(AppError::bad_request("document_ids must not be empty")); + } + + document_ids.sort(); + document_ids.dedup(); + + if let Some(target_folder) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, target_folder)?; + } + + let existing: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&document_ids)) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::deleted_at)) + .load(conn)?; + + if existing.len() != document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if existing.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request("cannot move deleted documents")); + } + + let now = Utc::now().naive_utc(); + let updated = match diesel::update( + documents::table + .filter(documents::id.eq_any(&document_ids)) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::folder_id.eq(folder_id), + documents::updated_at.eq(now), + )) + .execute(conn) + { + Ok(value) => value, + Err(diesel::result::Error::DatabaseError(kind, info)) => { + error!( + ?kind, + detail = ?info.details(), + constraint = info.constraint_name(), + tenant_id = %tenant_id, + target_folder = folder_id.map(|id| id.to_string()), + "bulk move update failed" + ); + let message = info + .constraint_name() + .map(|name| format!("constraint {name} prevented moving documents")) + .unwrap_or_else(|| "unable to move documents due to a constraint".to_string()); + return Err(AppError::conflict(message)); + } + Err(err) => { + error!( + ?err, + tenant_id = %tenant_id, + target_folder = folder_id.map(|id| id.to_string()), + "bulk move update failed" + ); + return Err(AppError::from(err)); + } + }; + + Ok(updated) + } + + fn to_document_response( + &self, + user_id: Uuid, + doc: Document, + tags: Vec, + correspondents: Vec, + current_version: Option<(DocumentVersionResponse, Vec)>, + ) -> AppResult { + let current_version = match current_version { + Some((version, assets)) => Some(DocumentVersionDetailResponse { + version, + assets, + download_path: build_download_path(self.state, &doc, user_id)?, + }), + None => None, + }; + + Ok(DocumentResponse { + id: doc.id, + filename: doc.filename, + title: doc.title, + original_name: doc.original_name, + content_type: doc.content_type, + folder_id: doc.folder_id, + created_at: to_iso(doc.created_at), + updated_at: to_iso(doc.updated_at), + deleted_at: doc.deleted_at.map(to_iso), + issued_at: doc.issued_at.map(to_iso), + metadata: doc.metadata, + tags: tags.into_iter().map(TagResponse::from).collect(), + correspondents, + current_version, + }) + } +} + +fn presign_disposition_for_asset( + asset: &DocumentAsset, + object: &DocumentAssetObject, +) -> Option { + let filename = format!("{}-{}", asset.asset_type, object.ordinal); + inline_content_disposition(&filename) +} diff --git a/backend/src/services/folders.rs b/backend/src/services/folders.rs new file mode 100644 index 0000000..27228a0 --- /dev/null +++ b/backend/src/services/folders.rs @@ -0,0 +1,637 @@ +use std::collections::HashMap; + +use diesel::pg::PgConnection; +use diesel::{ + dsl::{exists, sql}, + prelude::*, + sql_types::Text, +}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection}; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{IntoAppResult, RowsAffectedExt}; +use crate::models::{Document, Folder, NewFolder}; +use crate::schema::{documents, folders}; +use crate::services::{ + documents::{DocumentResponse, DocumentsService}, + TransactionExt, +}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::{json::deserialize_patch_field, text::normalize_identifier, time::to_iso}; + +const MAX_FOLDER_NAME_LEN: usize = 255; + +#[derive(Deserialize, ToSchema)] +pub struct CreateFolderRequest { + pub name: String, + #[schema(nullable)] + pub parent_id: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct EnsureFolderPathRequest { + #[schema(nullable)] + pub parent_id: Option, + pub segments: Vec, +} + +#[derive(Serialize, ToSchema, Clone, Debug)] +pub struct FolderInfo { + pub id: Uuid, + pub name: String, + #[schema(nullable)] + pub parent_id: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Serialize, ToSchema)] +pub struct FolderTreeNode { + pub id: Uuid, + pub name: String, + #[schema(nullable)] + pub parent_id: Option, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub children: Vec, +} + +#[derive(Deserialize, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] +pub struct FolderContentsQuery { + #[serde(default = "default_include_documents")] + #[schema(default = true)] + pub include_documents: bool, + #[serde(default)] + #[schema(default = "title")] + pub sort: DocumentSortField, + #[serde(default)] + #[schema(default = "asc")] + pub dir: SortDirection, +} + +#[derive(Default, Deserialize, ToSchema)] +pub struct UpdateFolderRequest { + #[serde(default, deserialize_with = "deserialize_patch_field")] + #[schema(nullable, value_type = Option)] + pub parent_id: Option>, + #[serde(default, deserialize_with = "deserialize_patch_field")] + #[schema(nullable)] + pub name: Option>, +} + +pub struct FolderContentsData { + pub folder: Option, + pub subfolders: Vec, + pub documents: Vec, +} + +pub struct FolderService<'a> { + state: &'a AppState, +} + +impl<'a> FolderService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn get_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Uuid, + ) -> AppResult { + let folder: Folder = folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)?; + Ok(folder_to_info(folder)) + } + + pub fn ensure_folder_path( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + payload: EnsureFolderPathRequest, + ) -> AppResult { + if payload.segments.is_empty() { + return Err(AppError::bad_request("segments must not be empty")); + } + + let folder = conn.app_transaction(|conn| { + let mut current_parent = payload.parent_id; + let mut last_folder: Option = None; + + for raw_name in &payload.segments { + let name = normalize_folder_name(raw_name, "folder names must not be empty")?; + + let existing: Option = if let Some(parent_id) = current_parent { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + } else { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + }; + + let folder = if let Some(folder) = existing { + folder + } else { + let new_folder = NewFolder { + id: Uuid::new_v4(), + name: name.clone(), + parent_id: current_parent, + tenant_id, + }; + + let inserted_id: Option = diesel::insert_into(folders::table) + .values(&new_folder) + .on_conflict_do_nothing() + .returning(folders::id) + .get_result(conn) + .optional()?; + + if let Some(id) = inserted_id { + folders::table + .find(id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)? + } else if let Some(parent_id) = current_parent { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn)? + } else { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn)? + } + }; + + current_parent = Some(folder.id); + last_folder = Some(folder); + } + + last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path")) + })?; + + Ok(folder_to_info(folder)) + } + + pub fn create_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + payload: CreateFolderRequest, + ) -> AppResult<(FolderInfo, bool)> { + let name = normalize_folder_name(&payload.name, "name must not be empty")?; + + let existing: Option = if let Some(parent_id) = payload.parent_id { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + } else { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + }; + + let (folder, created) = if let Some(folder) = existing { + (folder, false) + } else { + let new_folder = NewFolder { + id: Uuid::new_v4(), + name: name.clone(), + parent_id: payload.parent_id, + tenant_id, + }; + + let inserted_id: Option = diesel::insert_into(folders::table) + .values(&new_folder) + .on_conflict_do_nothing() + .returning(folders::id) + .get_result(conn) + .optional()?; + + if let Some(id) = inserted_id { + ( + folders::table + .find(id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)?, + true, + ) + } else if let Some(parent_id) = payload.parent_id { + ( + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn)?, + false, + ) + } else { + ( + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn)?, + false, + ) + } + }; + + Ok((folder_to_info(folder), created)) + } + + pub fn list_folder_contents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Option, + sort: DocumentSortField, + dir: SortDirection, + include_documents: bool, + ) -> AppResult { + let folder = match folder_id { + Some(id) => Some(folder_to_info( + folders::table + .find(id) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn)?, + )), + None => None, + }; + + let child_folders: Vec = if let Some(parent_id) = folder_id { + folders::table + .filter(folders::parent_id.eq(parent_id)) + .filter(folders::tenant_id.eq(tenant_id)) + .order(sql::("name COLLATE \"unicode_ci\" ASC")) + .load(conn)? + } else { + folders::table + .filter(folders::parent_id.is_null()) + .filter(folders::tenant_id.eq(tenant_id)) + .order(sql::("name COLLATE \"unicode_ci\" ASC")) + .load(conn)? + }; + + let subfolders = child_folders.into_iter().map(folder_to_info).collect(); + + let documents = if include_documents { + let mut docs_query = documents::table + .filter(documents::deleted_at.is_null()) + .filter(documents::tenant_id.eq(tenant_id)) + .into_boxed(); + + let (primary_sql, secondary_sql) = ordering_clauses(sort, dir); + docs_query = docs_query.order(sql::(primary_sql)); + if let Some(second) = secondary_sql { + docs_query = docs_query.then_order_by(sql::(second)); + } + + if let Some(current_folder) = folder_id { + docs_query + .filter(documents::folder_id.eq(current_folder)) + .load::(conn)? + } else { + docs_query + .filter(documents::folder_id.is_null()) + .load::(conn)? + } + } else { + Vec::new() + }; + + Ok(FolderContentsData { + folder, + subfolders, + documents, + }) + } + + pub fn list_folder_tree( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + ) -> AppResult> { + let folders: Vec = folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .order(sql::("name COLLATE \"unicode_ci\" ASC")) + .load(conn)?; + + let mut node_map: HashMap = HashMap::with_capacity(folders.len()); + let mut children_map: HashMap> = HashMap::new(); + let mut roots: Vec = Vec::new(); + + for folder in folders { + let id = folder.id; + let parent_id = folder.parent_id; + let node = FolderTreeNode { + id, + name: folder.name, + parent_id, + created_at: to_iso(folder.created_at), + updated_at: to_iso(folder.updated_at), + children: Vec::new(), + }; + + if let Some(parent) = parent_id { + children_map.entry(parent).or_default().push(id); + } else { + roots.push(id); + } + + node_map.insert(id, node); + } + + fn build_node( + id: Uuid, + nodes: &HashMap, + child_map: &HashMap>, + ) -> FolderTreeNode { + let mut node = nodes.get(&id).cloned().expect("folder node must exist"); + + if let Some(children) = child_map.get(&id) { + node.children = children + .iter() + .map(|child_id| build_node(*child_id, nodes, child_map)) + .collect(); + } + + node + } + + let tree = roots + .iter() + .map(|root_id| build_node(*root_id, &node_map, &children_map)) + .collect(); + + Ok(tree) + } + + pub fn delete_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Uuid, + ) -> AppResult<()> { + conn.app_transaction(|conn| { + folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn)?; + + let has_child_folders: bool = diesel::select(exists( + folders::table + .filter(folders::parent_id.eq(Some(folder_id))) + .filter(folders::tenant_id.eq(tenant_id)), + )) + .get_result(conn)?; + + if has_child_folders { + return Err(AppError::bad_request( + "folder must be empty before deletion", + )); + } + + let has_documents: bool = diesel::select(exists( + documents::table + .filter(documents::folder_id.eq(Some(folder_id))) + .filter(documents::tenant_id.eq(tenant_id)) + .filter(documents::deleted_at.is_null()), + )) + .get_result(conn)?; + + if has_documents { + return Err(AppError::bad_request( + "folder must be empty before deletion", + )); + } + + diesel::delete( + folders::table + .filter(folders::id.eq(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)), + ) + .execute(conn) + .into_app_result()? + .or_not_found()?; + + Ok(()) + }) + } + + pub fn update_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Uuid, + payload: UpdateFolderRequest, + ) -> AppResult<()> { + conn.app_transaction(|conn| { + let folder: Folder = folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)?; + + let mut next_parent = folder.parent_id; + let mut parent_changed = false; + match payload.parent_id { + None => {} + Some(None) => { + if folder.parent_id.is_some() { + parent_changed = true; + } + next_parent = None; + } + Some(Some(parent_id)) => { + if parent_id == folder_id { + return Err(AppError::bad_request("folder cannot be its own parent")); + } + + folders::table + .find(parent_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn)?; + + if folder.parent_id != Some(parent_id) { + let descendant_ids = + gather_descendant_folder_ids(conn, tenant_id, folder_id)?; + if descendant_ids.contains(&parent_id) { + return Err(AppError::bad_request( + "cannot move folder into itself or a descendant", + )); + } + parent_changed = true; + } + + next_parent = Some(parent_id); + } + } + + let mut new_name = folder.name.clone(); + let mut name_changed = false; + match payload.name { + None => {} + Some(None) => { + return Err(AppError::bad_request("name cannot be null")); + } + Some(Some(value)) => { + let normalized = normalize_folder_name(&value, "name must not be empty")?; + + if normalized != folder.name { + new_name = normalized; + name_changed = true; + } + } + } + + if !parent_changed && !name_changed { + return Ok(()); + } + + let conflict = if let Some(parent_id) = next_parent { + folders::table + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&new_name)) + .filter(folders::id.ne(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn) + .optional()? + } else { + folders::table + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&new_name)) + .filter(folders::id.ne(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn) + .optional()? + }; + + if conflict.is_some() { + return Err(AppError::bad_request( + "a folder with the same name already exists in the target", + )); + } + + diesel::update( + folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)), + ) + .set(( + folders::parent_id.eq(next_parent), + folders::name.eq(&new_name), + )) + .execute(conn) + .into_app_result()? + .or_not_found()?; + + Ok(()) + }) + } + + pub fn hydrate_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + docs: Vec, + ) -> AppResult> { + DocumentsService::new(self.state).hydrate_documents(conn, tenant_id, user_id, docs) + } +} + +pub fn gather_descendant_folder_ids( + conn: &mut PgConnection, + tenant_id: Uuid, + folder_id: Uuid, +) -> AppResult> { + let mut ids = vec![folder_id]; + let mut queue = vec![folder_id]; + + while let Some(current) = queue.pop() { + let child_ids: Vec = folders::table + .filter(folders::parent_id.eq(Some(current))) + .filter(folders::tenant_id.eq(tenant_id)) + .select(folders::id) + .load(conn)?; + queue.extend(child_ids.iter().copied()); + ids.extend(child_ids); + } + + Ok(ids) +} + +fn folder_to_info(folder: Folder) -> FolderInfo { + FolderInfo { + id: folder.id, + name: folder.name, + parent_id: folder.parent_id, + created_at: to_iso(folder.created_at), + updated_at: to_iso(folder.updated_at), + } +} + +fn normalize_folder_name(value: &str, empty_message: &str) -> AppResult { + normalize_identifier( + value, + MAX_FOLDER_NAME_LEN, + empty_message, + "folder name must not exceed 255 characters", + Some("folder name may only contain printable characters"), + |ch| !ch.is_control(), + ) +} + +const fn default_include_documents() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::UpdateFolderRequest; + use serde_json::json; + + #[test] + fn update_folder_request_deserializes_null_parent() { + let req: UpdateFolderRequest = + serde_json::from_value(json!({ "parent_id": null })).unwrap(); + assert!(matches!(req.parent_id, Some(None))); + } + + #[test] + fn update_folder_request_deserializes_absent_parent() { + let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap(); + assert!(req.parent_id.is_none()); + } + + #[test] + fn update_folder_request_deserializes_null_name() { + let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap(); + assert!(matches!(req.name, Some(None))); + } +} diff --git a/backend/src/services/helpers.rs b/backend/src/services/helpers.rs new file mode 100644 index 0000000..50c0052 --- /dev/null +++ b/backend/src/services/helpers.rs @@ -0,0 +1,25 @@ +use diesel::prelude::*; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::models::Document; +use crate::schema::documents; +use crate::state::PgPooledConnection; + +/// Load a document that belongs to the tenant and is not soft-deleted. +pub fn load_active_document( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, +) -> AppResult { + let doc: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + if doc.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + Ok(doc) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..affb9d3 --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1,67 @@ +use diesel::{Connection, PgConnection}; + +use crate::error::{AppError, AppResult}; +use crate::state::{AppState, PgPooledConnection}; + +pub trait TransactionExt { + fn app_transaction(&mut self, f: F) -> AppResult + where + F: FnOnce(&mut Self) -> AppResult; +} + +impl TransactionExt for PgConnection { + fn app_transaction(&mut self, f: F) -> AppResult + where + F: FnOnce(&mut Self) -> AppResult, + { + self.transaction::(|conn| f(conn)) + } +} + +impl TransactionExt for PgPooledConnection { + fn app_transaction(&mut self, f: F) -> AppResult + where + F: FnOnce(&mut Self) -> AppResult, + { + self.transaction::(|conn| f(conn)) + } +} + +pub struct ServiceContext<'a> { + state: &'a AppState, +} + +impl<'a> ServiceContext<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn state(&self) -> &'a AppState { + self.state + } + + pub fn db_conn(&self) -> AppResult { + self.state.db_unscoped() + } + + pub fn db_conn_for_tenant(&self, tenant_id: uuid::Uuid) -> AppResult { + self.state.db_for_tenant(tenant_id) + } + + pub fn tx(&self, conn: &mut C, f: F) -> AppResult + where + C: TransactionExt, + F: FnOnce(&mut C) -> AppResult, + { + conn.app_transaction(f) + } +} + +pub mod auth; +pub mod capability_sets; +pub mod correspondents; +pub mod documents; +pub mod folders; +pub mod helpers; +pub mod profile; +pub mod tags; diff --git a/backend/src/services/profile.rs b/backend/src/services/profile.rs new file mode 100644 index 0000000..b5ea4dc --- /dev/null +++ b/backend/src/services/profile.rs @@ -0,0 +1,222 @@ +use axum::http::StatusCode; +use chrono::{DateTime, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::auth::{ + api_tokens::{ + create_api_token as issue_token, list_api_tokens as load_tokens, + regenerate_api_token as rotate_token, revoke_api_token as revoke_token, + }, + capability_sets::load_capability_set, + passkeys::PasskeySummary, +}; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{created_json, no_content, ok_json, JsonResponse}; +use crate::models::ApiToken; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::time::to_iso; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ApiTokenResponse { + pub id: Uuid, + pub tenant_id: Uuid, + #[schema(nullable)] + pub label: Option, + pub capability_set_id: Uuid, + pub created_at: String, + #[schema(nullable)] + pub last_used_at: Option, + #[schema(nullable)] + pub expires_at: Option, + #[schema(nullable)] + pub revoked_at: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ApiTokenCreatedResponse { + pub token: String, + pub token_info: ApiTokenResponse, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateApiTokenRequest { + #[schema(nullable)] + pub label: Option, + #[schema(nullable)] + pub expires_at: Option, + pub capability_set_id: Uuid, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct RevokePasskeyQuery { + #[serde(default)] + #[schema(nullable)] + pub reason: Option, +} + +pub struct ProfileService<'a> { + state: &'a AppState, +} + +impl<'a> ProfileService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn list_passkeys( + &self, + conn: &mut PgPooledConnection, + user_id: Uuid, + ) -> AppResult>> { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let passkeys = service.list_for_user(conn, user_id)?; + ok_json(passkeys) + } + + pub fn list_api_tokens( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + ) -> AppResult>> { + let tokens = load_tokens(conn, user_id, Some(tenant_id))?; + let responses = tokens.into_iter().map(api_token_to_response).collect(); + ok_json(responses) + } + + pub fn create_api_token( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + payload: CreateApiTokenRequest, + ) -> AppResult> { + let expires_at = payload + .expires_at + .as_ref() + .map(|value| parse_timestamp(value)) + .transpose()?; + + let capability_set_id = + validate_capability_set(conn, tenant_id, payload.capability_set_id)?; + + let issued = issue_token( + conn, + user_id, + tenant_id, + payload.label.clone(), + expires_at, + capability_set_id, + )?; + + let token_info = api_token_to_response(issued.record); + + let response = ApiTokenCreatedResponse { + token: issued.token, + token_info, + }; + + created_json(response) + } + + pub fn regenerate_api_token( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + token_id: Uuid, + ) -> AppResult> { + let issued = rotate_token(conn, token_id, user_id, Some(tenant_id))?; + let token_info = api_token_to_response(issued.record); + ok_json(ApiTokenCreatedResponse { + token: issued.token, + token_info, + }) + } + + pub fn delete_api_token( + &self, + conn: &mut PgPooledConnection, + user_id: Uuid, + token_id: Uuid, + ) -> AppResult { + revoke_token(conn, token_id, user_id)?; + no_content() + } + + pub fn delete_passkey( + &self, + conn: &mut PgPooledConnection, + user_id: Uuid, + passkey_id: Uuid, + reason: Option, + ) -> AppResult { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let active_count = service.active_passkey_count(conn, user_id)?; + if active_count <= 1 { + return Err(AppError::bad_request( + "cannot revoke the last remaining passkey", + )); + } + + service.revoke_passkey(conn, user_id, passkey_id, reason)?; + no_content() + } +} + +fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { + let ApiToken { + id, + tenant_id, + label, + created_at, + last_used_at, + expires_at, + revoked_at, + capability_set_id, + .. + } = token; + + ApiTokenResponse { + id, + tenant_id, + label, + capability_set_id, + created_at: to_iso(created_at), + last_used_at: last_used_at.map(to_iso), + expires_at: expires_at.map(to_iso), + revoked_at: revoked_at.map(to_iso), + } +} + +fn parse_timestamp(value: &str) -> AppResult { + let dt = DateTime::parse_from_rfc3339(value) + .map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?; + Ok(dt.naive_utc()) +} + +fn validate_capability_set( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + capability_set_id: Uuid, +) -> AppResult { + let set = load_capability_set(conn, capability_set_id)?; + if set.tenant_id != tenant_id { + return Err(AppError::bad_request( + "capability set does not belong to the tenant", + )); + } + Ok(set.id) +} diff --git a/backend/src/services/tags.rs b/backend/src/services/tags.rs new file mode 100644 index 0000000..595464c --- /dev/null +++ b/backend/src/services/tags.rs @@ -0,0 +1,166 @@ +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::documents::tags::assign_tags as assign_tags_to_document; +use crate::error::{AppError, AppResult}; +use crate::models::{Document, NewDocumentTag, Tag}; +use crate::schema::{document_tags, documents, tags}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::db::validate_bulk_ids; + +#[derive(Deserialize, ToSchema)] +pub struct AssignTagsRequest { + pub tag_ids: Vec, +} + +#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BulkTagAction { + Add, + Remove, +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkTagRequest { + pub document_ids: Vec, + pub tag_ids: Vec, + pub action: BulkTagAction, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkTagResponse { + pub added: usize, + pub removed: usize, +} + +pub struct TagsService<'a> { + _state: &'a AppState, +} + +impl<'a> TagsService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { _state: state } + } + + pub fn assign_to_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + tag_ids: &[Uuid], + ) -> AppResult<()> { + if tag_ids.is_empty() { + return Err(AppError::bad_request("tag_ids must not be empty")); + } + + let document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + assign_tags_to_document(conn, tenant_id, &document, tag_ids, Some(user_id))?; + Ok(()) + } + + pub fn bulk_update( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + mut payload: BulkTagRequest, + ) -> AppResult { + validate_bulk_ids(&mut payload.document_ids, "document_ids")?; + validate_bulk_ids(&mut payload.tag_ids, "tag_ids")?; + + let docs: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&payload.document_ids)) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::deleted_at)) + .load(conn)?; + + if docs.len() != payload.document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if docs.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request( + "cannot assign or remove tags from deleted documents", + )); + } + + let existing_tags: Vec = tags::table + .filter(tags::id.eq_any(&payload.tag_ids)) + .filter(tags::tenant_id.eq(tenant_id)) + .load(conn)?; + + if existing_tags.len() != payload.tag_ids.len() { + return Err(AppError::bad_request("one or more tags do not exist")); + } + + match payload.action { + BulkTagAction::Add => { + let mut inserts = + Vec::with_capacity(payload.document_ids.len() * payload.tag_ids.len()); + for doc_id in &payload.document_ids { + for tag_id in &payload.tag_ids { + inserts.push(NewDocumentTag { + document_id: *doc_id, + tag_id: *tag_id, + assigned_by: Some(user_id), + tenant_id, + }); + } + } + + let added = if inserts.is_empty() { + 0 + } else { + diesel::insert_into(document_tags::table) + .values(&inserts) + .on_conflict_do_nothing() + .execute(conn)? + }; + + Ok(BulkTagResponse { added, removed: 0 }) + } + BulkTagAction::Remove => { + let removed = diesel::delete( + document_tags::table + .filter(document_tags::document_id.eq_any(&payload.document_ids)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .filter(document_tags::tag_id.eq_any(&payload.tag_ids)), + ) + .execute(conn)?; + + Ok(BulkTagResponse { added: 0, removed }) + } + } + } + + pub fn remove_from_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + tag_id: Uuid, + ) -> AppResult<()> { + let deleted = diesel::delete( + document_tags::table + .filter(document_tags::document_id.eq(document_id)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .filter(document_tags::tag_id.eq(tag_id)), + ) + .execute(conn)?; + + if deleted == 0 { + return Err(AppError::not_found()); + } + + Ok(()) + } +} diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs index baaded7..8ef9374 100644 --- a/backend/src/tenants.rs +++ b/backend/src/tenants.rs @@ -8,6 +8,7 @@ use crate::{ jobs::{enqueue_job, JOB_PROVISION_TENANT}, models::{Tenant, TenantStatus}, schema::tenants::dsl, + utils::text::normalize_identifier, }; pub struct TenantRepository; @@ -96,6 +97,8 @@ impl TenantService { return Err(AppError::bad_request("tenant name must not be empty")); } + let name = normalize_tenant_name(name)?; + let id = Uuid::new_v4(); let storage_root = normalize_storage_root(storage_root, id); let quickwit_index = normalize_quickwit_index(quickwit_index, id); @@ -103,7 +106,7 @@ impl TenantService { diesel::insert_into(dsl::tenants) .values(( dsl::id.eq(id), - dsl::name.eq(name), + dsl::name.eq(&name), dsl::storage_root.eq(Some(storage_root.clone())), dsl::quickwit_index.eq(Some(quickwit_index.clone())), dsl::config.eq(json!({})), @@ -186,6 +189,17 @@ pub fn apply_api_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResul .map_err(AppError::from) } +fn normalize_tenant_name(value: &str) -> AppResult { + normalize_identifier( + value, + 255, + "tenant name must not be empty", + "tenant name must not exceed 255 characters", + Some("tenant name may only contain printable characters"), + |ch| !ch.is_control(), + ) +} + pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> { diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)") .execute(conn) diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs index 3fc9a62..09e6f47 100644 --- a/backend/src/utils/mod.rs +++ b/backend/src/utils/mod.rs @@ -4,7 +4,9 @@ pub mod error; pub mod http; pub mod json; pub mod named_entity; +pub mod setops; pub mod storage_paths; +pub mod text; pub mod time; pub mod tracing; pub mod validation; diff --git a/backend/src/utils/setops.rs b/backend/src/utils/setops.rs new file mode 100644 index 0000000..91c8eb0 --- /dev/null +++ b/backend/src/utils/setops.rs @@ -0,0 +1,41 @@ +use std::collections::HashSet; +use std::hash::Hash; + +use uuid::Uuid; + +use crate::error::AppResult; +use crate::state::PgPooledConnection; + +/// Intersect an optional base set with a new set, returning the resulting option. +pub fn intersect_option_sets(base: Option>, next: HashSet) -> Option> +where + T: Eq + Hash + Copy, +{ + Some(match base { + Some(existing) => existing.intersection(&next).copied().collect(), + None => next, + }) +} + +/// Iteratively intersect documents linked via a join table loader. +pub fn load_linked_doc_ids( + conn: &mut PgPooledConnection, + ids: &[Uuid], + mut loader: F, +) -> AppResult> +where + F: FnMut(&mut PgPooledConnection, Uuid) -> AppResult>, +{ + let mut current: Option> = None; + + for id in ids { + let docs_set = loader(conn, *id)?; + current = intersect_option_sets(current, docs_set); + + if current.as_ref().is_some_and(|set| set.is_empty()) { + break; + } + } + + Ok(current.unwrap_or_default()) +} diff --git a/backend/src/utils/text.rs b/backend/src/utils/text.rs new file mode 100644 index 0000000..67785ee --- /dev/null +++ b/backend/src/utils/text.rs @@ -0,0 +1,31 @@ +use crate::error::{AppError, AppResult}; + +/// Normalizes an identifier-like user input by trimming, enforcing length, and validating characters. +pub fn normalize_identifier( + value: &str, + max_len: usize, + empty_message: &str, + length_message: &str, + invalid_message: Option<&str>, + mut validator: F, +) -> AppResult +where + F: FnMut(char) -> bool, +{ + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request(empty_message)); + } + + if trimmed.len() > max_len { + return Err(AppError::bad_request(length_message)); + } + + if let Some(msg) = invalid_message { + if !trimmed.chars().all(|ch| validator(ch)) { + return Err(AppError::bad_request(msg)); + } + } + + Ok(trimmed.to_string()) +}