use axum::{
Json, Router,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::error::{ErrorBody, HttpError};
use crate::state::{AppState, tenant_id_from_headers};
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub(crate) struct CreateWishlistRequest {
#[schema(value_type = String, format = "uuid")]
pub customer_id: String,
pub name: Option<String>,
pub is_public: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub(crate) struct AddWishlistItemRequest {
#[schema(value_type = String, format = "uuid")]
pub product_id: String,
pub note: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
#[into_params(parameter_in = Query)]
pub(crate) struct WishlistFilterParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub customer_id: Option<String>,
}
impl WishlistFilterParams {
const DEFAULT_LIMIT: u32 = 50;
const MAX_LIMIT: u32 = 200;
#[must_use]
pub(crate) fn resolved_limit(&self) -> u32 {
self.limit.unwrap_or(Self::DEFAULT_LIMIT).clamp(1, Self::MAX_LIMIT)
}
#[must_use]
pub(crate) fn resolved_offset(&self) -> u32 {
self.offset.unwrap_or(0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub(crate) struct WishlistItemResponse {
#[schema(value_type = String, format = "uuid")]
pub product_id: String,
pub note: Option<String>,
pub added_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub(crate) struct WishlistResponse {
#[schema(value_type = String, format = "uuid")]
pub id: String,
#[schema(value_type = String, format = "uuid")]
pub customer_id: String,
pub name: String,
pub is_public: bool,
pub items: Vec<WishlistItemResponse>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub(crate) struct WishlistListResponse {
pub wishlists: Vec<WishlistResponse>,
pub total: usize,
pub limit: u32,
pub offset: u32,
pub has_more: bool,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/wishlists", post(create_wishlist).get(list_wishlists))
.route("/wishlists/{id}", get(get_wishlist).delete(delete_wishlist))
.route("/wishlists/{id}/items", post(add_item))
.route("/wishlists/{id}/items/{product_id}", axum::routing::delete(remove_item))
}
#[utoipa::path(
post,
path = "/api/v1/wishlists",
tag = "wishlists",
request_body = CreateWishlistRequest,
responses(
(status = 201, description = "Wishlist created", body = WishlistResponse),
(status = 400, description = "Invalid request", body = ErrorBody),
)
)]
#[tracing::instrument(skip(state, headers, req))]
pub(crate) async fn create_wishlist(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateWishlistRequest>,
) -> Result<(StatusCode, Json<WishlistResponse>), HttpError> {
let tenant_id = tenant_id_from_headers(&headers);
let commerce = state.commerce_for_tenant(tenant_id.as_deref())?;
let wishlist = commerce.wishlists().create(stateset_core::CreateWishlist {
customer_id: req
.customer_id
.parse()
.map_err(|e| HttpError::BadRequest(format!("Invalid customer_id: {e}")))?,
name: req.name.unwrap_or_else(|| "My Wishlist".to_string()),
is_public: req.is_public.unwrap_or(false),
})?;
Ok((StatusCode::CREATED, Json(wishlist_to_response(wishlist))))
}
#[utoipa::path(
get,
path = "/api/v1/wishlists/{id}",
tag = "wishlists",
params(("id" = String, Path, description = "Wishlist ID (UUID)")),
responses(
(status = 200, description = "Wishlist details", body = WishlistResponse),
(status = 404, description = "Wishlist not found", body = ErrorBody),
)
)]
#[tracing::instrument(skip(state, headers))]
pub(crate) async fn get_wishlist(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<stateset_primitives::WishlistId>,
) -> Result<Json<WishlistResponse>, HttpError> {
let tenant_id = tenant_id_from_headers(&headers);
let commerce = state.commerce_for_tenant(tenant_id.as_deref())?;
let wishlist = commerce
.wishlists()
.get(id)?
.ok_or_else(|| HttpError::NotFound(format!("Wishlist {id} not found")))?;
Ok(Json(wishlist_to_response(wishlist)))
}
#[utoipa::path(
get,
path = "/api/v1/wishlists",
tag = "wishlists",
params(WishlistFilterParams),
responses(
(status = 200, description = "List of wishlists", body = WishlistListResponse),
(status = 400, description = "Invalid filter parameter", body = ErrorBody),
)
)]
#[tracing::instrument(skip(state, headers, params))]
pub(crate) async fn list_wishlists(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<WishlistFilterParams>,
) -> Result<Json<WishlistListResponse>, HttpError> {
let tenant_id = tenant_id_from_headers(&headers);
let commerce = state.commerce_for_tenant(tenant_id.as_deref())?;
let limit = params.resolved_limit();
let offset = params.resolved_offset();
let customer_id = params
.customer_id
.map(|s| s.parse::<stateset_primitives::CustomerId>())
.transpose()
.map_err(|e| HttpError::BadRequest(format!("Invalid customer_id: {e}")))?;
let count_filter =
stateset_core::WishlistFilter { customer_id, is_public: None, limit: None, offset: None };
let total = commerce.wishlists().list(count_filter)?.len();
let filter = stateset_core::WishlistFilter {
customer_id,
is_public: None,
limit: Some(limit.saturating_add(1)),
offset: Some(offset),
};
let mut wishlists = commerce.wishlists().list(filter)?;
let has_more = wishlists.len() > limit as usize;
if has_more {
wishlists.truncate(limit as usize);
}
Ok(Json(WishlistListResponse {
wishlists: wishlists.into_iter().map(wishlist_to_response).collect(),
total,
limit,
offset,
has_more,
}))
}
#[utoipa::path(
delete,
path = "/api/v1/wishlists/{id}",
tag = "wishlists",
params(("id" = String, Path, description = "Wishlist ID (UUID)")),
responses(
(status = 204, description = "Wishlist deleted"),
(status = 404, description = "Wishlist not found", body = ErrorBody),
)
)]
#[tracing::instrument(skip(state, headers))]
pub(crate) async fn delete_wishlist(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<stateset_primitives::WishlistId>,
) -> Result<StatusCode, HttpError> {
let tenant_id = tenant_id_from_headers(&headers);
let commerce = state.commerce_for_tenant(tenant_id.as_deref())?;
commerce.wishlists().delete(id)?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/v1/wishlists/{id}/items",
tag = "wishlists",
params(("id" = String, Path, description = "Wishlist ID (UUID)")),
request_body = AddWishlistItemRequest,
responses(
(status = 200, description = "Item added to wishlist", body = WishlistResponse),
(status = 404, description = "Wishlist not found", body = ErrorBody),
(status = 400, description = "Invalid request", body = ErrorBody),
)
)]
#[tracing::instrument(skip(state, headers, req))]
pub(crate) async fn add_item(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<stateset_primitives::WishlistId>,
Json(req): Json<AddWishlistItemRequest>,
) -> Result<Json<WishlistResponse>, HttpError> {
let tenant_id = tenant_id_from_headers(&headers);
let commerce = state.commerce_for_tenant(tenant_id.as_deref())?;
let product_id: stateset_primitives::ProductId = req
.product_id
.parse()
.map_err(|e| HttpError::BadRequest(format!("Invalid product_id: {e}")))?;
let _item = commerce.wishlists().add_item(
id,
stateset_core::AddWishlistItem {
product_id,
variant_id: None,
note: req.note,
quantity: None,
priority: None,
},
)?;
let wishlist = commerce
.wishlists()
.get(id)?
.ok_or_else(|| HttpError::NotFound(format!("Wishlist {id} not found")))?;
Ok(Json(wishlist_to_response(wishlist)))
}
#[utoipa::path(
delete,
path = "/api/v1/wishlists/{id}/items/{product_id}",
tag = "wishlists",
params(
("id" = String, Path, description = "Wishlist ID (UUID)"),
("product_id" = String, Path, description = "Product ID (UUID) to remove"),
),
responses(
(status = 200, description = "Item removed from wishlist", body = WishlistResponse),
(status = 404, description = "Wishlist or item not found", body = ErrorBody),
)
)]
#[tracing::instrument(skip(state, headers))]
pub(crate) async fn remove_item(
State(state): State<AppState>,
headers: HeaderMap,
Path((id, product_id)): Path<(stateset_primitives::WishlistId, stateset_primitives::ProductId)>,
) -> Result<Json<WishlistResponse>, HttpError> {
let tenant_id = tenant_id_from_headers(&headers);
let commerce = state.commerce_for_tenant(tenant_id.as_deref())?;
commerce.wishlists().remove_item(id, product_id)?;
let wishlist = commerce
.wishlists()
.get(id)?
.ok_or_else(|| HttpError::NotFound(format!("Wishlist {id} not found")))?;
Ok(Json(wishlist_to_response(wishlist)))
}
fn wishlist_to_response(w: stateset_core::Wishlist) -> WishlistResponse {
WishlistResponse {
id: w.id.to_string(),
customer_id: w.customer_id.to_string(),
name: w.name,
is_public: w.is_public,
items: w
.items
.into_iter()
.map(|i| WishlistItemResponse {
product_id: i.product_id.to_string(),
note: i.note,
added_at: i.added_at,
})
.collect(),
created_at: w.created_at,
updated_at: w.updated_at,
}
}
#[cfg(test)]
mod tests {}