Skip to main content

docbox_database/models/
link.rs

1use super::{
2    document_box::DocumentBoxScopeRaw,
3    folder::FolderId,
4    user::{User, UserId},
5};
6use crate::{
7    DbExecutor, DbResult,
8    models::{
9        document_box::DocumentBoxScopeRawRef,
10        shared::{
11            CountResult, DocboxInputPair, FolderPathSegment, WithFullPath, WithFullPathScope,
12        },
13    },
14};
15use chrono::{DateTime, Utc};
16use serde::Serialize;
17use sqlx::{postgres::PgQueryResult, prelude::FromRow};
18use utoipa::ToSchema;
19use uuid::Uuid;
20
21pub type LinkId = Uuid;
22
23#[derive(Debug, Clone, FromRow, Serialize, sqlx::Type, ToSchema)]
24#[sqlx(type_name = "docbox_link")]
25pub struct Link {
26    /// Unique identifier for the link
27    #[schema(value_type = Uuid)]
28    pub id: LinkId,
29    /// Name of the link
30    pub name: String,
31    /// value of the link
32    pub value: String,
33    /// Whether the link is pinned
34    pub pinned: bool,
35    /// Parent folder ID
36    #[schema(value_type = Uuid)]
37    pub folder_id: FolderId,
38    /// When the link was created
39    pub created_at: DateTime<Utc>,
40    /// User who created the link
41    #[serde(skip)]
42    pub created_by: Option<UserId>,
43}
44
45impl Eq for Link {}
46
47impl PartialEq for Link {
48    fn eq(&self, other: &Self) -> bool {
49        self.id.eq(&other.id)
50            && self.name.eq(&other.name)
51            && self.value.eq(&other.value)
52            && self.pinned.eq(&other.pinned)
53            && self.folder_id.eq(&other.folder_id)
54            && self.created_by.eq(&self.created_by)
55            // Reduce precision when checking creation timestamp
56            // (Database does not store the full precision)
57            && self
58                .created_at
59                .timestamp_millis()
60                .eq(&other.created_at.timestamp_millis())
61    }
62}
63
64#[derive(Debug, FromRow, Serialize)]
65pub struct LinkWithScope {
66    #[sqlx(flatten)]
67    pub link: Link,
68    pub scope: String,
69}
70
71#[derive(Debug, Clone, Serialize, FromRow, ToSchema)]
72pub struct LinkWithExtra {
73    #[serde(flatten)]
74    pub link: Link,
75    /// User who created the link
76    #[schema(nullable, value_type = User)]
77    pub created_by: Option<User>,
78    /// User who last modified the link
79    #[schema(nullable, value_type = User)]
80    pub last_modified_by: Option<User>,
81    /// Last time the file was modified
82    pub last_modified_at: Option<DateTime<Utc>>,
83}
84
85/// Link with extra with an additional resolved full path
86#[derive(Debug, FromRow, Serialize, ToSchema)]
87pub struct ResolvedLinkWithExtra {
88    #[serde(flatten)]
89    #[sqlx(flatten)]
90    pub link: LinkWithExtra,
91    pub full_path: Vec<FolderPathSegment>,
92}
93
94pub struct CreateLink {
95    pub name: String,
96    pub value: String,
97    pub folder_id: FolderId,
98    pub created_by: Option<UserId>,
99}
100
101impl Link {
102    pub async fn create(
103        db: impl DbExecutor<'_>,
104        CreateLink {
105            name,
106            value,
107            folder_id,
108            created_by,
109        }: CreateLink,
110    ) -> DbResult<Link> {
111        let id = Uuid::new_v4();
112        let created_at = Utc::now();
113
114        sqlx::query(
115            r#"INSERT INTO "docbox_links" (
116                "id",
117                "name",
118                "value",
119                "folder_id",
120                "created_by",
121                "created_at"
122            ) VALUES ($1, $2, $3, $4, $5, $6)
123            "#,
124        )
125        .bind(id)
126        .bind(name.clone())
127        .bind(value.clone())
128        .bind(folder_id)
129        .bind(created_by.as_ref())
130        .bind(created_at)
131        .execute(db)
132        .await?;
133
134        Ok(Link {
135            id,
136            name,
137            value,
138            folder_id,
139            created_by,
140            created_at,
141            pinned: false,
142        })
143    }
144
145    pub async fn move_to_folder(
146        mut self,
147        db: impl DbExecutor<'_>,
148        folder_id: FolderId,
149    ) -> DbResult<Link> {
150        sqlx::query(r#"UPDATE "docbox_links" SET "folder_id" = $1 WHERE "id" = $2"#)
151            .bind(folder_id)
152            .bind(self.id)
153            .execute(db)
154            .await?;
155
156        self.folder_id = folder_id;
157
158        Ok(self)
159    }
160
161    pub async fn rename(mut self, db: impl DbExecutor<'_>, name: String) -> DbResult<Link> {
162        sqlx::query(r#"UPDATE "docbox_links" SET "name" = $1 WHERE "id" = $2"#)
163            .bind(name.as_str())
164            .bind(self.id)
165            .execute(db)
166            .await?;
167
168        self.name = name;
169        Ok(self)
170    }
171
172    pub async fn set_pinned(mut self, db: impl DbExecutor<'_>, pinned: bool) -> DbResult<Link> {
173        sqlx::query(r#"UPDATE "docbox_links" SET "pinned" = $1 WHERE "id" = $2"#)
174            .bind(pinned)
175            .bind(self.id)
176            .execute(db)
177            .await?;
178
179        self.pinned = pinned;
180        Ok(self)
181    }
182
183    pub async fn update_value(mut self, db: impl DbExecutor<'_>, value: String) -> DbResult<Link> {
184        sqlx::query(r#"UPDATE "docbox_links" SET "value" = $1 WHERE "id" = $2"#)
185            .bind(value.as_str())
186            .bind(self.id)
187            .execute(db)
188            .await?;
189
190        self.value = value;
191
192        Ok(self)
193    }
194
195    pub async fn all(
196        db: impl DbExecutor<'_>,
197        offset: u64,
198        page_size: u64,
199    ) -> DbResult<Vec<LinkWithScope>> {
200        sqlx::query_as(
201            r#"
202            SELECT
203            "link".*,
204            "folder"."document_box" AS "scope"
205            FROM "docbox_links" AS "link"
206            INNER JOIN "docbox_folders" "folder" ON "link"."folder_id" = "folder"."id"
207            ORDER BY "link"."created_at" ASC
208            OFFSET $1
209            LIMIT $2
210        "#,
211        )
212        .bind(offset as i64)
213        .bind(page_size as i64)
214        .fetch_all(db)
215        .await
216    }
217
218    pub async fn find(
219        db: impl DbExecutor<'_>,
220        scope: DocumentBoxScopeRawRef<'_>,
221        link_id: LinkId,
222    ) -> DbResult<Option<Link>> {
223        sqlx::query_as(
224            r#"
225            SELECT "link".*
226            FROM "docbox_links" AS "link"
227            INNER JOIN "docbox_folders" "folder" ON "link"."folder_id" = "folder"."id"
228            WHERE "link"."id" = $1 AND "folder"."document_box" = $2
229        "#,
230        )
231        .bind(link_id)
232        .bind(scope)
233        .fetch_optional(db)
234        .await
235    }
236    /// Collects the IDs and names of all parent folders of the
237    /// provided folder
238    pub async fn resolve_path(
239        db: impl DbExecutor<'_>,
240        link_id: LinkId,
241    ) -> DbResult<Vec<FolderPathSegment>> {
242        sqlx::query_as(r#"SELECT "id", "name" FROM resolve_link_path($1)"#)
243            .bind(link_id)
244            .fetch_all(db)
245            .await
246    }
247
248    /// Finds all links within the provided parent folder
249    pub async fn find_by_parent(
250        db: impl DbExecutor<'_>,
251        parent_id: FolderId,
252    ) -> DbResult<Vec<Link>> {
253        sqlx::query_as(r#"SELECT * FROM "docbox_links" WHERE "folder_id" = $1"#)
254            .bind(parent_id)
255            .fetch_all(db)
256            .await
257    }
258
259    /// Deletes the link
260    pub async fn delete(&self, db: impl DbExecutor<'_>) -> DbResult<PgQueryResult> {
261        sqlx::query(r#"DELETE FROM "docbox_links" WHERE "id" = $1"#)
262            .bind(self.id)
263            .execute(db)
264            .await
265    }
266
267    /// Finds a collection of links that are within various document box scopes, resolves
268    /// both the links themselves and the folder path to traverse to get to each link
269    pub async fn resolve_with_extra_mixed_scopes(
270        db: impl DbExecutor<'_>,
271        links_scope_with_id: Vec<DocboxInputPair<'_>>,
272    ) -> DbResult<Vec<WithFullPathScope<LinkWithExtra>>> {
273        if links_scope_with_id.is_empty() {
274            return Ok(Vec::new());
275        }
276
277        sqlx::query_as(r#"SELECT * FROM resolve_links_with_extra_mixed_scopes($1)"#)
278            .bind(links_scope_with_id)
279            .fetch_all(db)
280            .await
281    }
282
283    /// Finds a collection of links that are all within the same document box, resolves
284    /// both the links themselves and the folder path to traverse to get to each link
285    pub async fn resolve_with_extra(
286        db: impl DbExecutor<'_>,
287        scope: &DocumentBoxScopeRaw,
288        link_ids: Vec<Uuid>,
289    ) -> DbResult<Vec<WithFullPath<LinkWithExtra>>> {
290        if link_ids.is_empty() {
291            return Ok(Vec::new());
292        }
293
294        sqlx::query_as(r#"SELECT * FROM resolve_links_with_extra($1, $2)"#)
295            .bind(scope)
296            .bind(link_ids)
297            .fetch_all(db)
298            .await
299    }
300
301    /// Finds all links within the provided parent folder
302    pub async fn find_by_parent_with_extra(
303        db: impl DbExecutor<'_>,
304        parent_id: FolderId,
305    ) -> DbResult<Vec<LinkWithExtra>> {
306        sqlx::query_as(r#"SELECT * FROM resolve_links_by_parent_folder_with_extra($1)"#)
307            .bind(parent_id)
308            .fetch_all(db)
309            .await
310    }
311
312    pub async fn find_with_extra(
313        db: impl DbExecutor<'_>,
314        scope: &DocumentBoxScopeRaw,
315        link_id: LinkId,
316    ) -> DbResult<Option<LinkWithExtra>> {
317        sqlx::query_as(r#"SELECT * FROM resolve_link_by_id_with_extra($1, $2)"#)
318            .bind(scope)
319            .bind(link_id)
320            .fetch_optional(db)
321            .await
322    }
323
324    /// Get the total number of folders in the tenant
325    pub async fn total_count(db: impl DbExecutor<'_>) -> DbResult<i64> {
326        let count_result: CountResult =
327            sqlx::query_as(r#"SELECT COUNT(*) AS "count" FROM "docbox_links""#)
328                .fetch_one(db)
329                .await?;
330
331        Ok(count_result.count)
332    }
333}