Skip to main content

docbox_http/routes/
document_box.rs

1//! Document box related endpoints
2
3use crate::{
4    error::{DynHttpError, HttpCommonError, HttpErrorResponse, HttpResult, HttpStatusResult},
5    middleware::{
6        action_user::{ActionUser, UserParams},
7        tenant::{TenantDb, TenantEvents, TenantParams, TenantSearch, TenantStorage},
8    },
9    models::document_box::{
10        CreateDocumentBoxRequest, DocumentBoxResponse, DocumentBoxScope, DocumentBoxStats,
11        HttpDocumentBoxError,
12    },
13};
14use axum::{Json, extract::Path, http::StatusCode};
15use axum_valid::Garde;
16use docbox_core::{
17    database::models::{
18        document_box::DocumentBox,
19        file::File,
20        folder::{Folder, FolderWithExtra, ResolvedFolderWithExtra},
21        shared::WithFullPath,
22    },
23    document_box::{
24        create_document_box::{CreateDocumentBox, CreateDocumentBoxError, create_document_box},
25        delete_document_box::{DeleteDocumentBoxError, delete_document_box},
26        search_document_box::{ResolvedSearchResult, search_document_box},
27    },
28    search::models::{SearchRequest, SearchResultItem, SearchResultResponse},
29};
30use tokio::join;
31
32pub const DOCUMENT_BOX_TAG: &str = "Document Box";
33
34/// Create document box
35///
36/// Creates a new document box using the requested scope
37#[utoipa::path(
38    post,
39    operation_id = "document_box_create",
40    tag = DOCUMENT_BOX_TAG,
41    path = "/box",
42    responses(
43        (status = 201, description = "Document box created successfully", body = DocumentBoxResponse),
44        (status = 409, description = "Scope already exists", body = HttpErrorResponse),
45        (status = 500, description = "Internal server error", body = HttpErrorResponse)
46    ),
47    params(
48        TenantParams,
49        UserParams
50    )
51)]
52#[tracing::instrument(skip_all, fields(?req))]
53pub async fn create(
54    action_user: ActionUser,
55    TenantDb(db): TenantDb,
56    TenantEvents(events): TenantEvents,
57    Garde(Json(req)): Garde<Json<CreateDocumentBoxRequest>>,
58) -> Result<(StatusCode, Json<DocumentBoxResponse>), DynHttpError> {
59    // Update stored editing user data
60    let created_by = action_user.store_user(&db).await?;
61
62    let create = CreateDocumentBox {
63        scope: req.scope,
64        created_by: created_by.as_ref().map(|value| value.id.to_string()),
65    };
66
67    let (document_box, root) =
68        create_document_box(&db, &events, create)
69            .await
70            .map_err(|error| match error {
71                CreateDocumentBoxError::ScopeAlreadyExists => {
72                    DynHttpError::from(HttpDocumentBoxError::ScopeAlreadyExists)
73                }
74                error => {
75                    tracing::error!(?error, "failed to create document box");
76                    DynHttpError::from(HttpCommonError::ServerError)
77                }
78            })?;
79
80    Ok((
81        StatusCode::CREATED,
82        Json(DocumentBoxResponse {
83            document_box,
84            root: FolderWithExtra {
85                folder: root,
86                created_by,
87                last_modified_at: None,
88                last_modified_by: None,
89            },
90            children: Default::default(),
91        }),
92    ))
93}
94
95/// Get document box by scope
96///
97/// Gets a specific document box and the root folder for the box
98/// along with the resolved root folder children
99#[utoipa::path(
100    get,
101    operation_id = "document_box_get",
102    tag = DOCUMENT_BOX_TAG,
103    path = "/box/{scope}",
104    responses(
105        (status = 200, description = "Document box obtained successfully", body = DocumentBoxResponse),
106        (status = 404, description = "Document box not found", body = HttpErrorResponse),
107        (status = 500, description = "Internal server error", body = HttpErrorResponse)
108    ),
109    params(
110        ("scope" = DocumentBoxScope, Path, description = "Scope of the document box"),
111        TenantParams
112    )
113)]
114#[tracing::instrument(skip_all, fields(%scope))]
115pub async fn get(
116    TenantDb(db): TenantDb,
117    Path(DocumentBoxScope(scope)): Path<DocumentBoxScope>,
118) -> HttpResult<DocumentBoxResponse> {
119    let document_box = DocumentBox::find_by_scope(&db, &scope)
120        .await
121        .map_err(|error| {
122            tracing::error!(?error, "failed to query document box");
123            HttpCommonError::ServerError
124        })?
125        .ok_or(HttpDocumentBoxError::UnknownDocumentBox)?;
126
127    let WithFullPath {
128        data: root,
129        full_path,
130    } = Folder::find_root_with_extra(&db, &scope)
131        .await
132        .map_err(|error| {
133            tracing::error!(?error, "failed to query folder");
134            HttpCommonError::ServerError
135        })?
136        .ok_or_else(|| {
137            tracing::error!("document box missing root");
138            HttpCommonError::ServerError
139        })?;
140
141    let children = ResolvedFolderWithExtra::resolve(&db, root.folder.id, full_path)
142        .await
143        .map_err(|error| {
144            tracing::error!(?error, "failed to query document box root folder");
145            HttpCommonError::ServerError
146        })?;
147
148    Ok(Json(DocumentBoxResponse {
149        document_box,
150        root,
151        children,
152    }))
153}
154
155/// Get document box stats by scope
156///
157/// Requests stats about a document box using its scope. Provides stats such as:
158/// - Total files
159/// - Total links
160/// - Total folders
161/// - Size of all files
162#[utoipa::path(
163    get,
164    operation_id = "document_box_stats",
165    tag = DOCUMENT_BOX_TAG,
166    path = "/box/{scope}/stats",
167    responses(
168        (status = 200, description = "Document box stats obtained successfully", body = DocumentBoxStats),
169        (status = 404, description = "Document box not found", body = HttpErrorResponse),
170        (status = 500, description = "Internal server error", body = HttpErrorResponse)
171    ),
172    params(
173        ("scope" = DocumentBoxScope, Path, description = "Scope of the document box"),
174        TenantParams
175    )
176)]
177#[tracing::instrument(skip_all, fields(%scope))]
178pub async fn stats(
179    TenantDb(db): TenantDb,
180    Path(DocumentBoxScope(scope)): Path<DocumentBoxScope>,
181) -> HttpResult<DocumentBoxStats> {
182    // Assert that the document box exists
183    let _document_box = DocumentBox::find_by_scope(&db, &scope)
184        .await
185        .map_err(|error| {
186            tracing::error!(?error, "failed to query document box");
187            HttpCommonError::ServerError
188        })?
189        .ok_or(HttpDocumentBoxError::UnknownDocumentBox)?;
190
191    let root = Folder::find_root(&db, &scope)
192        .await
193        .map_err(|error| {
194            tracing::error!(?error, "failed to query folder");
195            HttpCommonError::ServerError
196        })?
197        .ok_or_else(|| {
198            tracing::error!("document box missing root");
199            HttpCommonError::ServerError
200        })?;
201
202    let children_future = Folder::count_children(&db, root.id);
203    let file_size_future = File::total_size_within_scope(&db, &scope);
204
205    // Load the children count and file sizes in parallel
206    let (children, file_size) = join!(children_future, file_size_future);
207
208    let children = children.map_err(|error| {
209        tracing::error!(?error, "failed to query document box children count");
210        HttpCommonError::ServerError
211    })?;
212
213    let file_size = file_size.map_err(|error| {
214        tracing::error!(?error, "failed to query document box file size");
215        HttpCommonError::ServerError
216    })?;
217
218    Ok(Json(DocumentBoxStats {
219        total_files: children.file_count,
220        total_links: children.link_count,
221        total_folders: children.folder_count,
222        file_size,
223    }))
224}
225
226/// Delete document box by scope
227///
228/// Deletes a specific document box by scope and all its contents
229///
230/// Access control for this should probably be restricted
231/// on other end to prevent users from deleting an entire
232/// bucket?
233#[utoipa::path(
234    delete,
235    operation_id = "document_box_delete",
236    tag = DOCUMENT_BOX_TAG,
237    path = "/box/{scope}",
238    responses(
239        (status = 204, description = "Document box deleted successfully"),
240        (status = 404, description = "Document box not found", body = HttpErrorResponse),
241        (status = 500, description = "Internal server error", body = HttpErrorResponse)
242    ),
243    params(
244        ("scope" = DocumentBoxScope, Path, description = "Scope of the document box"),
245        TenantParams
246    )
247)]
248#[tracing::instrument(skip_all, fields(%scope))]
249pub async fn delete(
250    TenantDb(db): TenantDb,
251    TenantSearch(search): TenantSearch,
252    TenantStorage(storage): TenantStorage,
253    TenantEvents(events): TenantEvents,
254    Path(DocumentBoxScope(scope)): Path<DocumentBoxScope>,
255) -> HttpStatusResult {
256    delete_document_box(&db, &search, &storage, &events, &scope)
257        .await
258        .map_err(|error| match error {
259            DeleteDocumentBoxError::UnknownScope => HttpDocumentBoxError::UnknownDocumentBox.into(),
260            error => {
261                tracing::error!(?error, "failed to delete document box");
262                DynHttpError::from(HttpCommonError::ServerError)
263            }
264        })?;
265
266    Ok(StatusCode::NO_CONTENT)
267}
268
269/// Search document box
270///
271/// Search within the document box
272#[utoipa::path(
273    post,
274    operation_id = "document_box_search",
275    tag = DOCUMENT_BOX_TAG,
276    path = "/box/{scope}/search",
277    responses(
278        (status = 200, description = "Searched successfully", body = SearchResultResponse),
279        (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
280        (status = 404, description = "Target folder not found", body = HttpErrorResponse),
281        (status = 500, description = "Internal server error", body = HttpErrorResponse)
282    ),
283    params(
284        ("scope" = DocumentBoxScope, Path, description = "Scope of the document box"),
285        TenantParams
286    )
287)]
288#[tracing::instrument(skip_all, fields(%scope, ?req))]
289pub async fn search(
290    TenantDb(db): TenantDb,
291    TenantSearch(search): TenantSearch,
292    Path(DocumentBoxScope(scope)): Path<DocumentBoxScope>,
293    Garde(Json(req)): Garde<Json<SearchRequest>>,
294) -> HttpResult<SearchResultResponse> {
295    let resolved = search_document_box(&db, &search, scope, req)
296        .await
297        .map_err(|error| {
298            tracing::error!(?error, "failed to search document box");
299            HttpCommonError::ServerError
300        })?;
301
302    let out: Vec<SearchResultItem> = resolved
303        .results
304        .into_iter()
305        .map(
306            |ResolvedSearchResult { result, data, path }| SearchResultItem {
307                path,
308                score: result.score,
309                data,
310                page_matches: result.page_matches,
311                total_hits: result.total_hits,
312                name_match: result.name_match,
313                content_match: result.content_match,
314            },
315        )
316        .collect();
317
318    Ok(Json(SearchResultResponse {
319        total_hits: resolved.total_hits,
320        results: out,
321    }))
322}