Skip to main content

max / makenotwork

4.7 KB · 176 lines History Blame Raw
1 //! Custom link API: create, update, delete, reorder.
2
3 use axum::{
4 Form, Json,
5 extract::{Path, State},
6 http::{StatusCode, header::HeaderMap},
7 response::{Html, IntoResponse, Response},
8 };
9 use serde::{Deserialize, Serialize};
10
11 use sqlx::PgPool;
12
13 use crate::{
14 auth::AuthUser,
15 db::{self, CustomLinkId},
16 error::{AppError, Result},
17 helpers::{htmx_toast_response, is_htmx_request},
18 templates::LinkRowTemplate,
19 validation,
20 };
21
22 // Custom Links API
23
24 /// Form input for creating a custom profile link.
25 #[derive(Debug, Deserialize)]
26 pub(super) struct CreateLinkRequest {
27 pub url: String,
28 pub title: String,
29 pub description: Option<String>,
30 }
31
32 /// JSON response representing a custom profile link.
33 #[derive(Debug, Serialize)]
34 pub(super) struct LinkResponse {
35 pub id: CustomLinkId,
36 pub url: String,
37 pub title: String,
38 pub description: Option<String>,
39 pub sort_order: i32,
40 }
41
42 /// Create a new custom profile link for the authenticated user.
43 #[tracing::instrument(skip_all, name = "links::create_link")]
44 pub(super) async fn create_link(
45 State(db): State<PgPool>,
46 headers: HeaderMap,
47 AuthUser(user): AuthUser,
48 Form(req): Form<CreateLinkRequest>,
49 ) -> Result<Response> {
50 user.check_not_suspended()?;
51 // Validate input
52 validation::validate_link_url(&req.url)?;
53 validation::validate_link_title(&req.title)?;
54
55 let link = db::custom_links::create_custom_link(
56 &db,
57 user.id,
58 &req.url,
59 &req.title,
60 req.description.as_deref(),
61 )
62 .await?;
63
64 if is_htmx_request(&headers) {
65 return Ok(Html(
66 LinkRowTemplate {
67 id: link.id.to_string(),
68 title: link.title,
69 url: link.url,
70 }
71 .render_string()?,
72 )
73 .into_response());
74 }
75
76 Ok(Json(LinkResponse {
77 id: link.id,
78 url: link.url,
79 title: link.title,
80 description: link.description,
81 sort_order: link.sort_order,
82 })
83 .into_response())
84 }
85
86 /// JSON input for updating a custom profile link.
87 #[derive(Debug, Deserialize)]
88 pub(super) struct UpdateLinkRequest {
89 pub url: Option<String>,
90 pub title: Option<String>,
91 pub description: Option<String>,
92 }
93
94 /// Update an existing custom profile link owned by the user.
95 #[tracing::instrument(skip_all, name = "links::update_link")]
96 pub(super) async fn update_link(
97 State(db): State<PgPool>,
98 AuthUser(user): AuthUser,
99 Path(id): Path<CustomLinkId>,
100 Json(req): Json<UpdateLinkRequest>,
101 ) -> Result<impl IntoResponse> {
102 user.check_not_suspended()?;
103
104 // Validate input (same rules as create_link, but all fields are optional)
105 if let Some(ref url) = req.url {
106 validation::validate_link_url(url)?;
107 }
108 if let Some(ref title) = req.title {
109 validation::validate_link_title(title)?;
110 }
111
112 // Verify ownership with efficient single-row check
113 if !db::custom_links::user_owns_custom_link(&db, user.id, id).await? {
114 return Err(AppError::NotFound);
115 }
116
117 let link = db::custom_links::update_custom_link(
118 &db,
119 id,
120 user.id,
121 req.url.as_deref(),
122 req.title.as_deref(),
123 req.description.as_deref(),
124 )
125 .await?;
126
127 Ok(Json(LinkResponse {
128 id: link.id,
129 url: link.url,
130 title: link.title,
131 description: link.description,
132 sort_order: link.sort_order,
133 }))
134 }
135
136 /// Delete a custom profile link owned by the user.
137 #[tracing::instrument(skip_all, name = "links::delete_link")]
138 pub(super) async fn delete_link(
139 State(db): State<PgPool>,
140 headers: HeaderMap,
141 AuthUser(user): AuthUser,
142 Path(id): Path<CustomLinkId>,
143 ) -> Result<Response> {
144 user.check_not_suspended()?;
145 // Verify ownership with efficient single-row check
146 if !db::custom_links::user_owns_custom_link(&db, user.id, id).await? {
147 return Err(AppError::NotFound);
148 }
149
150 db::custom_links::delete_custom_link(&db, id, user.id).await?;
151
152 if is_htmx_request(&headers) {
153 return Ok(htmx_toast_response("Link removed", "success").into_response());
154 }
155
156 Ok(StatusCode::NO_CONTENT.into_response())
157 }
158
159 /// JSON input for reordering custom profile links.
160 #[derive(Debug, Deserialize)]
161 pub(super) struct ReorderLinksRequest {
162 pub link_ids: Vec<CustomLinkId>,
163 }
164
165 /// Reorder the authenticated user's custom profile links.
166 #[tracing::instrument(skip_all, name = "links::reorder_links")]
167 pub(super) async fn reorder_links(
168 State(db): State<PgPool>,
169 AuthUser(user): AuthUser,
170 Json(req): Json<ReorderLinksRequest>,
171 ) -> Result<impl IntoResponse> {
172 user.check_not_suspended()?;
173 db::custom_links::reorder_custom_links(&db, user.id, &req.link_ids).await?;
174 Ok(StatusCode::NO_CONTENT)
175 }
176