This commit is contained in:
2025-10-31 01:19:01 +01:00
parent 5b5916ff92
commit fa43bc749e
16 changed files with 878 additions and 872 deletions
+135 -1
View File
@@ -13,7 +13,7 @@ use diesel::{pg::PgConnection, prelude::*};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use utoipa::ToSchema;
use utoipa::{OpenApi, ToSchema};
use uuid::Uuid;
use crate::{
@@ -104,12 +104,71 @@ pub enum LoginResponseVariants {
Selection(TenantSelectionResponse),
}
#[derive(OpenApi)]
#[openapi(
paths(
login,
signup_start,
signup_finish,
refresh,
logout,
me,
list_tenants,
select_tenant,
passkey_register_start,
passkey_register_finish,
passkey_login_start,
passkey_login_finish,
),
components(schemas(
LoginRequest,
SignupStartRequest,
SignupStartResponse,
SignupFinishRequest,
LoginResponse,
LoginResponseVariants,
TenantSnippet,
TenantSelectionResponse,
TenantSelectionRequest,
TenantListResponse,
crate::auth::AuthenticatedUser,
crate::auth::passkeys::RegistrationChallengeResponse,
crate::auth::passkeys::AuthenticationChallengeResponse,
crate::auth::passkeys::PasskeySummary,
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
crate::auth::passkeys::PasskeyLoginStartPayload,
crate::auth::passkeys::PasskeyLoginFinishPayload,
))
)]
pub struct AuthApiDoc;
#[utoipa::path(
post,
path = "/api/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login succeeded", body = LoginResponseVariants),
(status = 401, description = "Invalid credentials")
),
tag = "Auth"
)]
pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
Err(AppError::bad_request(
"password authentication is no longer supported",
))
}
#[utoipa::path(
post,
path = "/api/auth/signup/start",
request_body = SignupStartRequest,
responses(
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
(status = 400, description = "Invalid signup request"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub async fn signup_start(
State(state): State<AppState>,
Json(payload): Json<SignupStartRequest>,
@@ -147,6 +206,17 @@ pub async fn signup_start(
}))
}
#[utoipa::path(
post,
path = "/api/auth/signup/finish",
request_body = SignupFinishRequest,
responses(
(status = 200, description = "Signup completed", body = LoginResponseVariants),
(status = 400, description = "Invalid signup completion"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub async fn signup_finish(
State(state): State<AppState>,
Json(payload): Json<SignupFinishRequest>,
@@ -204,6 +274,15 @@ pub async fn signup_finish(
Ok(response)
}
#[utoipa::path(
post,
path = "/api/auth/refresh",
responses(
(status = 200, description = "Refreshed access token", body = LoginResponse),
(status = 401, description = "Missing or invalid refresh token")
),
tag = "Auth"
)]
pub async fn refresh(
State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>,
@@ -257,6 +336,13 @@ fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<(
.map_err(AppError::from)
}
#[utoipa::path(
post,
path = "/api/auth/select-tenant",
request_body = TenantSelectionRequest,
responses((status = 200, description = "Tenant selected", body = LoginResponse)),
tag = "Auth"
)]
pub async fn select_tenant(
State(state): State<AppState>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
@@ -293,6 +379,12 @@ pub async fn select_tenant(
issue_session(&state, &mut conn, &user, payload.tenant_id)
}
#[utoipa::path(
post,
path = "/api/auth/logout",
responses((status = 204, description = "Session revoked")),
tag = "Auth"
)]
pub async fn logout(
State(state): State<AppState>,
user: AuthenticatedUser,
@@ -338,10 +430,22 @@ pub async fn logout(
Ok((headers, StatusCode::NO_CONTENT))
}
#[utoipa::path(
get,
path = "/api/auth/me",
responses((status = 200, description = "Authenticated principal", body = AuthenticatedUser)),
tag = "Auth"
)]
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user)
}
#[utoipa::path(
get,
path = "/api/auth/tenants",
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
tag = "Auth"
)]
pub async fn list_tenants(
State(state): State<AppState>,
auth: Option<TypedHeader<Authorization<Bearer>>>,
@@ -374,6 +478,12 @@ pub async fn list_tenants(
Ok(Json(TenantListResponse { tenants }))
}
#[utoipa::path(
post,
path = "/api/auth/passkeys/register/start",
responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)),
tag = "Auth"
)]
pub async fn passkey_register_start(
State(state): State<AppState>,
user: AuthenticatedUser,
@@ -389,6 +499,13 @@ pub async fn passkey_register_start(
Ok(Json(challenge))
}
#[utoipa::path(
post,
path = "/api/auth/passkeys/register/finish",
request_body = PasskeyRegistrationFinishPayload,
responses((status = 200, description = "Passkey registered", body = PasskeySummary)),
tag = "Auth"
)]
pub async fn passkey_register_finish(
State(state): State<AppState>,
user: AuthenticatedUser,
@@ -419,6 +536,13 @@ pub async fn passkey_register_finish(
Ok(Json(PasskeySummary::from(passkey)))
}
#[utoipa::path(
post,
path = "/api/auth/passkeys/login/start",
request_body = PasskeyLoginStartPayload,
responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)),
tag = "Auth"
)]
pub async fn passkey_login_start(
State(state): State<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>,
@@ -442,6 +566,16 @@ pub async fn passkey_login_start(
Ok(Json(challenge))
}
#[utoipa::path(
post,
path = "/api/auth/passkeys/login/finish",
request_body = PasskeyLoginFinishPayload,
responses(
(status = 200, description = "Passkey login successful", body = LoginResponseVariants),
(status = 401, description = "Authentication failed")
),
tag = "Auth"
)]
pub async fn passkey_login_finish(
State(state): State<AppState>,
Json(payload): Json<PasskeyLoginFinishPayload>,
+45
View File
@@ -58,6 +58,12 @@ struct CorrespondentChangeset<'a> {
metadata: Option<&'a Value>,
}
#[utoipa::path(
get,
path = "/api/correspondents",
responses((status = 200, description = "Correspondents", body = [CorrespondentSummary])),
tag = "Correspondents"
)]
pub async fn list_correspondents(
TenantScopedConn {
mut conn,
@@ -90,6 +96,13 @@ pub async fn list_correspondents(
response.into_json()
}
#[utoipa::path(
post,
path = "/api/correspondents",
request_body = CreateCorrespondentRequest,
responses((status = 200, description = "Correspondent created", body = CorrespondentSummary)),
tag = "Correspondents"
)]
pub async fn create_correspondent(
TenantScopedConn {
mut conn,
@@ -132,6 +145,14 @@ pub async fn create_correspondent(
build_summary(correspondent, 0).into_json()
}
#[utoipa::path(
patch,
path = "/api/correspondents/{id}",
params(("id" = Uuid, Path, description = "Correspondent ID")),
request_body = UpdateCorrespondentRequest,
responses((status = 200, description = "Correspondent updated", body = CorrespondentSummary)),
tag = "Correspondents"
)]
pub async fn update_correspondent(
Path(correspondent_id): Path<Uuid>,
TenantScopedConn {
@@ -206,6 +227,13 @@ pub async fn update_correspondent(
build_summary(updated, usage).into_json()
}
#[utoipa::path(
delete,
path = "/api/correspondents/{id}",
params(("id" = Uuid, Path, description = "Correspondent ID")),
responses((status = 204, description = "Correspondent deleted")),
tag = "Correspondents"
)]
pub async fn delete_correspondent(
Path(correspondent_id): Path<Uuid>,
TenantScopedConn {
@@ -269,3 +297,20 @@ fn load_usage_for_correspondent(
Ok(total)
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::correspondents::list_correspondents,
crate::routes::correspondents::create_correspondent,
crate::routes::correspondents::update_correspondent,
crate::routes::correspondents::delete_correspondent
),
components(schemas(
crate::routes::correspondents::CorrespondentSummary,
crate::routes::correspondents::CorrespondentUsage,
crate::routes::correspondents::CreateCorrespondentRequest,
crate::routes::correspondents::UpdateCorrespondentRequest
))
)]
pub struct CorrespondentsApiDoc;
+44
View File
@@ -26,6 +26,12 @@ pub struct UpdateDocumentTypeRequest {
pub name: Option<String>,
}
#[utoipa::path(
get,
path = "/api/document-types",
responses((status = 200, description = "Document types", body = [DocumentTypeResponse])),
tag = "DocumentTypes"
)]
pub async fn list_document_types(
TenantScopedConn {
mut conn,
@@ -43,6 +49,13 @@ pub async fn list_document_types(
))
}
#[utoipa::path(
post,
path = "/api/document-types",
request_body = CreateDocumentTypeRequest,
responses((status = 201, description = "Document type created", body = DocumentTypeResponse)),
tag = "DocumentTypes"
)]
pub async fn create_document_type(
TenantScopedConn {
mut conn,
@@ -81,6 +94,14 @@ pub async fn create_document_type(
}
}
#[utoipa::path(
patch,
path = "/api/document-types/{id}",
params(("id" = Uuid, Path, description = "Document type ID")),
request_body = UpdateDocumentTypeRequest,
responses((status = 200, description = "Document type updated", body = DocumentTypeResponse)),
tag = "DocumentTypes"
)]
pub async fn update_document_type(
Path(document_type_id): Path<Uuid>,
TenantScopedConn {
@@ -119,6 +140,13 @@ pub async fn update_document_type(
}
}
#[utoipa::path(
delete,
path = "/api/document-types/{id}",
params(("id" = Uuid, Path, description = "Document type ID")),
responses((status = 204, description = "Document type deleted")),
tag = "DocumentTypes"
)]
pub async fn delete_document_type(
Path(document_type_id): Path<Uuid>,
TenantScopedConn {
@@ -140,3 +168,19 @@ pub async fn delete_document_type(
Ok(StatusCode::NO_CONTENT)
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::document_types::list_document_types,
crate::routes::document_types::create_document_type,
crate::routes::document_types::update_document_type,
crate::routes::document_types::delete_document_type
),
components(schemas(
crate::routes::document_types::CreateDocumentTypeRequest,
crate::routes::document_types::UpdateDocumentTypeRequest,
crate::routes::documents::DocumentTypeResponse
))
)]
pub struct DocumentTypesApiDoc;
+288 -38
View File
@@ -378,6 +378,13 @@ pub struct AssetObjectsQuery {
pub limit: Option<i32>,
}
#[utoipa::path(
get,
path = "/api/documents",
params(DocumentListQuery),
responses((status = 200, description = "List documents", body = [DocumentResponse])),
tag = "Documents"
)]
pub async fn list_documents(
State(state): State<AppState>,
Query(params): Query<DocumentListQuery>,
@@ -633,48 +640,18 @@ pub async fn list_documents(
.load(&mut conn)?
};
let type_ids: HashSet<Uuid> = docs.iter().filter_map(|doc| doc.document_type_id).collect();
let doc_type_map: HashMap<Uuid, DocumentType> = if type_ids.is_empty() {
HashMap::new()
} else {
let ids: Vec<Uuid> = type_ids.iter().copied().collect();
document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.filter(document_types::id.eq_any(&ids))
.load::<DocumentType>(&mut conn)?
.into_iter()
.map(|typ| (typ.id, typ))
.collect()
};
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let primary_versions = load_primary_assets(&state, tenant_id, &docs)?;
let mut response = 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();
let doc_type = doc
.document_type_id
.and_then(|id| doc_type_map.get(&id).cloned());
response.push(to_document_response(
&state,
user_id,
doc,
tags,
correspondents,
current_version,
doc_type,
)?);
}
let response = hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?;
Ok(Json(response))
}
#[utoipa::path(
get,
path = "/api/documents/check",
params(DocumentCheckQuery),
responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)),
tag = "Documents"
)]
pub async fn check_document(
Query(query): Query<DocumentCheckQuery>,
TenantScopedConn {
@@ -728,6 +705,13 @@ pub async fn check_document(
}
}
#[utoipa::path(
get,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document detail", body = DocumentDetailResponse)),
tag = "Documents"
)]
pub async fn get_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
@@ -773,6 +757,17 @@ pub async fn get_document(
}))
}
#[utoipa::path(
post,
path = "/api/documents",
request_body = UploadDocumentForm,
responses(
(status = 201, description = "Document created", body = DocumentDetailResponse),
(status = 200, description = "Existing document reused", body = DocumentDetailResponse),
(status = 204, description = "Upload skipped because the document already exists")
),
tag = "Documents"
)]
pub async fn upload_document(
State(state): State<AppState>,
TenantScopedConn {
@@ -978,6 +973,13 @@ pub async fn upload_document(
Ok(response)
}
#[utoipa::path(
post,
path = "/api/documents/{id}/assets",
params(("id" = Uuid, Path, description = "Document ID"), AssetRequestQuery),
responses((status = 202, description = "Asset generation requested")),
tag = "Assets"
)]
pub async fn request_document_assets(
Path(document_id): Path<Uuid>,
Query(query): Query<AssetRequestQuery>,
@@ -1014,6 +1016,13 @@ pub async fn request_document_assets(
Ok(StatusCode::ACCEPTED)
}
#[utoipa::path(
post,
path = "/api/documents/bulk/reanalyze",
request_body = BulkReanalyzeSelectionRequest,
responses((status = 200, description = "Reanalyze queued", body = BulkReanalyzeResponse)),
tag = "Documents"
)]
pub async fn reanalyze_selected_documents(
TenantScopedConn {
mut conn,
@@ -1065,6 +1074,13 @@ pub async fn reanalyze_selected_documents(
Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued })))
}
#[utoipa::path(
get,
path = "/api/documents/{id}/assets",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document assets", body = [DocumentAssetResponse])),
tag = "Assets"
)]
pub async fn list_document_assets(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
@@ -1089,6 +1105,13 @@ pub async fn list_document_assets(
Ok(Json(assets))
}
#[utoipa::path(
get,
path = "/api/assets/{asset_id}",
params(("asset_id" = Uuid, Path, description = "Asset ID"), AssetObjectsQuery),
responses((status = 200, description = "Asset detail", body = DocumentAssetDetailResponse)),
tag = "Assets"
)]
pub async fn get_document_asset(
State(state): State<AppState>,
Path(asset_id): Path<Uuid>,
@@ -1163,6 +1186,13 @@ pub async fn get_document_asset(
Ok(Json(to_asset_detail_response(asset, object_responses)))
}
#[utoipa::path(
get,
path = "/api/documents/{id}/versions",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])),
tag = "Documents"
)]
pub async fn list_document_versions(
Path(document_id): Path<Uuid>,
TenantScopedConn {
@@ -1192,6 +1222,16 @@ pub async fn list_document_versions(
Ok(Json(versions))
}
#[utoipa::path(
get,
path = "/api/documents/{id}/versions/{version_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("version_id" = Uuid, Path, description = "Version ID"),
),
responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)),
tag = "Documents"
)]
pub async fn get_document_version(
State(state): State<AppState>,
Path((document_id, version_id)): Path<(Uuid, Uuid)>,
@@ -1230,6 +1270,13 @@ pub async fn get_document_version(
}))
}
#[utoipa::path(
get,
path = "/download/{token}",
params(("token" = String, Path, description = "Download token")),
responses((status = 302, description = "Redirect to pre-signed URL")),
tag = "Documents"
)]
pub async fn download_with_token(
State(state): State<AppState>,
Path(token): Path<String>,
@@ -1282,6 +1329,13 @@ pub async fn download_with_token(
Ok(axum::response::Redirect::temporary(&presigned_url))
}
#[utoipa::path(
delete,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 204, description = "Document deleted")),
tag = "Documents"
)]
pub async fn delete_document(
Path(document_id): Path<Uuid>,
TenantScopedConn {
@@ -1304,6 +1358,14 @@ pub async fn delete_document(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
patch,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = UpdateDocumentRequest,
responses((status = 200, description = "Updated document", body = DocumentDetailResponse)),
tag = "Documents"
)]
pub async fn update_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
@@ -1519,6 +1581,14 @@ pub async fn update_document(
}))
}
#[utoipa::path(
post,
path = "/api/documents/{id}/restore",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = RestoreDocumentRequest,
responses((status = 204, description = "Document restored")),
tag = "Documents"
)]
pub async fn restore_document(
Path(document_id): Path<Uuid>,
TenantScopedConn {
@@ -1569,6 +1639,14 @@ pub async fn restore_document(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
patch,
path = "/api/documents/{id}/folder",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = MoveDocumentRequest,
responses((status = 204, description = "Document moved")),
tag = "Documents"
)]
pub async fn move_document(
Path(document_id): Path<Uuid>,
TenantScopedConn {
@@ -1597,6 +1675,13 @@ pub async fn move_document(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/documents/bulk/move",
request_body = BulkMoveRequest,
responses((status = 200, description = "Bulk move outcome", body = BulkMoveResponse)),
tag = "Documents"
)]
pub async fn bulk_move_documents(
TenantScopedConn {
mut conn,
@@ -1680,6 +1765,14 @@ pub async fn bulk_move_documents(
Ok((StatusCode::OK, body.into_json()?))
}
#[utoipa::path(
post,
path = "/api/documents/{id}/correspondents",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = AssignCorrespondentsRequest,
responses((status = 204, description = "Correspondents assigned")),
tag = "Documents"
)]
pub async fn assign_correspondents(
Path(document_id): Path<Uuid>,
TenantScopedConn {
@@ -1761,6 +1854,13 @@ pub async fn assign_correspondents(
no_content()
}
#[utoipa::path(
post,
path = "/api/documents/bulk/correspondents",
request_body = BulkCorrespondentsRequest,
responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentResponse)),
tag = "Documents"
)]
pub async fn bulk_assign_correspondents(
TenantScopedConn {
mut conn,
@@ -1853,6 +1953,16 @@ pub async fn bulk_assign_correspondents(
Ok((StatusCode::OK, body.into_json()?))
}
#[utoipa::path(
delete,
path = "/api/documents/{id}/correspondents/{correspondent_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("correspondent_id" = Uuid, Path, description = "Correspondent ID")
),
responses((status = 204, description = "Correspondent removed")),
tag = "Documents"
)]
pub async fn remove_correspondent(
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
TenantScopedConn {
@@ -1892,6 +2002,14 @@ pub async fn remove_correspondent(
no_content()
}
#[utoipa::path(
post,
path = "/api/documents/{id}/tags",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = AssignTagsRequest,
responses((status = 204, description = "Tags assigned")),
tag = "Documents"
)]
pub async fn assign_tags(
Path(document_id): Path<Uuid>,
TenantScopedConn {
@@ -1922,6 +2040,13 @@ pub async fn assign_tags(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/documents/bulk/tags",
request_body = BulkTagRequest,
responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)),
tag = "Documents"
)]
pub async fn bulk_update_tags(
TenantScopedConn {
mut conn,
@@ -2007,6 +2132,16 @@ pub async fn bulk_update_tags(
Ok((StatusCode::OK, response.into_json()?))
}
#[utoipa::path(
delete,
path = "/api/documents/{id}/tags/{tag_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("tag_id" = Uuid, Path, description = "Tag ID")
),
responses((status = 204, description = "Tag removed")),
tag = "Documents"
)]
pub async fn remove_tag(
Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
TenantScopedConn {
@@ -2363,3 +2498,118 @@ fn load_document_type(
Ok(None)
}
}
pub(crate) fn hydrate_documents(
state: &AppState,
conn: &mut PgConnection,
tenant_id: Uuid,
user_id: Uuid,
docs: Vec<Document>,
) -> AppResult<Vec<DocumentResponse>> {
if docs.is_empty() {
return Ok(Vec::new());
}
let type_ids: HashSet<Uuid> = docs.iter().filter_map(|doc| doc.document_type_id).collect();
let doc_type_map: HashMap<Uuid, DocumentType> = if type_ids.is_empty() {
HashMap::new()
} else {
let ids: Vec<Uuid> = type_ids.iter().copied().collect();
document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.filter(document_types::id.eq_any(&ids))
.load::<DocumentType>(conn)?
.into_iter()
.map(|typ| (typ.id, typ))
.collect()
};
let doc_ids: Vec<Uuid> = 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();
let doc_type = doc
.document_type_id
.and_then(|id| doc_type_map.get(&id).cloned());
responses.push(to_document_response(
state,
user_id,
doc,
tags,
correspondents,
current_version,
doc_type,
)?);
}
Ok(responses)
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::documents::list_documents,
crate::routes::documents::check_document,
crate::routes::documents::upload_document,
crate::routes::documents::get_document,
crate::routes::documents::update_document,
crate::routes::documents::delete_document,
crate::routes::documents::restore_document,
crate::routes::documents::download_with_token,
crate::routes::documents::move_document,
crate::routes::documents::assign_tags,
crate::routes::documents::remove_tag,
crate::routes::documents::bulk_move_documents,
crate::routes::documents::bulk_update_tags,
crate::routes::documents::bulk_assign_correspondents,
crate::routes::documents::assign_correspondents,
crate::routes::documents::remove_correspondent,
crate::routes::documents::reanalyze_selected_documents,
crate::routes::documents::list_document_assets,
crate::routes::documents::request_document_assets,
crate::routes::documents::get_document_asset,
crate::routes::documents::list_document_versions,
crate::routes::documents::get_document_version,
),
components(schemas(
crate::routes::documents::DocumentListQuery,
crate::routes::documents::DocumentStatusFilter,
crate::routes::documents::AssetRequestQuery,
crate::routes::documents::DocumentCheckQuery,
crate::routes::documents::DocumentCheckResponse,
crate::routes::documents::DocumentResponse,
crate::routes::documents::DocumentDetailResponse,
crate::routes::documents::DocumentMetadataUpdate,
crate::routes::documents::DocumentTypeResponse,
crate::routes::documents::TagResponse,
crate::routes::documents::CorrespondentAssignmentInput,
crate::routes::documents::AssignCorrespondentsRequest,
crate::routes::documents::BulkCorrespondentAction,
crate::routes::documents::BulkCorrespondentsRequest,
crate::routes::documents::BulkCorrespondentResponse,
crate::routes::documents::BulkMoveRequest,
crate::routes::documents::BulkMoveResponse,
crate::routes::documents::BulkTagAction,
crate::routes::documents::BulkTagRequest,
crate::routes::documents::BulkTagResponse,
crate::routes::documents::AssignTagsRequest,
crate::routes::documents::MoveDocumentRequest,
crate::routes::documents::BulkReanalyzeSelectionRequest,
crate::routes::documents::BulkReanalyzeResponse,
crate::routes::documents::AssetObjectsQuery,
crate::routes::documents::UploadDocumentForm,
crate::documents::asset::DocumentVersionResponse,
crate::documents::asset::DocumentVersionDetailResponse,
crate::documents::asset::DocumentAssetResponse,
crate::documents::asset::DocumentAssetDetailResponse,
crate::documents::asset::DocumentAssetObjectResponse,
crate::documents::correspondents::DocumentCorrespondentResponse,
))
)]
pub struct DocumentsApiDoc;
+100 -54
View File
@@ -1,5 +1,3 @@
use std::collections::{HashMap, HashSet};
use axum::{
extract::{Json, Path, Query, State},
http::StatusCode,
@@ -9,20 +7,16 @@ use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::models::{Document, DocumentType, Folder, NewFolder};
use crate::schema::{document_types, documents, folders};
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders};
use crate::state::AppState;
use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult},
};
use super::documents::{to_document_response, DocumentResponse};
use crate::documents::{
asset::load_primary_assets, correspondents::load_correspondents_for_documents,
tags::load_tags_for_documents,
};
use crate::utils::time::to_iso;
use super::documents::{hydrate_documents, DocumentResponse};
use crate::utils::{json::deserialize_patch_field, time::to_iso};
#[derive(Deserialize, ToSchema)]
pub struct CreateFolderRequest {
@@ -74,16 +68,47 @@ pub struct FolderInfo {
}
#[derive(Default, Deserialize, ToSchema)]
#[serde(default)]
pub struct UpdateFolderRequest {
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<Uuid>)]
pub parent_id: Option<Option<Uuid>>,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable)]
pub name: Option<Option<String>>,
}
#[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}",
params(("id" = Uuid, Path, description = "Folder ID")),
responses((status = 200, description = "Folder detail", body = FolderResponse)),
tag = "Folders"
)]
pub async fn get_folder(
Path(folder_id): Path<Uuid>,
TenantScopedConn {
@@ -102,6 +127,13 @@ pub async fn get_folder(
}))
}
#[utoipa::path(
post,
path = "/api/folders/path",
request_body = EnsureFolderPathRequest,
responses((status = 200, description = "Folder path ensured", body = FolderResponse)),
tag = "Folders"
)]
pub async fn ensure_folder_path(
TenantScopedConn {
mut conn,
@@ -188,6 +220,16 @@ pub async fn ensure_folder_path(
}))
}
#[utoipa::path(
post,
path = "/api/folders",
request_body = CreateFolderRequest,
responses(
(status = 201, description = "Folder created", body = FolderResponse),
(status = 200, description = "Folder already existed", body = FolderResponse)
),
tag = "Folders"
)]
pub async fn create_folder(
TenantScopedConn {
mut conn,
@@ -275,6 +317,13 @@ pub async fn create_folder(
}
}
#[utoipa::path(
get,
path = "/api/folders/{id}/contents",
params(("id" = Uuid, Path, description = "Folder ID"), FolderContentsQuery),
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
tag = "Folders"
)]
pub async fn list_folder_contents(
State(state): State<AppState>,
Path(folder_identifier): Path<String>,
@@ -336,47 +385,7 @@ pub async fn list_folder_contents(
.load(&mut conn)?
};
let type_ids: HashSet<Uuid> = docs.iter().filter_map(|doc| doc.document_type_id).collect();
let doc_type_map: HashMap<Uuid, DocumentType> = if type_ids.is_empty() {
HashMap::new()
} else {
let ids: Vec<Uuid> = type_ids.iter().copied().collect();
document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.filter(document_types::id.eq_any(&ids))
.load::<DocumentType>(&mut conn)?
.into_iter()
.map(|typ| (typ.id, typ))
.collect()
};
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let primary_versions = load_primary_assets(&state, tenant_id, &docs)?;
let mut documents = 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();
let doc_type = doc
.document_type_id
.and_then(|id| doc_type_map.get(&id).cloned());
documents.push(to_document_response(
&state,
user_id,
doc,
tags,
correspondents,
current_version,
doc_type,
)?);
}
documents
hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?
} else {
Vec::new()
};
@@ -388,6 +397,13 @@ pub async fn list_folder_contents(
}))
}
#[utoipa::path(
delete,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
responses((status = 204, description = "Folder deleted")),
tag = "Folders"
)]
pub async fn delete_folder(
Path(folder_id): Path<Uuid>,
TenantScopedConn {
@@ -442,6 +458,14 @@ pub async fn delete_folder(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
patch,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
request_body = UpdateFolderRequest,
responses((status = 204, description = "Folder updated")),
tag = "Folders"
)]
pub async fn update_folder(
Path(folder_id): Path<Uuid>,
TenantScopedConn {
@@ -586,3 +610,25 @@ pub(super) fn gather_descendant_folder_ids(
Ok(ids)
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::folders::create_folder,
crate::routes::folders::ensure_folder_path,
crate::routes::folders::get_folder,
crate::routes::folders::list_folder_contents,
crate::routes::folders::delete_folder,
crate::routes::folders::update_folder
),
components(schemas(
crate::routes::folders::CreateFolderRequest,
crate::routes::folders::EnsureFolderPathRequest,
crate::routes::folders::FolderResponse,
crate::routes::folders::FolderInfo,
crate::routes::folders::FolderContentsQuery,
crate::routes::folders::FolderContentsResponse,
crate::routes::folders::UpdateFolderRequest
))
)]
pub struct FoldersApiDoc;
+10
View File
@@ -1,6 +1,16 @@
use axum::{http::StatusCode, response::Json};
use serde_json::json;
#[derive(utoipa::OpenApi)]
#[openapi(paths(crate::routes::health::health_check))]
pub struct HealthApiDoc;
#[utoipa::path(
get,
path = "/api/health",
responses((status = 200, description = "Service is healthy")),
tag = "Health"
)]
pub async fn health_check() -> (StatusCode, Json<serde_json::Value>) {
(StatusCode::OK, Json(json!({ "status": "ok" })))
}
+55
View File
@@ -57,6 +57,12 @@ pub struct RevokePasskeyQuery {
pub reason: Option<String>,
}
#[utoipa::path(
get,
path = "/api/profile/passkeys",
responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])),
tag = "Profile"
)]
pub async fn list_passkeys(
State(state): State<AppState>,
TenantScopedConn {
@@ -72,6 +78,12 @@ pub async fn list_passkeys(
Ok(Json(passkeys))
}
#[utoipa::path(
get,
path = "/api/profile/webdav-tokens",
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
tag = "Profile"
)]
pub async fn list_webdav_tokens(
TenantScopedConn {
mut conn,
@@ -85,6 +97,13 @@ pub async fn list_webdav_tokens(
Ok(Json(responses))
}
#[utoipa::path(
post,
path = "/api/profile/webdav-tokens",
request_body = CreateWebdavTokenRequest,
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
tag = "Profile"
)]
pub async fn create_webdav_token(
TenantScopedConn {
mut conn,
@@ -115,6 +134,13 @@ pub async fn create_webdav_token(
Ok((StatusCode::CREATED, Json(response)))
}
#[utoipa::path(
delete,
path = "/api/profile/webdav-tokens/{id}",
params(("id" = Uuid, Path, description = "WebDAV token ID")),
responses((status = 204, description = "WebDAV token revoked")),
tag = "Profile"
)]
pub async fn delete_webdav_token(
TenantScopedConn {
mut conn, user_id, ..
@@ -125,6 +151,16 @@ pub async fn delete_webdav_token(
no_content()
}
#[utoipa::path(
delete,
path = "/api/profile/passkeys/{id}",
params(
("id" = Uuid, Path, description = "Passkey ID"),
("reason" = Option<String>, Query, description = "Optional reason for revoking the passkey")
),
responses((status = 204, description = "Passkey revoked")),
tag = "Profile"
)]
pub async fn delete_passkey(
State(state): State<AppState>,
TenantScopedConn {
@@ -166,3 +202,22 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
Ok(dt.naive_utc())
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::profile::list_webdav_tokens,
crate::routes::profile::create_webdav_token,
crate::routes::profile::delete_webdav_token,
crate::routes::profile::list_passkeys,
crate::routes::profile::delete_passkey
),
components(schemas(
crate::routes::profile::WebdavTokenResponse,
crate::routes::profile::WebdavTokenCreatedResponse,
crate::routes::profile::CreateWebdavTokenRequest,
crate::routes::profile::RevokePasskeyQuery,
crate::auth::passkeys::PasskeySummary
))
)]
pub struct ProfileApiDoc;
+74 -4
View File
@@ -9,7 +9,10 @@ use crate::auth::TenantScopedConn;
use crate::error::{AppError, AppResult};
use crate::models::{NewTag, Tag};
use crate::schema::{document_tags, tags};
use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse};
use crate::utils::{
db::{no_content, EnsureEntity, IntoJsonResponse},
json::deserialize_patch_field,
};
#[derive(Deserialize, ToSchema)]
pub struct CreateTagRequest {
@@ -25,6 +28,30 @@ struct UpdateTagChangeset<'a> {
color: Option<Option<&'a str>>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn update_tag_request_deserializes_null_fields() {
let request: UpdateTagRequest = serde_json::from_value(json!({
"label": null,
"color": null
}))
.unwrap();
assert!(matches!(request.label, Some(None)));
assert!(matches!(request.color, Some(None)));
}
#[test]
fn update_tag_request_omitted_fields_are_none() {
let request: UpdateTagRequest = serde_json::from_value(json!({})).unwrap();
assert!(request.label.is_none());
assert!(request.color.is_none());
}
}
#[derive(Serialize, ToSchema)]
pub struct TagCatalogEntry {
pub id: Uuid,
@@ -35,16 +62,21 @@ pub struct TagCatalogEntry {
}
#[derive(Debug, Default, Deserialize, ToSchema)]
#[serde(default)]
pub struct UpdateTagRequest {
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<String>)]
pub label: Option<Option<String>>,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<String>)]
pub color: Option<Option<String>>,
}
#[utoipa::path(
get,
path = "/api/tags",
responses((status = 200, description = "Tags", body = [TagCatalogEntry])),
tag = "Tags"
)]
pub async fn list_tags(
TenantScopedConn {
mut conn,
@@ -78,6 +110,13 @@ pub async fn list_tags(
response.into_json()
}
#[utoipa::path(
post,
path = "/api/tags",
request_body = CreateTagRequest,
responses((status = 200, description = "Tag created", body = TagCatalogEntry)),
tag = "Tags"
)]
pub async fn create_tag(
TenantScopedConn {
mut conn,
@@ -126,6 +165,14 @@ pub async fn create_tag(
.into_json()
}
#[utoipa::path(
patch,
path = "/api/tags/{id}",
params(("id" = Uuid, Path, description = "Tag ID")),
request_body = UpdateTagRequest,
responses((status = 200, description = "Tag updated", body = TagCatalogEntry)),
tag = "Tags"
)]
pub async fn update_tag(
Path(tag_id): Path<Uuid>,
TenantScopedConn {
@@ -253,6 +300,13 @@ pub async fn update_tag(
.into_json()
}
#[utoipa::path(
delete,
path = "/api/tags/{id}",
params(("id" = Uuid, Path, description = "Tag ID")),
responses((status = 204, description = "Tag deleted")),
tag = "Tags"
)]
pub async fn delete_tag(
Path(tag_id): Path<Uuid>,
TenantScopedConn {
@@ -285,3 +339,19 @@ pub async fn delete_tag(
no_content()
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::tags::list_tags,
crate::routes::tags::create_tag,
crate::routes::tags::update_tag,
crate::routes::tags::delete_tag
),
components(schemas(
crate::routes::tags::CreateTagRequest,
crate::routes::tags::TagCatalogEntry,
crate::routes::tags::UpdateTagRequest
))
)]
pub struct TagsApiDoc;