Skip to main content

max / makenotwork

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