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