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 pub id: LinkId,
25 pub name: String,
27 pub value: String,
29 pub folder_id: FolderId,
31 pub created_at: DateTime<Utc>,
33 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 pub id: Uuid,
48 pub name: String,
50 pub value: String,
52 pub folder_id: Uuid,
54 pub created_at: DateTime<Utc>,
56 #[sqlx(flatten)]
58 #[schema(nullable, value_type = User)]
59 pub created_by: CreatedByUser,
60 pub last_modified_at: Option<DateTime<Utc>>,
62 #[sqlx(flatten)]
64 #[schema(nullable, value_type = User)]
65 pub last_modified_by: LastModifiedByUser,
66}
67
68#[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#[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#[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 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 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 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 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 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 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}