docbox_database/models/
file.rs

1use chrono::{DateTime, Utc};
2use serde::Serialize;
3use sqlx::{postgres::PgQueryResult, prelude::FromRow};
4use utoipa::ToSchema;
5use uuid::Uuid;
6
7use super::{
8    document_box::DocumentBoxScopeRaw,
9    folder::FolderId,
10    user::{User, UserId},
11};
12use crate::{
13    DbExecutor, DbResult,
14    models::{
15        document_box::DocumentBoxScopeRawRef,
16        shared::{
17            CountResult, DocboxInputPair, FolderPathSegment, TotalSizeResult, WithFullPath,
18            WithFullPathScope,
19        },
20    },
21};
22
23pub type FileId = Uuid;
24
25#[derive(Debug, Clone, FromRow, Serialize, sqlx::Type, ToSchema)]
26#[sqlx(type_name = "docbox_file")]
27pub struct File {
28    /// Unique identifier for the file
29    #[schema(value_type = Uuid)]
30    pub id: FileId,
31    /// Name of the file
32    pub name: String,
33    /// Mime type of the file content
34    pub mime: String,
35    /// Parent folder ID
36    #[schema(value_type = Uuid)]
37    pub folder_id: FolderId,
38    /// Optional parent file ID if the file is a child of
39    /// some other file (i.e attachment for an email file)
40    #[schema(value_type = Option<Uuid>)]
41    pub parent_id: Option<FileId>,
42    /// Hash of the file bytes stored in S3
43    pub hash: String,
44    /// Size of the file in bytes
45    pub size: i32,
46    /// Whether the file was determined to be encrypted when processing
47    pub encrypted: bool,
48    /// Whether the file is marked as pinned
49    pub pinned: bool,
50    /// S3 key pointing to the file
51    #[serde(skip)]
52    pub file_key: String,
53    /// When the file was created
54    pub created_at: DateTime<Utc>,
55    /// User who created the file
56    #[serde(skip)]
57    pub created_by: Option<UserId>,
58}
59
60impl Eq for File {}
61
62impl PartialEq for File {
63    fn eq(&self, other: &Self) -> bool {
64        self.id.eq(&other.id)
65            && self.name.eq(&other.name)
66            && self.mime.eq(&other.mime)
67            && self.folder_id.eq(&other.folder_id)
68            && self.parent_id.eq(&other.parent_id)
69            && self.hash.eq(&other.hash)
70            && self.size.eq(&other.size)
71            && self.encrypted.eq(&other.encrypted)
72            && self.pinned.eq(&other.pinned)
73            && self.file_key.eq(&other.file_key)
74            && self.created_by.eq(&self.created_by)
75            // Reduce precision when checking creation timestamp
76            // (Database does not store the full precision)
77            && self
78                .created_at
79                .timestamp_millis()
80                .eq(&other.created_at.timestamp_millis())
81    }
82}
83
84#[derive(Debug, FromRow, Serialize)]
85pub struct FileWithScope {
86    #[sqlx(flatten)]
87    pub file: File,
88    pub scope: String,
89}
90
91/// File with the resolved creator and last modified data
92#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
93pub struct FileWithExtra {
94    #[serde(flatten)]
95    pub file: File,
96    #[schema(nullable, value_type = User)]
97    pub created_by: Option<User>,
98    #[schema(nullable, value_type = User)]
99    pub last_modified_by: Option<User>,
100    /// Last time the file was modified
101    pub last_modified_at: Option<DateTime<Utc>>,
102}
103
104/// File with extra with an additional resolved full path
105#[derive(Debug, FromRow, Serialize, ToSchema)]
106pub struct ResolvedFileWithExtra {
107    #[serde(flatten)]
108    #[sqlx(flatten)]
109    pub file: FileWithExtra,
110    pub full_path: Vec<FolderPathSegment>,
111}
112
113#[derive(Debug, Default)]
114pub struct CreateFile {
115    /// ID for the file to use
116    pub id: FileId,
117
118    /// Optional parent file if the file was created
119    /// as the result of another file (i.e. email attachments)
120    pub parent_id: Option<FileId>,
121
122    pub name: String,
123    pub mime: String,
124    pub folder_id: FolderId,
125    pub hash: String,
126    pub size: i32,
127    pub file_key: String,
128    pub created_by: Option<UserId>,
129    pub created_at: DateTime<Utc>,
130    pub encrypted: bool,
131}
132
133impl File {
134    pub async fn create(
135        db: impl DbExecutor<'_>,
136        CreateFile {
137            id,
138            parent_id,
139            name,
140            mime,
141            folder_id,
142            hash,
143            size,
144            file_key,
145            created_by,
146            created_at,
147            encrypted,
148        }: CreateFile,
149    ) -> DbResult<File> {
150        sqlx::query(
151            r#"INSERT INTO "docbox_files" (
152                    "id", "name", "mime", "folder_id", "hash", "size",
153                    "encrypted", "file_key", "created_by", "created_at",
154                    "parent_id"
155                )
156                VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
157                "#,
158        )
159        .bind(id)
160        .bind(name.as_str())
161        .bind(mime.as_str())
162        .bind(folder_id)
163        .bind(hash.as_str())
164        .bind(size)
165        .bind(encrypted)
166        .bind(file_key.as_str())
167        .bind(created_by.as_ref())
168        .bind(created_at)
169        .bind(parent_id)
170        .execute(db)
171        .await?;
172
173        Ok(File {
174            id,
175            name,
176            mime,
177            folder_id,
178            hash,
179            size,
180            encrypted,
181            file_key,
182            created_by,
183            created_at,
184            parent_id,
185            pinned: false,
186        })
187    }
188
189    pub async fn all(
190        db: impl DbExecutor<'_>,
191        offset: u64,
192        page_size: u64,
193    ) -> DbResult<Vec<FileWithScope>> {
194        sqlx::query_as(
195            r#"
196            SELECT
197            "file".*,
198            "folder"."document_box" AS "scope"
199            FROM "docbox_files" "file"
200            INNER JOIN "docbox_folders" "folder" ON "file"."folder_id" = "folder"."id"
201            ORDER BY "created_at" ASC
202            OFFSET $1
203            LIMIT $2
204        "#,
205        )
206        .bind(offset as i64)
207        .bind(page_size as i64)
208        .fetch_all(db)
209        .await
210    }
211
212    pub async fn move_to_folder(
213        mut self,
214        db: impl DbExecutor<'_>,
215        folder_id: FolderId,
216    ) -> DbResult<File> {
217        sqlx::query(r#"UPDATE "docbox_files" SET "folder_id" = $1 WHERE "id" = $2"#)
218            .bind(folder_id)
219            .bind(self.id)
220            .execute(db)
221            .await?;
222
223        self.folder_id = folder_id;
224
225        Ok(self)
226    }
227
228    pub async fn rename(mut self, db: impl DbExecutor<'_>, name: String) -> DbResult<File> {
229        sqlx::query(r#"UPDATE "docbox_files" SET "name" = $1 WHERE "id" = $2"#)
230            .bind(name.as_str())
231            .bind(self.id)
232            .execute(db)
233            .await?;
234
235        self.name = name;
236
237        Ok(self)
238    }
239
240    /// Updates the pinned state of the file
241    pub async fn set_pinned(mut self, db: impl DbExecutor<'_>, pinned: bool) -> DbResult<File> {
242        sqlx::query(r#"UPDATE "docbox_files" SET "pinned" = $1 WHERE "id" = $2"#)
243            .bind(pinned)
244            .bind(self.id)
245            .execute(db)
246            .await?;
247
248        self.pinned = pinned;
249
250        Ok(self)
251    }
252
253    /// Updates the encryption state of the file
254    pub async fn set_encrypted(
255        mut self,
256        db: impl DbExecutor<'_>,
257        encrypted: bool,
258    ) -> DbResult<File> {
259        sqlx::query(r#"UPDATE "docbox_files" SET "encrypted" = $1 WHERE "id" = $2"#)
260            .bind(encrypted)
261            .bind(self.id)
262            .execute(db)
263            .await?;
264
265        self.encrypted = encrypted;
266
267        Ok(self)
268    }
269
270    /// Updates the mime type of a file
271    pub async fn set_mime(mut self, db: impl DbExecutor<'_>, mime: String) -> DbResult<File> {
272        sqlx::query(r#"UPDATE "docbox_files" SET "mime" = $1 WHERE "id" = $2"#)
273            .bind(&mime)
274            .bind(self.id)
275            .execute(db)
276            .await?;
277
278        self.mime = mime;
279
280        Ok(self)
281    }
282
283    pub async fn all_by_mime(
284        db: impl DbExecutor<'_>,
285        mime: &str,
286        offset: u64,
287        page_size: u64,
288    ) -> DbResult<Vec<FileWithScope>> {
289        sqlx::query_as(
290            r#"
291            SELECT
292            "file".*,
293            "folder"."document_box" AS "scope"
294            FROM "docbox_files" "file"
295            INNER JOIN "docbox_folders" "folder" ON "file"."folder_id" = "folder"."id"
296            WHERE "file"."mime" = $1
297            ORDER BY "created_at" ASC
298            OFFSET $2
299            LIMIT $3
300        "#,
301        )
302        .bind(mime)
303        .bind(offset as i64)
304        .bind(page_size as i64)
305        .fetch_all(db)
306        .await
307    }
308
309    pub async fn all_by_mimes(
310        db: impl DbExecutor<'_>,
311        mimes: &[&str],
312        offset: u64,
313        page_size: u64,
314    ) -> DbResult<Vec<FileWithScope>> {
315        sqlx::query_as(
316            r#"
317            SELECT
318                "file".*,
319                "folder"."document_box" AS "scope"
320            FROM "docbox_files" AS "file"
321            INNER JOIN "docbox_folders" "folder" ON "file"."folder_id" = "folder"."id"
322            WHERE "mime" = ANY($1) AND "file"."encrypted" = FALSE
323            ORDER BY "file"."created_at" ASC
324            OFFSET $2
325            LIMIT $3
326        "#,
327        )
328        .bind(mimes)
329        .bind(offset as i64)
330        .bind(page_size as i64)
331        .fetch_all(db)
332        .await
333    }
334
335    /// Finds a specific file using its full path scope -> folder -> file
336    pub async fn find(
337        db: impl DbExecutor<'_>,
338        scope: &DocumentBoxScopeRaw,
339        file_id: FileId,
340    ) -> DbResult<Option<File>> {
341        sqlx::query_as(
342            r#"
343            SELECT "file".*
344            FROM "docbox_files" AS "file"
345            INNER JOIN "docbox_folders" "folder" ON "file"."folder_id" = "folder"."id"
346            WHERE "file"."id" = $1 AND "folder"."document_box" = $2
347        "#,
348        )
349        .bind(file_id)
350        .bind(scope)
351        .fetch_optional(db)
352        .await
353    }
354
355    /// Collects the IDs and names of all parent folders of the
356    /// provided folder
357    pub async fn resolve_path(
358        db: impl DbExecutor<'_>,
359        file_id: FileId,
360    ) -> DbResult<Vec<FolderPathSegment>> {
361        sqlx::query_as(r#"SELECT "id", "name" FROM resolve_file_path($1)"#)
362            .bind(file_id)
363            .fetch_all(db)
364            .await
365    }
366
367    pub async fn find_by_parent(
368        db: impl DbExecutor<'_>,
369        parent_id: FolderId,
370    ) -> DbResult<Vec<File>> {
371        sqlx::query_as(r#"SELECT * FROM "docbox_files" WHERE "folder_id" = $1"#)
372            .bind(parent_id)
373            .fetch_all(db)
374            .await
375    }
376
377    /// Deletes the file
378    pub async fn delete(&self, db: impl DbExecutor<'_>) -> DbResult<PgQueryResult> {
379        sqlx::query(r#"DELETE FROM "docbox_files" WHERE "id" = $1"#)
380            .bind(self.id)
381            .execute(db)
382            .await
383    }
384
385    /// Finds a collection of files that are all within the same document box, resolves
386    /// both the files themselves and the folder path to traverse to get to each file
387    pub async fn resolve_with_extra(
388        db: impl DbExecutor<'_>,
389        scope: &DocumentBoxScopeRaw,
390        file_ids: Vec<Uuid>,
391    ) -> DbResult<Vec<WithFullPath<FileWithExtra>>> {
392        if file_ids.is_empty() {
393            return Ok(Vec::new());
394        }
395
396        sqlx::query_as(r#"SELECT * FROM resolve_files_with_extra($1, $2)"#)
397            .bind(scope)
398            .bind(file_ids)
399            .fetch_all(db)
400            .await
401    }
402
403    /// Finds a collection of files that are within various document box scopes, resolves
404    /// both the files themselves and the folder path to traverse to get to each file
405    pub async fn resolve_with_extra_mixed_scopes(
406        db: impl DbExecutor<'_>,
407        files_scope_with_id: Vec<DocboxInputPair<'_>>,
408    ) -> DbResult<Vec<WithFullPathScope<FileWithExtra>>> {
409        if files_scope_with_id.is_empty() {
410            return Ok(Vec::new());
411        }
412
413        sqlx::query_as(
414            r#"SELECT * FROM resolve_files_with_extra_mixed_scopes($1::docbox_input_pair[])"#,
415        )
416        .bind(files_scope_with_id)
417        .fetch_all(db)
418        .await
419    }
420
421    /// Finds a specific file using its full path scope -> folder -> file
422    /// fetching the additional details about the file like the creator and
423    /// last modified
424    pub async fn find_with_extra(
425        db: impl DbExecutor<'_>,
426        scope: &DocumentBoxScopeRaw,
427        file_id: FileId,
428    ) -> DbResult<Option<FileWithExtra>> {
429        sqlx::query_as(r#"SELECT * FROM resolve_file_by_id_with_extra($1, $2)"#)
430            .bind(scope)
431            .bind(file_id)
432            .fetch_optional(db)
433            .await
434    }
435
436    pub async fn find_by_parent_folder_with_extra(
437        db: impl DbExecutor<'_>,
438        parent_id: FolderId,
439    ) -> DbResult<Vec<FileWithExtra>> {
440        sqlx::query_as(r#"SELECT * FROM resolve_files_by_parent_folder_with_extra($1)"#)
441            .bind(parent_id)
442            .fetch_all(db)
443            .await
444    }
445
446    pub async fn find_by_parent_file_with_extra(
447        db: impl DbExecutor<'_>,
448        parent_id: FileId,
449    ) -> DbResult<Vec<FileWithExtra>> {
450        sqlx::query_as(r#"SELECT * FROM resolve_files_by_parent_file_with_extra($1)"#)
451            .bind(parent_id)
452            .fetch_all(db)
453            .await
454    }
455
456    /// Get the total number of files in the tenant
457    pub async fn total_count(db: impl DbExecutor<'_>) -> DbResult<i64> {
458        let count_result: CountResult =
459            sqlx::query_as(r#"SELECT COUNT(*) AS "count" FROM "docbox_files""#)
460                .fetch_one(db)
461                .await?;
462
463        Ok(count_result.count)
464    }
465
466    /// Get the total "size" of files within the current tenant, this does not include
467    /// the size of generated files
468    pub async fn total_size(db: impl DbExecutor<'_>) -> DbResult<i64> {
469        let size_result: TotalSizeResult = sqlx::query_as(
470            r#"
471            SELECT COALESCE(SUM("file"."size"), 0) AS "total_size"
472            FROM "docbox_files" "file";
473        "#,
474        )
475        .fetch_one(db)
476        .await?;
477
478        Ok(size_result.total_size)
479    }
480
481    /// Get the total "size" of files within a specific scope, this does not include
482    /// the size of generated files
483    pub async fn total_size_within_scope(
484        db: impl DbExecutor<'_>,
485        scope: DocumentBoxScopeRawRef<'_>,
486    ) -> DbResult<i64> {
487        let size_result: TotalSizeResult = sqlx::query_as(
488            r#"
489            SELECT COALESCE(SUM("file"."size"), 0) AS "total_size"
490            FROM "docbox_files" "file"
491            INNER JOIN "docbox_folders" "folder" ON "file"."folder_id" = "folder"."id"
492            WHERE "folder"."document_box" = $1;
493        "#,
494        )
495        .bind(scope)
496        .fetch_one(db)
497        .await?;
498
499        Ok(size_result.total_size)
500    }
501}