Skip to main content

docbox_http/routes/
folder.rs

1//! Folder 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::{
10        document_box::DocumentBoxScope,
11        file::UploadTaskResponse,
12        folder::{
13            CreateFolderRequest, FolderResponse, HttpFolderError, UpdateFolderRequest,
14            ZipFolderRequest,
15        },
16    },
17};
18use axum::{Json, extract::Path, http::StatusCode};
19use axum_valid::Garde;
20use docbox_core::{
21    database::models::{
22        edit_history::EditHistory,
23        folder::{Folder, FolderId, FolderWithExtra, ResolvedFolderWithExtra},
24        shared::WithFullPath,
25        tasks::TaskStatus,
26    },
27    folders::{
28        create_folder::{CreateFolderData, safe_create_folder},
29        create_folder_zip::{CreateFolderZipOptions, create_folder_zip},
30        delete_folder::delete_folder,
31        update_folder::{UpdateFolder, UpdateFolderError},
32    },
33    tasks::background_task::background_task,
34};
35use tracing::Instrument;
36
37pub const FOLDER_TAG: &str = "Folder";
38
39/// Create folder
40///
41/// Creates a new folder in the provided document box folder
42#[utoipa::path(
43    post,
44    operation_id = "folder_create",
45    tag = FOLDER_TAG,
46    path = "/box/{scope}/folder",
47    responses(
48        (status = 201, description = "Folder created successfully", body = FolderResponse),
49        (status = 404, description = "Destination folder not found", body = HttpErrorResponse),
50        (status = 500, description = "Internal server error", body = HttpErrorResponse)
51    ),
52    params(
53        ("scope" = DocumentBoxScope, Path, description = "Scope to create the folder within"),
54        TenantParams,
55        UserParams
56    )
57)]
58#[tracing::instrument(skip_all, fields(%scope, ?req))]
59pub async fn create(
60    action_user: ActionUser,
61    TenantDb(db): TenantDb,
62    TenantSearch(search): TenantSearch,
63    TenantEvents(events): TenantEvents,
64    Path(DocumentBoxScope(scope)): Path<DocumentBoxScope>,
65    Garde(Json(req)): Garde<Json<CreateFolderRequest>>,
66) -> Result<(StatusCode, Json<FolderResponse>), DynHttpError> {
67    let folder_id = req.folder_id;
68    let parent_folder = Folder::find_by_id(&db, &scope, folder_id)
69        .await
70        // Failed to query destination folder
71        .map_err(|error| {
72            tracing::error!(
73                %scope,
74                %folder_id,
75                ?error,
76                "failed to query link destination folder"
77            );
78            HttpCommonError::ServerError
79        })?
80        // Folder not found
81        .ok_or(HttpFolderError::UnknownFolder)?;
82
83    // Update stored editing user data
84    let created_by = action_user.store_user(&db).await?;
85
86    // Make the create query
87    let create = CreateFolderData {
88        folder: parent_folder,
89        name: req.name,
90        created_by: created_by.as_ref().map(|value| value.id.to_string()),
91    };
92
93    // Perform Folder creation
94    let folder = safe_create_folder(&db, search, &events, create)
95        .await
96        .map_err(|error| {
97            tracing::error!(?error, "failed to create link");
98            HttpFolderError::CreateError(error)
99        })?;
100
101    Ok((
102        StatusCode::CREATED,
103        Json(FolderResponse {
104            folder: FolderWithExtra {
105                folder,
106                created_by,
107                last_modified_at: None,
108                last_modified_by: None,
109            },
110            children: ResolvedFolderWithExtra::default(),
111        }),
112    ))
113}
114
115/// Get folder by ID
116///
117/// Requests a specific folder by ID. Will return the folder itself
118/// as well as the first resolved set of children for the folder
119#[utoipa::path(
120    get,
121    operation_id = "folder_get",
122    tag = FOLDER_TAG,
123    path = "/box/{scope}/folder/{folder_id}",
124    responses(
125        (status = 200, description = "Folder obtained successfully", body = FolderResponse),
126        (status = 404, description = "Folder not found", body = HttpErrorResponse),
127        (status = 500, description = "Internal server error", body = HttpErrorResponse)
128    ),
129    params(
130        ("scope" = DocumentBoxScope, Path, description = "Scope the folder resides within"),
131        ("folder_id" = Uuid, Path, description = "ID of the folder to request"),
132        TenantParams
133    )
134)]
135#[tracing::instrument(skip_all, fields(%scope, %folder_id))]
136pub async fn get(
137    TenantDb(db): TenantDb,
138    Path((scope, folder_id)): Path<(DocumentBoxScope, FolderId)>,
139) -> HttpResult<FolderResponse> {
140    let DocumentBoxScope(scope) = scope;
141
142    let WithFullPath {
143        data: folder,
144        full_path,
145    } = Folder::find_by_id_with_extra(&db, &scope, folder_id)
146        .await
147        // Failed to query folder
148        .map_err(|error| {
149            tracing::error!(?error, "failed to query folder");
150            HttpCommonError::ServerError
151        })?
152        // Folder not found
153        .ok_or(HttpFolderError::UnknownFolder)?;
154
155    let children = ResolvedFolderWithExtra::resolve(&db, folder.folder.id, full_path)
156        .await
157        .map_err(|error| {
158            tracing::error!(?error, "failed to resolve folder children");
159            HttpCommonError::ServerError
160        })?;
161
162    Ok(Json(FolderResponse { folder, children }))
163}
164
165/// Get folder edit history
166///
167/// Request the edit history for the provided folder
168#[utoipa::path(
169    get,
170    operation_id = "folder_edit_history",
171    tag = FOLDER_TAG,
172    path = "/box/{scope}/folder/{folder_id}/edit-history",
173    responses(
174        (status = 200, description = "Obtained edit history", body = [EditHistory]),
175        (status = 404, description = "Folder not found", body = HttpErrorResponse),
176        (status = 500, description = "Internal server error", body = HttpErrorResponse)
177    ),
178    params(
179        ("scope" = DocumentBoxScope, Path, description = "Scope the folder resides within"),
180        ("folder_id" = Uuid, Path, description = "ID of the folder to request"),
181        TenantParams
182    )
183)]
184#[tracing::instrument(skip_all, fields(%scope, %folder_id))]
185pub async fn get_edit_history(
186    TenantDb(db): TenantDb,
187    Path((scope, folder_id)): Path<(DocumentBoxScope, FolderId)>,
188) -> HttpResult<Vec<EditHistory>> {
189    let DocumentBoxScope(scope) = scope;
190
191    _ = Folder::find_by_id(&db, &scope, folder_id)
192        .await
193        // Failed to query folder
194        .map_err(|error| {
195            tracing::error!(?error, "failed to query folder");
196            HttpCommonError::ServerError
197        })?
198        // Folder not found
199        .ok_or(HttpFolderError::UnknownFolder)?;
200
201    let edit_history = EditHistory::all_by_folder(&db, folder_id)
202        .await
203        .map_err(|error| {
204            tracing::error!(?error, "failed to query folder edit history");
205            HttpCommonError::ServerError
206        })?;
207
208    Ok(Json(edit_history))
209}
210
211/// Update folder
212///
213/// Updates a folder, can be a name change, a folder move, or both
214#[utoipa::path(
215    put,
216    operation_id = "folder_update",
217    tag = FOLDER_TAG,
218    path = "/box/{scope}/folder/{folder_id}",
219    responses(
220        (status = 200, description = "Updated folder successfully"),
221        (status = 400, description = "Attempted to move a root folder or a folder into itself", body = HttpErrorResponse),
222        (status = 404, description = "Folder not found", body = HttpErrorResponse),
223        (status = 500, description = "Internal server error", body = HttpErrorResponse)
224    ),
225    params(
226        ("scope" = DocumentBoxScope, Path, description = "Scope the folder resides within"),
227        ("folder_id" = Uuid, Path, description = "ID of the folder to request"),
228        TenantParams,
229        UserParams
230    )
231)]
232#[tracing::instrument(skip_all, fields(%scope, %folder_id, ?req))]
233pub async fn update(
234    action_user: ActionUser,
235    TenantDb(db): TenantDb,
236    TenantSearch(search): TenantSearch,
237    Path((scope, folder_id)): Path<(DocumentBoxScope, FolderId)>,
238    Garde(Json(req)): Garde<Json<UpdateFolderRequest>>,
239) -> HttpStatusResult {
240    let DocumentBoxScope(scope) = scope;
241
242    let folder = Folder::find_by_id(&db, &scope, folder_id)
243        .await
244        // Failed to query folder
245        .map_err(|error| {
246            tracing::error!(%scope, %folder_id, ?error, "failed to query folder");
247            HttpCommonError::ServerError
248        })?
249        // Folder not found
250        .ok_or(HttpFolderError::UnknownFolder)?;
251
252    // Update stored editing user data
253    let user = action_user.store_user(&db).await?;
254    let user_id = user.as_ref().map(|value| value.id.to_string());
255
256    let update = UpdateFolder {
257        folder_id: req.folder_id,
258        name: req.name,
259        pinned: req.pinned,
260    };
261
262    docbox_core::folders::update_folder::update_folder(
263        &db, &search, &scope, folder, user_id, update,
264    )
265    .await
266    .map_err(|error| match error {
267        UpdateFolderError::UnknownTargetFolder => HttpFolderError::UnknownTargetFolder.into(),
268        UpdateFolderError::CannotModifyRoot => HttpFolderError::CannotModifyRoot.into(),
269        UpdateFolderError::CannotMoveIntoSelf => HttpFolderError::CannotMoveIntoSelf.into(),
270        _ => DynHttpError::from(HttpCommonError::ServerError),
271    })?;
272
273    Ok(StatusCode::OK)
274}
275
276/// Delete a folder by ID
277///
278/// Deletes a document box folder and all its contents. This will
279/// traverse the folder contents as a stack deleting all files and
280/// folders within the folder before deleting itself
281#[utoipa::path(
282    delete,
283    operation_id = "folder_delete",
284    tag = FOLDER_TAG,
285    path = "/box/{scope}/folder/{folder_id}",
286    responses(
287        (status = 204, description = "Deleted folder successfully"),
288        (status = 404, description = "Folder not found", body = HttpErrorResponse),
289        (status = 500, description = "Internal server error", body = HttpErrorResponse)
290    ),
291    params(
292        ("scope" = DocumentBoxScope, Path, description = "Scope the folder resides within"),
293        ("folder_id" = Uuid, Path, description = "ID of the folder to delete"),
294        TenantParams
295    )
296)]
297#[tracing::instrument(skip_all, fields(%scope, %folder_id))]
298pub async fn delete(
299    TenantDb(db): TenantDb,
300    TenantStorage(storage): TenantStorage,
301    TenantEvents(events): TenantEvents,
302    TenantSearch(search): TenantSearch,
303    Path((scope, folder_id)): Path<(DocumentBoxScope, FolderId)>,
304) -> HttpStatusResult {
305    let DocumentBoxScope(scope) = scope;
306
307    let folder = Folder::find_by_id(&db, &scope, folder_id)
308        .await
309        // Failed to query folder
310        .map_err(|error| {
311            tracing::error!(?error, "failed to query folder");
312            HttpCommonError::ServerError
313        })?
314        // Folder not found
315        .ok_or(HttpFolderError::UnknownFolder)?;
316
317    // Root folder cannot be deleted through the API
318    if folder.folder_id.is_none() {
319        return Err(HttpFolderError::CannotDeleteRoot.into());
320    }
321
322    delete_folder(&db, &storage, &search, &events, folder)
323        .await
324        .map_err(|error| {
325            tracing::error!(?error, "failed to delete folder");
326            HttpCommonError::ServerError
327        })?;
328
329    Ok(StatusCode::NO_CONTENT)
330}
331
332/// Create a folder ZIP
333///
334/// Create a ZIP file from the contents of a folder
335#[utoipa::path(
336    post,
337    operation_id = "folder_create_zip",
338    tag = FOLDER_TAG,
339    path = "/box/{scope}/folder/{folder_id}/zip",
340    responses(
341        (status = 204, description = "Deleted folder successfully"),
342        (status = 404, description = "Folder not found", body = HttpErrorResponse),
343        (status = 500, description = "Internal server error", body = HttpErrorResponse)
344    ),
345    params(
346        ("scope" = DocumentBoxScope, Path, description = "Scope the folder resides within"),
347        ("folder_id" = Uuid, Path, description = "ID of the folder to delete"),
348        TenantParams
349    )
350)]
351#[tracing::instrument(skip_all, fields(%scope, %folder_id, ?req))]
352pub async fn create_zip(
353    TenantDb(db): TenantDb,
354    TenantStorage(storage): TenantStorage,
355    Path((scope, folder_id)): Path<(DocumentBoxScope, FolderId)>,
356    Garde(Json(req)): Garde<Json<ZipFolderRequest>>,
357) -> HttpResult<UploadTaskResponse> {
358    let DocumentBoxScope(scope) = scope;
359
360    let folder = Folder::find_by_id(&db, &scope, folder_id)
361        .await
362        // Failed to query folder
363        .map_err(|error| {
364            tracing::error!(?error, "failed to query folder");
365            HttpCommonError::ServerError
366        })?
367        // Folder not found
368        .ok_or(HttpFolderError::UnknownFolder)?;
369
370    let options = CreateFolderZipOptions {
371        include: req.include,
372        exclude: req.exclude,
373    };
374
375    let span = tracing::Span::current();
376
377    // Spawn background task
378    let (task_id, created_at) = background_task(
379        db.clone(),
380        scope.clone(),
381        async move {
382            let result = create_folder_zip(&db, &storage, &folder, options)
383                .await
384                .map_err(|error| {
385                    tracing::error!(?error, "failed to upload file");
386                    DynHttpError::from(HttpFolderError::CreateZipFile)
387                })
388                // Serialize the response for storage
389                .and_then(|value| {
390                    serde_json::to_value(&value).map_err(|error| {
391                        tracing::error!(?error, "failed to serialize upload task outcome");
392                        DynHttpError::from(HttpCommonError::ServerError)
393                    })
394                });
395
396            match result {
397                Ok(value) => (TaskStatus::Completed, value),
398                Err(error) => (
399                    TaskStatus::Failed,
400                    serde_json::json!({ "error": error.to_string() }),
401                ),
402            }
403        }
404        // Ensure the logging span is passed onto the background task so that
405        // logging context continues
406        .instrument(span),
407    )
408    .await
409    .map_err(|error| {
410        tracing::error!(?error, "failed to create background task");
411        HttpCommonError::ServerError
412    })?;
413
414    Ok(Json(UploadTaskResponse {
415        task_id,
416        created_at,
417    }))
418}