//! Collection API: create, update, delete, add/remove items, reorder. use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, constants, db::{self, CollectionId, ItemId, Slug}, error::{AppError, Result}, validation, AppState, }; // ── Request / response types ── #[derive(Debug, Deserialize)] pub struct CreateCollectionRequest { pub slug: String, pub title: String, pub description: Option, #[serde(default)] pub is_public: bool, } #[derive(Debug, Deserialize)] pub struct UpdateCollectionRequest { pub title: String, pub description: Option, #[serde(default)] pub is_public: bool, } #[derive(Debug, Deserialize)] pub struct ReorderItemsRequest { pub item_ids: Vec, } #[derive(Debug, Serialize)] pub struct CollectionResponse { pub id: String, pub slug: String, pub title: String, pub description: Option, pub is_public: bool, } #[derive(Debug, Serialize)] pub struct CollectionForItemEntry { pub id: String, pub title: String, pub in_collection: bool, } // ── Helpers ── /// Fetch a collection and verify the authenticated user owns it. async fn verify_collection_ownership( state: &AppState, collection_id: CollectionId, user_id: db::UserId, ) -> Result { let collection = db::collections::get_collection_by_id(&state.db, collection_id) .await? .ok_or(AppError::NotFound)?; if collection.user_id != user_id { return Err(AppError::Forbidden); } Ok(collection) } // ── Write routes ── /// Create a new collection. #[tracing::instrument(skip_all, name = "collections::create")] pub(super) async fn create_collection( State(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let title = req.title.trim(); let slug_str = req.slug.trim(); let description = req.description.as_deref().map(str::trim).filter(|s| !s.is_empty()); validation::validate_collection_title(title)?; if let Some(desc) = description { validation::validate_collection_description(desc)?; } let slug = Slug::new(slug_str)?; // Enforce per-user limit let count = db::collections::count_collections_by_user(&state.db, user.id).await?; if count >= constants::MAX_COLLECTIONS_PER_USER { return Err(AppError::validation(format!( "You can create up to {} collections", constants::MAX_COLLECTIONS_PER_USER ))); } let collection = match db::collections::create_collection( &state.db, user.id, &slug, title, description, req.is_public, ) .await { Ok(c) => c, Err(crate::error::AppError::Database(sqlx::Error::Database(ref db_err))) if db_err.code().as_deref() == Some("23505") => { return Err(AppError::validation( "You already have a collection with this slug".to_string(), )); } Err(e) => return Err(e), }; Ok(( StatusCode::CREATED, Json(CollectionResponse { id: collection.id.to_string(), slug: collection.slug.to_string(), title: collection.title, description: collection.description, is_public: collection.is_public, }), )) } /// Update a collection's title, description, and visibility. #[tracing::instrument(skip_all, name = "collections::update")] pub(super) async fn update_collection( State(state): State, AuthUser(user): AuthUser, Path(id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; verify_collection_ownership(&state, id, user.id).await?; let title = req.title.trim(); let description = req.description.as_deref().map(str::trim).filter(|s| !s.is_empty()); validation::validate_collection_title(title)?; if let Some(desc) = description { validation::validate_collection_description(desc)?; } let collection = db::collections::update_collection( &state.db, id, title, description, req.is_public, ) .await?; Ok(Json(CollectionResponse { id: collection.id.to_string(), slug: collection.slug.to_string(), title: collection.title, description: collection.description, is_public: collection.is_public, })) } /// Delete a collection. #[tracing::instrument(skip_all, name = "collections::delete")] pub(super) async fn delete_collection( State(state): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { user.check_not_suspended()?; verify_collection_ownership(&state, id, user.id).await?; db::collections::delete_collection(&state.db, id).await?; Ok(StatusCode::NO_CONTENT) } /// Add an item to a collection. #[tracing::instrument(skip_all, name = "collections::add_item")] pub(super) async fn add_item( State(state): State, AuthUser(user): AuthUser, Path((collection_id, item_id)): Path<(CollectionId, ItemId)>, ) -> Result { user.check_not_suspended()?; verify_collection_ownership(&state, collection_id, user.id).await?; // Item must exist and be public let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; if !item.is_public { return Err(AppError::validation( "Only public items can be added to collections".to_string(), )); } // Enforce per-collection limit let count = db::collections::count_collection_items(&state.db, collection_id).await?; if count >= constants::MAX_ITEMS_PER_COLLECTION { return Err(AppError::validation(format!( "A collection can hold up to {} items", constants::MAX_ITEMS_PER_COLLECTION ))); } db::collections::add_item_to_collection(&state.db, collection_id, item_id).await?; Ok(StatusCode::NO_CONTENT) } /// Remove an item from a collection. #[tracing::instrument(skip_all, name = "collections::remove_item")] pub(super) async fn remove_item( State(state): State, AuthUser(user): AuthUser, Path((collection_id, item_id)): Path<(CollectionId, ItemId)>, ) -> Result { user.check_not_suspended()?; verify_collection_ownership(&state, collection_id, user.id).await?; db::collections::remove_item_from_collection(&state.db, collection_id, item_id).await?; Ok(StatusCode::NO_CONTENT) } /// Reorder items in a collection. #[tracing::instrument(skip_all, name = "collections::reorder_items")] pub(super) async fn reorder_items( State(state): State, AuthUser(user): AuthUser, Path(collection_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; verify_collection_ownership(&state, collection_id, user.id).await?; db::collections::reorder_collection_items(&state.db, collection_id, &req.item_ids).await?; Ok(StatusCode::NO_CONTENT) } // ── Read routes ── /// Get the current user's collections with membership state for a specific item. #[tracing::instrument(skip_all, name = "collections::for_item")] pub(super) async fn collections_for_item( State(state): State, AuthUser(user): AuthUser, Path(item_id): Path, ) -> Result { let rows = db::collections::get_user_collections_for_item(&state.db, user.id, item_id).await?; let entries: Vec = rows .into_iter() .map(|(id, title, in_collection)| CollectionForItemEntry { id: id.to_string(), title, in_collection, }) .collect(); Ok(Json(entries)) }