Skip to main content

docbox_database/models/
folder.rs

1use super::{
2    document_box::DocumentBoxScopeRaw,
3    file::{File, FileWithExtra},
4    link::{Link, LinkWithExtra},
5    user::{User, UserId},
6};
7use crate::{
8    DbExecutor, DbPool, DbResult,
9    models::shared::{CountResult, DocboxInputPair, FolderPathSegment, WithFullPath},
10};
11use chrono::{DateTime, Utc};
12use serde::Serialize;
13use sqlx::{postgres::PgQueryResult, prelude::FromRow};
14use tokio::try_join;
15use utoipa::ToSchema;
16use uuid::Uuid;
17
18pub type FolderId = Uuid;
19
20/// Folder with all the children resolved
21#[derive(Debug, Default, Serialize)]
22pub struct ResolvedFolder {
23    /// List of folders within the folder
24    pub folders: Vec<Folder>,
25    /// List of files within the folder
26    pub files: Vec<File>,
27    /// List of links within the folder
28    pub links: Vec<Link>,
29}
30
31impl ResolvedFolder {
32    pub async fn resolve(db: &DbPool, folder_id: FolderId) -> DbResult<ResolvedFolder> {
33        let files_futures = File::find_by_parent(db, folder_id);
34        let folders_future = Folder::find_by_parent(db, folder_id);
35        let links_future = Link::find_by_parent(db, folder_id);
36
37        let (files, folders, links) = try_join!(files_futures, folders_future, links_future)?;
38
39        Ok(ResolvedFolder {
40            folders,
41            files,
42            links,
43        })
44    }
45}
46
47/// Folder with all the children resolved, children also
48/// resolve the user and last modified data
49#[derive(Debug, Default, Serialize, ToSchema)]
50pub struct ResolvedFolderWithExtra {
51    /// Path to the resolved folder
52    pub path: Vec<FolderPathSegment>,
53    /// List of folders within the folder
54    pub folders: Vec<FolderWithExtra>,
55    /// List of files within the folder
56    pub files: Vec<FileWithExtra>,
57    /// List of links within the folder
58    pub links: Vec<LinkWithExtra>,
59}
60
61impl ResolvedFolderWithExtra {
62    pub async fn resolve(
63        db: &DbPool,
64        folder_id: FolderId,
65        path: Vec<FolderPathSegment>,
66    ) -> DbResult<ResolvedFolderWithExtra> {
67        let files_futures = File::find_by_parent_folder_with_extra(db, folder_id);
68        let folders_future = Folder::find_by_parent_with_extra(db, folder_id);
69        let links_future = Link::find_by_parent_with_extra(db, folder_id);
70
71        let (files, folders, links) = try_join!(files_futures, folders_future, links_future)?;
72
73        Ok(ResolvedFolderWithExtra {
74            path,
75            folders,
76            files,
77            links,
78        })
79    }
80}
81
82#[derive(Debug, Clone, Serialize, ToSchema, FromRow, sqlx::Type)]
83#[sqlx(type_name = "docbox_folder")]
84pub struct Folder {
85    /// Unique identifier for the folder
86    #[schema(value_type = Uuid)]
87    pub id: FolderId,
88    /// Name of the file
89    pub name: String,
90
91    /// Whether the folder is marked as pinned
92    pub pinned: bool,
93
94    /// ID of the document box the folder belongs to
95    pub document_box: DocumentBoxScopeRaw,
96    /// Parent folder ID if the folder is a child
97    #[schema(value_type = Option<Uuid>)]
98    pub folder_id: Option<FolderId>,
99
100    /// When the folder was created
101    pub created_at: DateTime<Utc>,
102    /// User who created the folder
103    #[serde(skip)]
104    pub created_by: Option<UserId>,
105}
106
107impl Eq for Folder {}
108
109impl PartialEq for Folder {
110    fn eq(&self, other: &Self) -> bool {
111        self.id.eq(&other.id)
112            && self.name.eq(&other.name)
113            && self.pinned.eq(&other.pinned)
114            && self.document_box.eq(&other.document_box)
115            && self.folder_id.eq(&other.folder_id)
116            && self.created_by.eq(&self.created_by)
117            // Reduce precision when checking creation timestamp
118            // (Database does not store the full precision)
119            && self
120                .created_at
121                .timestamp_millis()
122                .eq(&other.created_at.timestamp_millis())
123    }
124}
125
126#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
127pub struct FolderWithExtra {
128    #[serde(flatten)]
129    pub folder: Folder,
130    #[schema(nullable, value_type = User)]
131    pub created_by: Option<User>,
132    #[schema(nullable, value_type = User)]
133    pub last_modified_by: Option<User>,
134    /// Last time the folder was modified
135    pub last_modified_at: Option<DateTime<Utc>>,
136}
137
138#[derive(Debug, Clone, Default)]
139pub struct CreateFolder {
140    pub name: String,
141    pub document_box: DocumentBoxScopeRaw,
142    pub folder_id: Option<FolderId>,
143    pub created_by: Option<UserId>,
144}
145
146#[derive(Debug, Serialize)]
147pub struct FolderChildrenCount {
148    pub file_count: i64,
149    pub link_count: i64,
150    pub folder_count: i64,
151}
152
153impl Folder {
154    pub async fn create(
155        db: impl DbExecutor<'_>,
156        CreateFolder {
157            name,
158            document_box,
159            folder_id,
160            created_by,
161        }: CreateFolder,
162    ) -> DbResult<Folder> {
163        let folder = Folder {
164            id: Uuid::new_v4(),
165            name,
166            document_box,
167            folder_id,
168            created_by,
169            created_at: Utc::now(),
170            pinned: false,
171        };
172
173        sqlx::query(
174            r#"
175            INSERT INTO "docbox_folders" (
176                "id", "name", "document_box",  "folder_id",
177                "created_by", "created_at"
178            )
179            VALUES ($1, $2, $3, $4, $5, $6)
180        "#,
181        )
182        .bind(folder.id)
183        .bind(folder.name.as_str())
184        .bind(folder.document_box.as_str())
185        .bind(folder.folder_id)
186        .bind(folder.created_by.as_ref())
187        .bind(folder.created_at)
188        .bind(folder.pinned)
189        .execute(db)
190        .await?;
191
192        Ok(folder)
193    }
194
195    /// Collects the IDs of all child folders within the current folder
196    ///
197    /// Results are passed to the search engine when searching within a
198    /// specific folder to only get results from the folder subtree
199    pub async fn tree_all_children(&self, db: impl DbExecutor<'_>) -> DbResult<Vec<FolderId>> {
200        #[derive(FromRow)]
201        struct TempIdRow {
202            id: FolderId,
203        }
204
205        let results: Vec<TempIdRow> =
206            sqlx::query_as(r#"SELECT "id" FROM recursive_folder_children_ids($1)"#)
207                .bind(self.id)
208                .fetch_all(db)
209                .await?;
210
211        Ok(results.into_iter().map(|value| value.id).collect())
212    }
213
214    /// Uses a recursive query to count all the children in the provided
215    /// folder
216    pub async fn count_children(
217        db: impl DbExecutor<'_>,
218        folder_id: FolderId,
219    ) -> DbResult<FolderChildrenCount> {
220        let (file_count, link_count, folder_count): (i64, i64, i64) =
221            sqlx::query_as(r#"SELECT * FROM count_folder_children($1) AS "counts""#)
222                .bind(folder_id)
223                .fetch_one(db)
224                .await?;
225
226        Ok(FolderChildrenCount {
227            file_count,
228            link_count,
229            folder_count,
230        })
231    }
232
233    /// Collects the IDs and names of all parent folders of the
234    /// provided folder
235    pub async fn resolve_path(
236        db: impl DbExecutor<'_>,
237        folder_id: FolderId,
238    ) -> DbResult<Vec<FolderPathSegment>> {
239        sqlx::query_as(r#"SELECT "id", "name" FROM resolve_folder_path($1)"#)
240            .bind(folder_id)
241            .fetch_all(db)
242            .await
243    }
244
245    pub async fn move_to_folder(
246        mut self,
247        db: impl DbExecutor<'_>,
248        folder_id: FolderId,
249    ) -> DbResult<Folder> {
250        // Should never try moving a root folder
251        debug_assert!(self.folder_id.is_some());
252
253        sqlx::query(r#"UPDATE "docbox_folders" SET "folder_id" = $1 WHERE "id" = $2"#)
254            .bind(folder_id)
255            .bind(self.id)
256            .execute(db)
257            .await?;
258
259        self.folder_id = Some(folder_id);
260
261        Ok(self)
262    }
263
264    pub async fn rename(mut self, db: impl DbExecutor<'_>, name: String) -> DbResult<Folder> {
265        sqlx::query(r#"UPDATE "docbox_folders" SET "name" = $1 WHERE "id" = $2"#)
266            .bind(name.as_str())
267            .bind(self.id)
268            .execute(db)
269            .await?;
270
271        self.name = name;
272
273        Ok(self)
274    }
275
276    pub async fn set_pinned(mut self, db: impl DbExecutor<'_>, pinned: bool) -> DbResult<Folder> {
277        sqlx::query(r#"UPDATE "docbox_folders" SET "pinned" = $1 WHERE "id" = $2"#)
278            .bind(pinned)
279            .bind(self.id)
280            .execute(db)
281            .await?;
282
283        self.pinned = pinned;
284
285        Ok(self)
286    }
287
288    pub async fn find_by_id(
289        db: impl DbExecutor<'_>,
290        scope: &DocumentBoxScopeRaw,
291        id: FolderId,
292    ) -> DbResult<Option<Folder>> {
293        sqlx::query_as(r#"SELECT * FROM "docbox_folders" WHERE "id" = $1 AND "document_box" = $2"#)
294            .bind(id)
295            .bind(scope)
296            .fetch_optional(db)
297            .await
298    }
299
300    /// Get all folders and sub folder across any scope in a paginated fashion
301    /// (Ignores roots of document boxes)
302    pub async fn all_non_root(
303        db: impl DbExecutor<'_>,
304        offset: u64,
305        page_size: u64,
306    ) -> DbResult<Vec<Folder>> {
307        sqlx::query_as(
308            r#"
309            SELECT * FROM "docbox_folders"
310            WHERE "folder_id" IS NOT NULL
311            ORDER BY "created_at" ASC
312            OFFSET $1
313            LIMIT $2
314        "#,
315        )
316        .bind(offset as i64)
317        .bind(page_size as i64)
318        .fetch_all(db)
319        .await
320    }
321
322    pub async fn find_by_parent(
323        db: impl DbExecutor<'_>,
324        parent_id: FolderId,
325    ) -> DbResult<Vec<Folder>> {
326        sqlx::query_as(r#"SELECT * FROM "docbox_folders" WHERE "folder_id" = $1"#)
327            .bind(parent_id)
328            .fetch_all(db)
329            .await
330    }
331
332    pub async fn find_root(
333        db: impl DbExecutor<'_>,
334        document_box: &DocumentBoxScopeRaw,
335    ) -> DbResult<Option<Folder>> {
336        sqlx::query_as(
337            r#"SELECT * FROM "docbox_folders" WHERE "document_box" = $1 AND "folder_id" IS NULL"#,
338        )
339        .bind(document_box)
340        .fetch_optional(db)
341        .await
342    }
343
344    /// Deletes the folder
345    pub async fn delete(&self, db: impl DbExecutor<'_>) -> DbResult<PgQueryResult> {
346        sqlx::query(r#"DELETE FROM "docbox_folders" WHERE "id" = $1"#)
347            .bind(self.id)
348            .execute(db)
349            .await
350    }
351
352    /// Finds a collection of folders that are in various document box scopes, resolves
353    /// both the folders themselves and the folder path to traverse to get to each folder
354    pub async fn resolve_with_extra_mixed_scopes(
355        db: impl DbExecutor<'_>,
356        folders_scope_with_id: Vec<DocboxInputPair<'_>>,
357    ) -> DbResult<Vec<WithFullPath<FolderWithExtra>>> {
358        if folders_scope_with_id.is_empty() {
359            return Ok(Vec::new());
360        }
361
362        sqlx::query_as(r#"SELECT * FROM resolve_folders_with_extra_mixed_scopes($1)"#)
363            .bind(folders_scope_with_id)
364            .fetch_all(db)
365            .await
366    }
367
368    /// Finds a collection of folders that are all within the same document box, resolves
369    /// both the folders themselves and the folder path to traverse to get to each folder
370    pub async fn resolve_with_extra(
371        db: impl DbExecutor<'_>,
372        scope: &DocumentBoxScopeRaw,
373        folder_ids: Vec<Uuid>,
374    ) -> DbResult<Vec<WithFullPath<FolderWithExtra>>> {
375        if folder_ids.is_empty() {
376            return Ok(Vec::new());
377        }
378
379        sqlx::query_as(r#"SELECT * FROM resolve_folders_with_extra($1, $2)"#)
380            .bind(scope)
381            .bind(folder_ids)
382            .fetch_all(db)
383            .await
384    }
385
386    pub async fn find_by_id_with_extra(
387        db: impl DbExecutor<'_>,
388        scope: &DocumentBoxScopeRaw,
389        id: FolderId,
390    ) -> DbResult<Option<WithFullPath<FolderWithExtra>>> {
391        sqlx::query_as(r#"SELECT * FROM resolve_folder_by_id_with_extra($1, $2)"#)
392            .bind(scope)
393            .bind(id)
394            .fetch_optional(db)
395            .await
396    }
397
398    pub async fn find_by_parent_with_extra(
399        db: impl DbExecutor<'_>,
400        parent_id: FolderId,
401    ) -> DbResult<Vec<FolderWithExtra>> {
402        sqlx::query_as(r#"SELECT * FROM resolve_folder_by_parent_with_extra($1)"#)
403            .bind(parent_id)
404            .fetch_all(db)
405            .await
406    }
407
408    pub async fn find_root_with_extra(
409        db: impl DbExecutor<'_>,
410        document_box: &DocumentBoxScopeRaw,
411    ) -> DbResult<Option<WithFullPath<FolderWithExtra>>> {
412        sqlx::query_as(r#"SELECT * FROM resolve_root_folder_with_extra($1)"#)
413            .bind(document_box)
414            .fetch_optional(db)
415            .await
416    }
417
418    /// Get the total number of folders in the tenant
419    pub async fn total_count(db: impl DbExecutor<'_>) -> DbResult<i64> {
420        let count_result: CountResult =
421            sqlx::query_as(r#"SELECT COUNT(*) AS "count" FROM "docbox_folders""#)
422                .fetch_one(db)
423                .await?;
424
425        Ok(count_result.count)
426    }
427}