Skip to main content

max / makenotwork

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