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