Skip to main content

max / makenotwork

8.0 KB · 281 lines History Blame Raw
1 //! Collection API: create, update, delete, add/remove items, reorder.
2
3 use axum::{
4 extract::{Path, State},
5 http::StatusCode,
6 response::IntoResponse,
7 Json,
8 };
9 use serde::{Deserialize, Serialize};
10
11 use crate::{
12 auth::AuthUser,
13 constants,
14 db::{self, CollectionId, ItemId, Slug},
15 error::{AppError, Result},
16 validation,
17 AppState,
18 };
19
20 // ── Request / response types ──
21
22 #[derive(Debug, Deserialize)]
23 pub struct CreateCollectionRequest {
24 pub slug: String,
25 pub title: String,
26 pub description: Option<String>,
27 #[serde(default)]
28 pub is_public: bool,
29 }
30
31 #[derive(Debug, Deserialize)]
32 pub struct UpdateCollectionRequest {
33 pub title: String,
34 pub description: Option<String>,
35 #[serde(default)]
36 pub is_public: bool,
37 }
38
39 #[derive(Debug, Deserialize)]
40 pub struct ReorderItemsRequest {
41 pub item_ids: Vec<ItemId>,
42 }
43
44 #[derive(Debug, Serialize)]
45 pub struct CollectionResponse {
46 pub id: String,
47 pub slug: String,
48 pub title: String,
49 pub description: Option<String>,
50 pub is_public: bool,
51 }
52
53 #[derive(Debug, Serialize)]
54 pub struct CollectionForItemEntry {
55 pub id: String,
56 pub title: String,
57 pub in_collection: bool,
58 }
59
60 // ── Helpers ──
61
62 /// Fetch a collection and verify the authenticated user owns it.
63 async fn verify_collection_ownership(
64 state: &AppState,
65 collection_id: CollectionId,
66 user_id: db::UserId,
67 ) -> Result<db::DbCollection> {
68 let collection = db::collections::get_collection_by_id(&state.db, collection_id)
69 .await?
70 .ok_or(AppError::NotFound)?;
71
72 if collection.user_id != user_id {
73 return Err(AppError::Forbidden);
74 }
75
76 Ok(collection)
77 }
78
79 // ── Write routes ──
80
81 /// Create a new collection.
82 #[tracing::instrument(skip_all, name = "collections::create")]
83 pub(super) async fn create_collection(
84 State(state): State<AppState>,
85 AuthUser(user): AuthUser,
86 Json(req): Json<CreateCollectionRequest>,
87 ) -> Result<impl IntoResponse> {
88 user.check_not_suspended()?;
89
90 let title = req.title.trim();
91 let slug_str = req.slug.trim();
92 let description = req.description.as_deref().map(str::trim).filter(|s| !s.is_empty());
93
94 validation::validate_collection_title(title)?;
95 if let Some(desc) = description {
96 validation::validate_collection_description(desc)?;
97 }
98 let slug = Slug::new(slug_str)?;
99
100 // Enforce per-user limit
101 let count = db::collections::count_collections_by_user(&state.db, user.id).await?;
102 if count >= constants::MAX_COLLECTIONS_PER_USER {
103 return Err(AppError::validation(format!(
104 "You can create up to {} collections",
105 constants::MAX_COLLECTIONS_PER_USER
106 )));
107 }
108
109 let collection = match db::collections::create_collection(
110 &state.db,
111 user.id,
112 &slug,
113 title,
114 description,
115 req.is_public,
116 )
117 .await
118 {
119 Ok(c) => c,
120 Err(crate::error::AppError::Database(sqlx::Error::Database(ref db_err)))
121 if db_err.code().as_deref() == Some("23505") =>
122 {
123 return Err(AppError::validation(
124 "You already have a collection with this slug".to_string(),
125 ));
126 }
127 Err(e) => return Err(e),
128 };
129
130 Ok((
131 StatusCode::CREATED,
132 Json(CollectionResponse {
133 id: collection.id.to_string(),
134 slug: collection.slug.to_string(),
135 title: collection.title,
136 description: collection.description,
137 is_public: collection.is_public,
138 }),
139 ))
140 }
141
142 /// Update a collection's title, description, and visibility.
143 #[tracing::instrument(skip_all, name = "collections::update")]
144 pub(super) async fn update_collection(
145 State(state): State<AppState>,
146 AuthUser(user): AuthUser,
147 Path(id): Path<CollectionId>,
148 Json(req): Json<UpdateCollectionRequest>,
149 ) -> Result<impl IntoResponse> {
150 user.check_not_suspended()?;
151 verify_collection_ownership(&state, id, user.id).await?;
152
153 let title = req.title.trim();
154 let description = req.description.as_deref().map(str::trim).filter(|s| !s.is_empty());
155
156 validation::validate_collection_title(title)?;
157 if let Some(desc) = description {
158 validation::validate_collection_description(desc)?;
159 }
160
161 let collection = db::collections::update_collection(
162 &state.db,
163 id,
164 title,
165 description,
166 req.is_public,
167 )
168 .await?;
169
170 Ok(Json(CollectionResponse {
171 id: collection.id.to_string(),
172 slug: collection.slug.to_string(),
173 title: collection.title,
174 description: collection.description,
175 is_public: collection.is_public,
176 }))
177 }
178
179 /// Delete a collection.
180 #[tracing::instrument(skip_all, name = "collections::delete")]
181 pub(super) async fn delete_collection(
182 State(state): State<AppState>,
183 AuthUser(user): AuthUser,
184 Path(id): Path<CollectionId>,
185 ) -> Result<impl IntoResponse> {
186 user.check_not_suspended()?;
187 verify_collection_ownership(&state, id, user.id).await?;
188
189 db::collections::delete_collection(&state.db, id).await?;
190
191 Ok(StatusCode::NO_CONTENT)
192 }
193
194 /// Add an item to a collection.
195 #[tracing::instrument(skip_all, name = "collections::add_item")]
196 pub(super) async fn add_item(
197 State(state): State<AppState>,
198 AuthUser(user): AuthUser,
199 Path((collection_id, item_id)): Path<(CollectionId, ItemId)>,
200 ) -> Result<impl IntoResponse> {
201 user.check_not_suspended()?;
202 verify_collection_ownership(&state, collection_id, user.id).await?;
203
204 // Item must exist and be public
205 let item = db::items::get_item_by_id(&state.db, item_id)
206 .await?
207 .ok_or(AppError::NotFound)?;
208 if !item.is_public {
209 return Err(AppError::validation(
210 "Only public items can be added to collections".to_string(),
211 ));
212 }
213
214 // Enforce per-collection limit
215 let count = db::collections::count_collection_items(&state.db, collection_id).await?;
216 if count >= constants::MAX_ITEMS_PER_COLLECTION {
217 return Err(AppError::validation(format!(
218 "A collection can hold up to {} items",
219 constants::MAX_ITEMS_PER_COLLECTION
220 )));
221 }
222
223 db::collections::add_item_to_collection(&state.db, collection_id, item_id).await?;
224
225 Ok(StatusCode::NO_CONTENT)
226 }
227
228 /// Remove an item from a collection.
229 #[tracing::instrument(skip_all, name = "collections::remove_item")]
230 pub(super) async fn remove_item(
231 State(state): State<AppState>,
232 AuthUser(user): AuthUser,
233 Path((collection_id, item_id)): Path<(CollectionId, ItemId)>,
234 ) -> Result<impl IntoResponse> {
235 user.check_not_suspended()?;
236 verify_collection_ownership(&state, collection_id, user.id).await?;
237
238 db::collections::remove_item_from_collection(&state.db, collection_id, item_id).await?;
239
240 Ok(StatusCode::NO_CONTENT)
241 }
242
243 /// Reorder items in a collection.
244 #[tracing::instrument(skip_all, name = "collections::reorder_items")]
245 pub(super) async fn reorder_items(
246 State(state): State<AppState>,
247 AuthUser(user): AuthUser,
248 Path(collection_id): Path<CollectionId>,
249 Json(req): Json<ReorderItemsRequest>,
250 ) -> Result<impl IntoResponse> {
251 user.check_not_suspended()?;
252 verify_collection_ownership(&state, collection_id, user.id).await?;
253
254 db::collections::reorder_collection_items(&state.db, collection_id, &req.item_ids).await?;
255
256 Ok(StatusCode::NO_CONTENT)
257 }
258
259 // ── Read routes ──
260
261 /// Get the current user's collections with membership state for a specific item.
262 #[tracing::instrument(skip_all, name = "collections::for_item")]
263 pub(super) async fn collections_for_item(
264 State(state): State<AppState>,
265 AuthUser(user): AuthUser,
266 Path(item_id): Path<ItemId>,
267 ) -> Result<impl IntoResponse> {
268 let rows = db::collections::get_user_collections_for_item(&state.db, user.id, item_id).await?;
269
270 let entries: Vec<CollectionForItemEntry> = rows
271 .into_iter()
272 .map(|(id, title, in_collection)| CollectionForItemEntry {
273 id: id.to_string(),
274 title,
275 in_collection,
276 })
277 .collect();
278
279 Ok(Json(entries))
280 }
281