Skip to main content

max / makenotwork

3.5 KB · 125 lines History Blame Raw
1 //! CRUD operations for user-profile custom links.
2
3 use sqlx::PgPool;
4
5 use super::models::*;
6 use super::{CustomLinkId, UserId};
7 use crate::error::Result;
8
9 /// Create a custom link for a user, appended to the end of their link list.
10 /// Uses a single INSERT...SELECT to atomically compute the next sort_order.
11 #[tracing::instrument(skip_all)]
12 pub async fn create_custom_link(
13 pool: &PgPool,
14 user_id: UserId,
15 url: &str,
16 title: &str,
17 description: Option<&str>,
18 ) -> Result<DbCustomLink> {
19 let link = sqlx::query_as::<_, DbCustomLink>(
20 r#"
21 INSERT INTO custom_links (user_id, url, title, description, sort_order)
22 VALUES ($1, $2, $3, $4, COALESCE((SELECT MAX(sort_order) FROM custom_links WHERE user_id = $1), 0) + 1)
23 RETURNING *
24 "#,
25 )
26 .bind(user_id)
27 .bind(url)
28 .bind(title)
29 .bind(description)
30 .fetch_one(pool)
31 .await?;
32
33 Ok(link)
34 }
35
36 /// List all custom links for a user, ordered by sort_order.
37 ///
38 /// Capped at 100 as a safety limit.
39 #[tracing::instrument(skip_all)]
40 pub async fn get_custom_links_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbCustomLink>> {
41 let links = sqlx::query_as::<_, DbCustomLink>(
42 "SELECT * FROM custom_links WHERE user_id = $1 ORDER BY sort_order LIMIT 100",
43 )
44 .bind(user_id)
45 .fetch_all(pool)
46 .await?;
47
48 Ok(links)
49 }
50
51 /// Partially update a custom link's fields (COALESCE keeps existing values when `None`).
52 #[tracing::instrument(skip_all)]
53 pub async fn update_custom_link(
54 pool: &PgPool,
55 id: CustomLinkId,
56 user_id: UserId,
57 url: Option<&str>,
58 title: Option<&str>,
59 description: Option<&str>,
60 ) -> Result<DbCustomLink> {
61 let link = sqlx::query_as::<_, DbCustomLink>(
62 r#"
63 UPDATE custom_links
64 SET url = COALESCE($2, url),
65 title = COALESCE($3, title),
66 description = COALESCE($4, description)
67 WHERE id = $1 AND user_id = $5
68 RETURNING *
69 "#,
70 )
71 .bind(id)
72 .bind(url)
73 .bind(title)
74 .bind(description)
75 .bind(user_id)
76 .fetch_one(pool)
77 .await?;
78
79 Ok(link)
80 }
81
82 /// Permanently delete a custom link by ID.
83 #[tracing::instrument(skip_all)]
84 pub async fn delete_custom_link(pool: &PgPool, id: CustomLinkId, user_id: UserId) -> Result<()> {
85 sqlx::query("DELETE FROM custom_links WHERE id = $1 AND user_id = $2")
86 .bind(id)
87 .bind(user_id)
88 .execute(pool)
89 .await?;
90
91 Ok(())
92 }
93
94 /// Reorder a user's custom links by assigning sort_order from the given ID sequence.
95 /// Wrapped in a transaction so a crash mid-reorder doesn't leave inconsistent state.
96 #[tracing::instrument(skip_all)]
97 pub async fn reorder_custom_links(pool: &PgPool, user_id: UserId, link_ids: &[CustomLinkId]) -> Result<()> {
98 let mut tx = pool.begin().await?;
99 for (index, link_id) in link_ids.iter().enumerate() {
100 sqlx::query("UPDATE custom_links SET sort_order = $1 WHERE id = $2 AND user_id = $3")
101 .bind(index as i32)
102 .bind(link_id)
103 .bind(user_id)
104 .execute(&mut *tx)
105 .await?;
106 }
107 tx.commit().await?;
108
109 Ok(())
110 }
111
112 /// Check if a custom link belongs to a user (efficient ownership check)
113 #[tracing::instrument(skip_all)]
114 pub async fn user_owns_custom_link(pool: &PgPool, user_id: UserId, link_id: CustomLinkId) -> Result<bool> {
115 let exists: bool = sqlx::query_scalar(
116 "SELECT EXISTS(SELECT 1 FROM custom_links WHERE id = $1 AND user_id = $2)",
117 )
118 .bind(link_id)
119 .bind(user_id)
120 .fetch_one(pool)
121 .await?;
122
123 Ok(exists)
124 }
125