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 CreateLoyaltyProgramRequest {
pub name: String,
pub points_per_dollar: Option<u32>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub(crate) struct EnrollCustomerRequest {
#[schema(value_type = String, format = "uuid")]
pub program_id: String,
#[schema(value_type = String, format = "uuid")]
pub customer_id: String,
}
#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
#[into_params(parameter_in = Query)]
pub(crate) struct LoyaltyProgramFilterParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
impl LoyaltyProgramFilterParams {
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 LoyaltyProgramResponse {
#[schema(value_type = String, format = "uuid")]
pub id: String,
pub name: String,
pub description: String,
pub points_per_dollar: u32,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub(crate) struct LoyaltyProgramListResponse {
pub programs: Vec<LoyaltyProgramResponse>,
pub total: usize,
pub limit: u32,
pub offset: u32,
pub has_more: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub(crate) struct LoyaltyAccountResponse {
#[schema(value_type = String, format = "uuid")]
pub id: String,
#[schema(value_type = String, format = "uuid")]
pub program_id: String,
#[schema(value_type = String, format = "uuid")]
pub customer_id: String,
pub points_balance: i64,
pub tier: String,
pub lifetime_points: u64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/loyalty/programs", post(create_program).get(list_programs))
.route("/loyalty/enroll", post(enroll_customer))
.route("/loyalty/accounts/{id}", get(get_account))
}
#[utoipa::path(
post,
path = "/api/v1/loyalty/programs",
tag = "loyalty",
request_body = CreateLoyaltyProgramRequest,
responses(
(status = 201, description = "Loyalty program created", body = LoyaltyProgramResponse),
(status = 400, description = "Invalid request", body = ErrorBody),
)
)]
#[tracing::instrument(skip(state, headers, req))]
pub(crate) async fn create_program(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateLoyaltyProgramRequest>,
) -> Result<(StatusCode, Json<LoyaltyProgramResponse>), HttpError> {
let tenant_id = tenant_id_from_headers(&headers);
let commerce = state.commerce_for_tenant(tenant_id.as_deref())?;
let program = commerce.loyalty().create_program(stateset_core::CreateLoyaltyProgram {
name: req.name,
description: req.description,
points_per_dollar: req.points_per_dollar.unwrap_or(1),
tiers: vec![],
})?;
Ok((StatusCode::CREATED, Json(program_to_response(program))))
}
#[utoipa::path(
get,
path = "/api/v1/loyalty/programs",
tag = "loyalty",
params(LoyaltyProgramFilterParams),
responses(
(status = 200, description = "List of loyalty programs", body = LoyaltyProgramListResponse),
(status = 400, description = "Invalid filter parameter", body = ErrorBody),
)
)]
#[tracing::instrument(skip(state, headers, params))]
pub(crate) async fn list_programs(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<LoyaltyProgramFilterParams>,
) -> Result<Json<LoyaltyProgramListResponse>, 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 all_programs = commerce.loyalty().list_programs()?;
let total = all_programs.len();
let mut programs: Vec<_> =
all_programs.into_iter().skip(offset as usize).take(limit as usize + 1).collect();
let has_more = programs.len() > limit as usize;
if has_more {
programs.truncate(limit as usize);
}
Ok(Json(LoyaltyProgramListResponse {
programs: programs.into_iter().map(program_to_response).collect(),
total,
limit,
offset,
has_more,
}))
}
#[utoipa::path(
post,
path = "/api/v1/loyalty/enroll",
tag = "loyalty",
request_body = EnrollCustomerRequest,
responses(
(status = 201, description = "Customer enrolled", body = LoyaltyAccountResponse),
(status = 400, description = "Invalid request", body = ErrorBody),
(status = 404, description = "Program or customer not found", body = ErrorBody),
)
)]
#[tracing::instrument(skip(state, headers, req))]
pub(crate) async fn enroll_customer(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<EnrollCustomerRequest>,
) -> Result<(StatusCode, Json<LoyaltyAccountResponse>), HttpError> {
let tenant_id = tenant_id_from_headers(&headers);
let commerce = state.commerce_for_tenant(tenant_id.as_deref())?;
let program_id: stateset_primitives::LoyaltyProgramId = req
.program_id
.parse()
.map_err(|e| HttpError::BadRequest(format!("Invalid program_id: {e}")))?;
let customer_id: stateset_primitives::CustomerId = req
.customer_id
.parse()
.map_err(|e| HttpError::BadRequest(format!("Invalid customer_id: {e}")))?;
let account =
commerce.loyalty().enroll(stateset_core::EnrollCustomer { customer_id, program_id })?;
Ok((StatusCode::CREATED, Json(account_to_response(account))))
}
#[utoipa::path(
get,
path = "/api/v1/loyalty/accounts/{id}",
tag = "loyalty",
params(("id" = String, Path, description = "Loyalty account ID (UUID)")),
responses(
(status = 200, description = "Loyalty account details", body = LoyaltyAccountResponse),
(status = 404, description = "Loyalty account not found", body = ErrorBody),
)
)]
#[tracing::instrument(skip(state, headers))]
pub(crate) async fn get_account(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<stateset_primitives::LoyaltyAccountId>,
) -> Result<Json<LoyaltyAccountResponse>, HttpError> {
let tenant_id = tenant_id_from_headers(&headers);
let commerce = state.commerce_for_tenant(tenant_id.as_deref())?;
let account = commerce
.loyalty()
.get_account(id)?
.ok_or_else(|| HttpError::NotFound(format!("Loyalty account {id} not found")))?;
Ok(Json(account_to_response(account)))
}
fn program_to_response(p: stateset_core::LoyaltyProgram) -> LoyaltyProgramResponse {
let is_active = p.is_active();
LoyaltyProgramResponse {
id: p.id.to_string(),
name: p.name,
description: p.description.unwrap_or_default(),
points_per_dollar: p.points_per_dollar,
is_active,
created_at: p.created_at,
updated_at: p.updated_at,
}
}
fn account_to_response(a: stateset_core::LoyaltyAccount) -> LoyaltyAccountResponse {
LoyaltyAccountResponse {
id: a.id.to_string(),
program_id: a.program_id.to_string(),
customer_id: a.customer_id.to_string(),
points_balance: a.points_balance,
tier: a.tier,
lifetime_points: a.lifetime_points,
created_at: a.created_at,
updated_at: a.updated_at,
}
}
#[cfg(test)]
mod tests {}