docbox_database/models/
link.rs

1use super::{
2    document_box::DocumentBoxScopeRaw,
3    folder::{FolderId, FolderPathSegment},
4    user::{User, UserId},
5};
6use crate::{
7    DbExecutor, DbResult,
8    models::folder::{WithFullPath, WithFullPathScope},
9};
10use chrono::{DateTime, Utc};
11use serde::Serialize;
12use sqlx::{
13    postgres::{PgQueryResult, PgRow},
14    prelude::FromRow,
15};
16use utoipa::ToSchema;
17use uuid::Uuid;
18
19pub type LinkId = Uuid;
20
21#[derive(Debug, Clone, FromRow, Serialize)]
22pub struct Link {
23    /// Unique identifier for the link
24    pub id: LinkId,
25    /// Name of the link
26    pub name: String,
27    /// value of the link
28    pub value: String,
29    /// Parent folder ID
30    pub folder_id: FolderId,
31    /// When the link was created
32    pub created_at: DateTime<Utc>,
33    /// User who created the link
34    pub created_by: Option<UserId>,
35}
36
37#[derive(Debug, FromRow, Serialize)]
38pub struct LinkWithScope {
39    #[sqlx(flatten)]
40    pub link: Link,
41    pub scope: String,
42}
43
44#[derive(Debug, Clone, Serialize, FromRow, ToSchema)]
45pub struct LinkWithExtra {
46    /// Unique identifier for the link
47    pub id: Uuid,
48    /// Name of the link
49    pub name: String,
50    /// value of the link
51    pub value: String,
52    /// Parent folder ID
53    pub folder_id: Uuid,
54    /// When the link was created
55    pub created_at: DateTime<Utc>,
56    /// User who created the link
57    #[sqlx(flatten)]
58    #[schema(nullable, value_type = User)]
59    pub created_by: CreatedByUser,
60    /// Last time the link was modified
61    pub last_modified_at: Option<DateTime<Utc>>,
62    /// User who last modified the link
63    #[sqlx(flatten)]
64    #[schema(nullable, value_type = User)]
65    pub last_modified_by: LastModifiedByUser,
66}
67
68/// Link with extra with an additional resolved full path
69#[derive(Debug, FromRow, Serialize, ToSchema)]
70pub struct ResolvedLinkWithExtra {
71    #[serde(flatten)]
72    #[sqlx(flatten)]
73    pub link: LinkWithExtra,
74    #[sqlx(json)]
75    pub full_path: Vec<FolderPathSegment>,
76}
77
78/// Wrapper type for extracting a [User] that was joined
79/// from another table where the fields are prefixed with "cb_"
80#[derive(Debug, Clone, Serialize)]
81#[serde(transparent)]
82pub struct CreatedByUser(pub Option<User>);
83
84impl<'r> FromRow<'r, PgRow> for CreatedByUser {
85    fn from_row(row: &'r PgRow) -> Result<Self, sqlx::Error> {
86        use sqlx::Row;
87
88        let id: Option<UserId> = row.try_get("cb_id")?;
89        if let Some(id) = id {
90            let name: Option<String> = row.try_get("cb_name")?;
91            let image_id: Option<String> = row.try_get("cb_image_id")?;
92            return Ok(CreatedByUser(Some(User { id, name, image_id })));
93        }
94
95        Ok(CreatedByUser(None))
96    }
97}
98
99/// Wrapper type for extracting a [User] that was joined
100/// from another table where the fields are prefixed with "lmb_"
101#[derive(Debug, Clone, Serialize)]
102#[serde(transparent)]
103pub struct LastModifiedByUser(pub Option<User>);
104
105impl<'r> FromRow<'r, PgRow> for LastModifiedByUser {
106    fn from_row(row: &'r PgRow) -> Result<Self, sqlx::Error> {
107        use sqlx::Row;
108
109        let id: Option<UserId> = row.try_get("lmb_id")?;
110        if let Some(id) = id {
111            let name: Option<String> = row.try_get("lmb_name")?;
112            let image_id: Option<String> = row.try_get("lmb_image_id")?;
113            return Ok(LastModifiedByUser(Some(User { id, name, image_id })));
114        }
115
116        Ok(LastModifiedByUser(None))
117    }
118}
119
120pub struct CreateLink {
121    pub name: String,
122    pub value: String,
123    pub folder_id: FolderId,
124    pub created_by: Option<UserId>,
125}
126
127impl Link {
128    pub async fn create(
129        db: impl DbExecutor<'_>,
130        CreateLink {
131            name,
132            value,
133            folder_id,
134            created_by,
135        }: CreateLink,
136    ) -> DbResult<Link> {
137        let id = Uuid::new_v4();
138        let created_at = Utc::now();
139
140        sqlx::query(
141            r#"INSERT INTO "docbox_links" (
142                "id",
143                "name",
144                "value",
145                "folder_id",
146                "created_by",
147                "created_at"
148            ) VALUES ($1, $2, $3, $4, $5, $6)
149            "#,
150        )
151        .bind(id)
152        .bind(name.clone())
153        .bind(value.clone())
154        .bind(folder_id)
155        .bind(created_by.as_ref())
156        .bind(created_at)
157        .execute(db)
158        .await?;
159
160        Ok(Link {
161            id,
162            name,
163            value,
164            folder_id,
165            created_by,
166            created_at,
167        })
168    }
169
170    pub async fn move_to_folder(
171        mut self,
172        db: impl DbExecutor<'_>,
173        folder_id: FolderId,
174    ) -> DbResult<Link> {
175        sqlx::query(r#"UPDATE "docbox_links" SET "folder_id" = $1 WHERE "id" = $2"#)
176            .bind(folder_id)
177            .bind(self.id)
178            .execute(db)
179            .await?;
180
181        self.folder_id = folder_id;
182
183        Ok(self)
184    }
185
186    pub async fn rename(mut self, db: impl DbExecutor<'_>, name: String) -> DbResult<Link> {
187        sqlx::query(r#"UPDATE "docbox_links" SET "name" = $1 WHERE "id" = $2"#)
188            .bind(name.as_str())
189            .bind(self.id)
190            .execute(db)
191            .await?;
192
193        self.name = name;
194        Ok(self)
195    }
196
197    pub async fn update_value(mut self, db: impl DbExecutor<'_>, value: String) -> DbResult<Link> {
198        sqlx::query(r#"UPDATE "docbox_links" SET "value" = $1 WHERE "id" = $2"#)
199            .bind(value.as_str())
200            .bind(self.id)
201            .execute(db)
202            .await?;
203
204        self.value = value;
205
206        Ok(self)
207    }
208
209    pub async fn all(
210        db: impl DbExecutor<'_>,
211        offset: u64,
212        page_size: u64,
213    ) -> DbResult<Vec<LinkWithScope>> {
214        sqlx::query_as(
215            r#"
216            SELECT 
217            "link".*,
218            "folder"."document_box" AS "scope"
219            FROM "docbox_links" AS "link"
220            INNER JOIN "docbox_folders" "folder" ON "link"."folder_id" = "folder"."id"
221            ORDER BY "link"."created_at" ASC
222            OFFSET $1
223            LIMIT $2
224        "#,
225        )
226        .bind(offset as i64)
227        .bind(page_size as i64)
228        .fetch_all(db)
229        .await
230    }
231
232    pub async fn find(
233        db: impl DbExecutor<'_>,
234        scope: &DocumentBoxScopeRaw,
235        link_id: LinkId,
236    ) -> DbResult<Option<Link>> {
237        sqlx::query_as(
238            r#"
239            SELECT "link".*
240            FROM "docbox_links" AS "link"
241            INNER JOIN "docbox_folders" "folder" ON "link"."folder_id" = "folder"."id"
242            WHERE "link"."id" = $1 AND "folder"."document_box" = $2
243        "#,
244        )
245        .bind(link_id)
246        .bind(scope)
247        .fetch_optional(db)
248        .await
249    }
250    /// Collects the IDs and names of all parent folders of the
251    /// provided folder
252    pub async fn resolve_path(
253        db: impl DbExecutor<'_>,
254        link_id: LinkId,
255    ) -> DbResult<Vec<FolderPathSegment>> {
256        sqlx::query_as(
257            r#"
258            WITH RECURSIVE "folder_hierarchy" AS (
259                SELECT "id", "name", "folder_id", 0 AS "depth"
260                FROM "docbox_links" 
261                WHERE "docbox_links"."id" = $1 
262                UNION ALL (
263                    SELECT 
264                        "folder"."id", 
265                        "folder"."name", 
266                        "folder"."folder_id", 
267                        "folder_hierarchy"."depth" + 1 as "depth"
268                    FROM "docbox_folders" AS "folder" 
269                    INNER JOIN "folder_hierarchy" ON "folder"."id" = "folder_hierarchy"."folder_id"
270                )
271            ) 
272            CYCLE "id" SET "looped" USING "traversal_path" 
273            SELECT "folder_hierarchy"."id", "folder_hierarchy"."name" 
274            FROM "folder_hierarchy" 
275            WHERE "folder_hierarchy"."id" <> $1
276            ORDER BY "folder_hierarchy"."depth" DESC
277        "#,
278        )
279        .bind(link_id)
280        .fetch_all(db)
281        .await
282    }
283
284    /// Finds all links within the provided parent folder
285    pub async fn find_by_parent(
286        db: impl DbExecutor<'_>,
287        parent_id: FolderId,
288    ) -> DbResult<Vec<Link>> {
289        sqlx::query_as(r#"SELECT * FROM "docbox_links" WHERE "folder_id" = $1"#)
290            .bind(parent_id)
291            .fetch_all(db)
292            .await
293    }
294
295    /// Deletes the link
296    pub async fn delete(&self, db: impl DbExecutor<'_>) -> DbResult<PgQueryResult> {
297        sqlx::query(r#"DELETE FROM "docbox_links" WHERE "id" = $1"#)
298            .bind(self.id)
299            .execute(db)
300            .await
301    }
302
303    /// Finds a collection of links that are within various document box scopes, resolves
304    /// both the links themselves and the folder path to traverse to get to each link
305    pub async fn resolve_with_extra_mixed_scopes(
306        db: impl DbExecutor<'_>,
307        links_scope_with_id: Vec<(DocumentBoxScopeRaw, LinkId)>,
308    ) -> DbResult<Vec<WithFullPathScope<LinkWithExtra>>> {
309        if links_scope_with_id.is_empty() {
310            return Ok(Vec::new());
311        }
312
313        let (scopes, link_ids): (Vec<String>, Vec<LinkId>) =
314            links_scope_with_id.into_iter().unzip();
315
316        sqlx::query_as(
317            r#"
318        -- Recursively resolve the link paths for each link creating a JSON array for the path
319        WITH RECURSIVE 
320            "input_links" AS (
321                SELECT link_id, document_box
322                FROM UNNEST($1::text[], $2::uuid[]) AS t(document_box, link_id)
323            ),
324            "folder_hierarchy" AS (
325                SELECT
326                    "f"."id" AS "link_id",
327                    "folder"."id" AS "folder_id",
328                    "folder"."name" AS "folder_name",
329                    "folder"."folder_id" AS "parent_folder_id",
330                    0 AS "depth",
331                    jsonb_build_array(jsonb_build_object('id', "folder"."id", 'name', "folder"."name")) AS "path"
332                FROM "docbox_links" "f"
333                JOIN "input_links" "i" ON "f"."id" = "i"."link_id"
334                JOIN "docbox_folders" "folder" ON "f"."folder_id" = "folder"."id"
335                WHERE "folder"."document_box" = "i"."document_box"
336                UNION ALL
337                SELECT
338                    "fh"."link_id",
339                    "parent"."id",
340                    "parent"."name",
341                    "parent"."folder_id",
342                    "fh"."depth" + 1,
343                    jsonb_build_array(jsonb_build_object('id', "parent"."id", 'name', "parent"."name")) || "fh"."path"
344                FROM "folder_hierarchy" "fh"
345                JOIN "docbox_folders" "parent" ON "fh"."parent_folder_id" = "parent"."id"
346            ),
347            "folder_paths" AS (
348                SELECT "link_id", "path", ROW_NUMBER() OVER (PARTITION BY "link_id" ORDER BY "depth" DESC) AS "rn"
349                FROM "folder_hierarchy"
350            )
351        SELECT 
352            -- link itself 
353            "link".*,
354            -- Creator user details
355            "cu"."id" AS "cb_id", 
356            "cu"."name" AS "cb_name", 
357            "cu"."image_id" AS "cb_image_id", 
358            -- Last modified date
359            "ehl"."created_at" AS "last_modified_at", 
360            -- Last modified user details
361            "mu"."id" AS "lmb_id",  
362            "mu"."name" AS "lmb_name", 
363            "mu"."image_id" AS "lmb_image_id" ,
364            -- link path from path lookup
365            "fp"."path" AS "full_path",
366            -- Include document box in response
367            "folder"."document_box" AS "document_box" 
368        FROM "docbox_links" AS "link"
369        -- Join on the creator
370        LEFT JOIN "docbox_users" AS "cu" 
371            ON "link"."created_by" = "cu"."id" 
372        -- Join on the parent folder
373        INNER JOIN "docbox_folders" "folder" ON "link"."folder_id" = "folder"."id"
374        -- Join on the edit history (Latest only)
375        LEFT JOIN (
376            -- Get the latest edit history entry
377            SELECT DISTINCT ON ("link_id") "link_id", "user_id", "created_at" 
378            FROM "docbox_edit_history"
379            ORDER BY "link_id", "created_at" DESC 
380        ) AS "ehl" ON "link"."id" = "ehl"."link_id" 
381        -- Join on the editor history latest edit user
382        LEFT JOIN "docbox_users" AS "mu" ON "ehl"."user_id" = "mu"."id" 
383        -- Join on the resolved folder path
384        LEFT JOIN "folder_paths" "fp" ON "link".id = "fp"."link_id" AND "fp".rn = 1
385        -- Join on the input files for filtering
386        JOIN "input_links" "i" ON "link"."id" = "i"."link_id"
387        -- Ensure correct document box
388        WHERE "folder"."document_box" = "i"."document_box""#,
389        )
390        .bind(scopes)
391        .bind(link_ids)
392        .fetch_all(db)
393        .await
394    }
395
396    /// Finds a collection of links that are all within the same document box, resolves
397    /// both the links themselves and the folder path to traverse to get to each link
398    pub async fn resolve_with_extra(
399        db: impl DbExecutor<'_>,
400        scope: &DocumentBoxScopeRaw,
401        link_ids: Vec<Uuid>,
402    ) -> DbResult<Vec<WithFullPath<LinkWithExtra>>> {
403        if link_ids.is_empty() {
404            return Ok(Vec::new());
405        }
406
407        sqlx::query_as(
408            r#"
409        -- Recursively resolve the link paths for each link creating a JSON array for the path
410        WITH RECURSIVE "folder_hierarchy" AS (
411            SELECT
412                "f"."id" AS "link_id",
413                "folder"."id" AS "folder_id",
414                "folder"."name" AS "folder_name",
415                "folder"."folder_id" AS "parent_folder_id",
416                0 AS "depth",
417                jsonb_build_array(jsonb_build_object('id', "folder"."id", 'name', "folder"."name")) AS "path"
418            FROM "docbox_links" "f"
419            JOIN "docbox_folders" "folder" ON "f"."folder_id" = "folder"."id"
420            WHERE "f"."id" = ANY($1::uuid[]) AND "folder"."document_box" = $2
421            UNION ALL
422            SELECT
423                "fh"."link_id",
424                "parent"."id",
425                "parent"."name",
426                "parent"."folder_id",
427                "fh"."depth" + 1,
428                jsonb_build_array(jsonb_build_object('id', "parent"."id", 'name', "parent"."name")) || "fh"."path"
429            FROM "folder_hierarchy" "fh"
430            JOIN "docbox_folders" "parent" ON "fh"."parent_folder_id" = "parent"."id"
431        ),
432        "folder_paths" AS (
433            SELECT "link_id", "path", ROW_NUMBER() OVER (PARTITION BY "link_id" ORDER BY "depth" DESC) AS "rn"
434            FROM "folder_hierarchy"
435        )
436        SELECT 
437            -- link itself 
438            "link".*,
439            -- Creator user details
440            "cu"."id" AS "cb_id", 
441            "cu"."name" AS "cb_name", 
442            "cu"."image_id" AS "cb_image_id", 
443            -- Last modified date
444            "ehl"."created_at" AS "last_modified_at", 
445            -- Last modified user details
446            "mu"."id" AS "lmb_id",  
447            "mu"."name" AS "lmb_name", 
448            "mu"."image_id" AS "lmb_image_id" ,
449            -- link path from path lookup
450            "fp"."path" AS "full_path" 
451        FROM "docbox_links" AS "link"
452        -- Join on the creator
453        LEFT JOIN "docbox_users" AS "cu" 
454            ON "link"."created_by" = "cu"."id" 
455        -- Join on the parent folder
456        INNER JOIN "docbox_folders" "folder" ON "link"."folder_id" = "folder"."id"
457        -- Join on the edit history (Latest only)
458        LEFT JOIN (
459            -- Get the latest edit history entry
460            SELECT DISTINCT ON ("link_id") "link_id", "user_id", "created_at" 
461            FROM "docbox_edit_history"
462            ORDER BY "link_id", "created_at" DESC 
463        ) AS "ehl" ON "link"."id" = "ehl"."link_id" 
464        -- Join on the editor history latest edit user
465        LEFT JOIN "docbox_users" AS "mu" ON "ehl"."user_id" = "mu"."id" 
466        -- Join on the resolved folder path
467        LEFT JOIN "folder_paths" "fp" ON "link".id = "fp"."link_id" AND "fp".rn = 1
468        WHERE "link"."id" = ANY($1::uuid[]) AND "folder"."document_box" = $2"#,
469        )
470        .bind(link_ids)
471        .bind(scope)
472        .fetch_all(db)
473        .await
474    }
475
476    /// Finds all links within the provided parent folder
477    pub async fn find_by_parent_with_extra(
478        db: impl DbExecutor<'_>,
479        parent_id: FolderId,
480    ) -> DbResult<Vec<LinkWithExtra>> {
481        sqlx::query_as(
482            r#"
483        SELECT 
484            -- Link itself details
485            "link".*,
486            -- Creator user details
487            "cu"."id" AS "cb_id", 
488            "cu"."name" AS "cb_name", 
489            "cu"."image_id" AS "cb_image_id", 
490            -- Last modified date
491            "ehl"."created_at" AS "last_modified_at", 
492            -- Last modified user details
493            "mu"."id" AS "lmb_id",  
494            "mu"."name" AS "lmb_name", 
495            "mu"."image_id" AS "lmb_image_id" 
496        FROM "docbox_links" AS "link"
497        -- Join on the creator
498        LEFT JOIN "docbox_users" AS "cu" 
499            ON "link"."created_by" = "cu"."id" 
500        -- Join on the edit history (Latest only)
501        LEFT JOIN LATERAL (
502            -- Get the latest edit history entry
503            SELECT DISTINCT ON ("link_id") "link_id", "user_id", "created_at" 
504            FROM "docbox_edit_history"
505            ORDER BY "link_id", "created_at" DESC 
506        ) AS "ehl" ON "link"."id" = "ehl"."link_id" 
507        -- Join on the editor history latest edit user
508        LEFT JOIN "docbox_users" AS "mu" ON "ehl"."user_id" = "mu"."id" 
509        WHERE "link"."folder_id" = $1"#,
510        )
511        .bind(parent_id)
512        .fetch_all(db)
513        .await
514    }
515
516    pub async fn find_with_extra(
517        db: impl DbExecutor<'_>,
518        scope: &DocumentBoxScopeRaw,
519        link_id: LinkId,
520    ) -> DbResult<Option<LinkWithExtra>> {
521        sqlx::query_as(
522            r#"
523        SELECT 
524            -- Link itself details
525            "link".*,
526            -- Creator user details
527            "cu"."id" AS "cb_id", 
528            "cu"."name" AS "cb_name", 
529            "cu"."image_id" AS "cb_image_id", 
530            -- Last modified date
531            "ehl"."created_at" AS "last_modified_at", 
532            -- Last modified user details
533            "mu"."id" AS "lmb_id",  
534            "mu"."name" AS "lmb_name", 
535            "mu"."image_id" AS "lmb_image_id" 
536        FROM "docbox_links" AS "link"
537        -- Join on the creator
538        LEFT JOIN "docbox_users" AS "cu" 
539            ON "link"."created_by" = "cu"."id" 
540        -- Join on the parent folder
541        INNER JOIN "docbox_folders" "folder" ON "link"."folder_id" = "folder"."id"
542        -- Join on the edit history (Latest only)
543        LEFT JOIN (
544            -- Get the latest edit history entry
545            SELECT DISTINCT ON ("link_id") "link_id", "user_id", "created_at" 
546            FROM "docbox_edit_history"
547            ORDER BY "link_id", "created_at" DESC 
548        ) AS "ehl" ON "link"."id" = "ehl"."link_id" 
549        -- Join on the editor history latest edit user
550        LEFT JOIN "docbox_users" AS "mu" ON "ehl"."user_id" = "mu"."id" 
551        WHERE "link"."id" = $1 AND "folder"."document_box" = $2"#,
552        )
553        .bind(link_id)
554        .bind(scope)
555        .fetch_optional(db)
556        .await
557    }
558}