Skip to main content

max / makenotwork

3.8 KB · 146 lines History Blame Raw
1 //! CRUD operations for user-profile custom links.
2
3 use sqlx::PgPool;
4
5 use super::models::DbCustomLink;
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(crate) 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(crate) async fn get_custom_links_by_user(
41 pool: &PgPool,
42 user_id: UserId,
43 ) -> Result<Vec<DbCustomLink>> {
44 let links = sqlx::query_as::<_, DbCustomLink>(
45 "SELECT * FROM custom_links WHERE user_id = $1 ORDER BY sort_order LIMIT 100",
46 )
47 .bind(user_id)
48 .fetch_all(pool)
49 .await?;
50
51 Ok(links)
52 }
53
54 /// Partially update a custom link's fields (COALESCE keeps existing values when `None`).
55 #[tracing::instrument(skip_all)]
56 pub(crate) async fn update_custom_link(
57 pool: &PgPool,
58 id: CustomLinkId,
59 user_id: UserId,
60 url: Option<&str>,
61 title: Option<&str>,
62 description: Option<&str>,
63 ) -> Result<DbCustomLink> {
64 let link = sqlx::query_as::<_, DbCustomLink>(
65 r"
66 UPDATE custom_links
67 SET url = COALESCE($2, url),
68 title = COALESCE($3, title),
69 description = COALESCE($4, description)
70 WHERE id = $1 AND user_id = $5
71 RETURNING *
72 ",
73 )
74 .bind(id)
75 .bind(url)
76 .bind(title)
77 .bind(description)
78 .bind(user_id)
79 .fetch_one(pool)
80 .await?;
81
82 Ok(link)
83 }
84
85 /// Permanently delete a custom link by ID.
86 #[tracing::instrument(skip_all)]
87 pub(crate) async fn delete_custom_link(
88 pool: &PgPool,
89 id: CustomLinkId,
90 user_id: UserId,
91 ) -> Result<()> {
92 sqlx::query("DELETE FROM custom_links WHERE id = $1 AND user_id = $2")
93 .bind(id)
94 .bind(user_id)
95 .execute(pool)
96 .await?;
97
98 Ok(())
99 }
100
101 /// Reorder a user's custom links by assigning sort_order from the given ID sequence.
102 ///
103 /// A single `UNNEST ... WITH ORDINALITY` update, inherently atomic (no partial
104 /// ordering on failure) and one round-trip instead of one query per link, so the
105 /// prior explicit transaction is no longer needed.
106 #[tracing::instrument(skip_all)]
107 pub(crate) async fn reorder_custom_links(
108 pool: &PgPool,
109 user_id: UserId,
110 link_ids: &[CustomLinkId],
111 ) -> Result<()> {
112 let ids: Vec<uuid::Uuid> = link_ids.iter().map(|id| *id.as_uuid()).collect();
113 sqlx::query(
114 r"
115 UPDATE custom_links AS c
116 SET sort_order = ord.pos::int - 1
117 FROM UNNEST($1::uuid[]) WITH ORDINALITY AS ord(id, pos)
118 WHERE c.id = ord.id AND c.user_id = $2
119 ",
120 )
121 .bind(&ids)
122 .bind(user_id)
123 .execute(pool)
124 .await?;
125
126 Ok(())
127 }
128
129 /// Check if a custom link belongs to a user (efficient ownership check)
130 #[tracing::instrument(skip_all)]
131 pub(crate) async fn user_owns_custom_link(
132 pool: &PgPool,
133 user_id: UserId,
134 link_id: CustomLinkId,
135 ) -> Result<bool> {
136 let exists: bool = sqlx::query_scalar(
137 "SELECT EXISTS(SELECT 1 FROM custom_links WHERE id = $1 AND user_id = $2)",
138 )
139 .bind(link_id)
140 .bind(user_id)
141 .fetch_one(pool)
142 .await?;
143
144 Ok(exists)
145 }
146