summaryrefslogblamecommitdiff
path: root/makima/src/server/handlers/orders.rs
blob: 03719cb7d301a8c01de085aea24563d6e70cbd81 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12
13












                                                                        
                                      



















                                                                                       
                                                                                                   
                                                                                                                      




























                                                                                 
                     
                                

















                                                                     
                                                         





                                                                    
                                                                              


















                                                                                 






















                                                                                                 



























































































































































































































                                                                                                
                                         

                                                                                           



                                                          












                                                                                                








                                                                             
                                                                            


                                                                            



                                                             











                                                                      
//! HTTP handlers for order CRUD operations.
//! Orders are card-based work items (features, bugs, spikes) similar to
//! GitHub Issues or Linear cards.

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use uuid::Uuid;

use crate::db::models::{
    CreateOrderRequest, DirectiveStep,
    LinkDirectiveRequest, Order, OrderListQuery, OrderListResponse, UpdateOrderRequest,
};
use crate::db::repository;
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
use crate::server::state::SharedState;

// =============================================================================
// Order CRUD
// =============================================================================

/// List all orders for the authenticated user.
#[utoipa::path(
    get,
    path = "/api/v1/orders",
    params(
        ("status" = Option<String>, Query, description = "Filter by status"),
        ("type" = Option<String>, Query, description = "Filter by order type"),
        ("priority" = Option<String>, Query, description = "Filter by priority"),
        ("directive_id" = Option<Uuid>, Query, description = "Filter by directive ID"),
        ("dog_id" = Option<Uuid>, Query, description = "Filter by DOG (Directive Order Group) ID"),
        ("search" = Option<String>, Query, description = "Text search across title, description, and directive name"),
    ),
    responses(
        (status = 200, description = "List of orders", body = OrderListResponse),
        (status = 401, description = "Unauthorized", body = ApiError),
        (status = 503, description = "Database not configured", body = ApiError),
    ),
    security(("bearer_auth" = []), ("api_key" = [])),
    tag = "Orders"
)]
pub async fn list_orders(
    State(state): State<SharedState>,
    Authenticated(auth): Authenticated,
    Query(query): Query<OrderListQuery>,
) -> impl IntoResponse {
    let Some(ref pool) = state.db_pool else {
        return (
            StatusCode::SERVICE_UNAVAILABLE,
            Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
        )
            .into_response();
    };

    match repository::list_orders(
        pool,
        auth.owner_id,
        query.status.as_deref(),
        query.order_type.as_deref(),
        query.priority.as_deref(),
        query.directive_id,
        query.dog_id,
        query.search.as_deref(),
    )
    .await
    {
        Ok(orders) => {
            let total = orders.len() as i64;
            Json(OrderListResponse { orders, total }).into_response()
        }
        Err(e) => {
            tracing::error!("Failed to list orders: {}", e);
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(ApiError::new("LIST_FAILED", &e.to_string())),
            )
                .into_response()
        }
    }
}

/// Create a new order. A valid directive_id is required.
#[utoipa::path(
    post,
    path = "/api/v1/orders",
    request_body = CreateOrderRequest,
    responses(
        (status = 201, description = "Order created", body = Order),
        (status = 400, description = "Invalid directive_id", body = ApiError),
        (status = 401, description = "Unauthorized", body = ApiError),
        (status = 503, description = "Database not configured", body = ApiError),
    ),
    security(("bearer_auth" = []), ("api_key" = [])),
    tag = "Orders"
)]
pub async fn create_order(
    State(state): State<SharedState>,
    Authenticated(auth): Authenticated,
    Json(req): Json<CreateOrderRequest>,
) -> impl IntoResponse {
    let Some(ref pool) = state.db_pool else {
        return (
            StatusCode::SERVICE_UNAVAILABLE,
            Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
        )
            .into_response();
    };

    // Validate the directive exists and belongs to this owner.
    // directive_id is required by the CreateOrderRequest struct (Uuid, not Option<Uuid>).
    match repository::get_directive_for_owner(pool, auth.owner_id, req.directive_id).await {
        Ok(Some(_)) => {}
        Ok(None) => {
            return (
                StatusCode::BAD_REQUEST,
                Json(ApiError::new(
                    "INVALID_DIRECTIVE",
                    "directive_id is required and must reference a valid directive owned by you",
                )),
            )
                .into_response();
        }
        Err(e) => {
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(ApiError::new("VALIDATION_FAILED", &e.to_string())),
            )
                .into_response();
        }
    }

    match repository::create_order(pool, auth.owner_id, req).await {
        Ok(order) => (StatusCode::CREATED, Json(order)).into_response(),
        Err(e) => {
            tracing::error!("Failed to create order: {}", e);
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(ApiError::new("CREATE_FAILED", &e.to_string())),
            )
                .into_response()
        }
    }
}

/// Get an order by ID.
#[utoipa::path(
    get,
    path = "/api/v1/orders/{id}",
    params(("id" = Uuid, Path, description = "Order ID")),
    responses(
        (status = 200, description = "Order details", body = Order),
        (status = 404, description = "Not found", body = ApiError),
        (status = 401, description = "Unauthorized", body = ApiError),
        (status = 503, description = "Database not configured", body = ApiError),
    ),
    security(("bearer_auth" = []), ("api_key" = [])),
    tag = "Orders"
)]
pub async fn get_order(
    State(state): State<SharedState>,
    Authenticated(auth): Authenticated,
    Path(id): Path<Uuid>,
) -> impl IntoResponse {
    let Some(ref pool) = state.db_pool else {
        return (
            StatusCode::SERVICE_UNAVAILABLE,
            Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
        )
            .into_response();
    };

    match repository::get_order(pool, auth.owner_id, id).await {
        Ok(Some(order)) => Json(order).into_response(),
        Ok(None) => (
            StatusCode::NOT_FOUND,
            Json(ApiError::new("NOT_FOUND", "Order not found")),
        )
            .into_response(),
        Err(e) => {
            tracing::error!("Failed to get order: {}", e);
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(ApiError::new("GET_FAILED", &e.to_string())),
            )
                .into_response()
        }
    }
}

/// Update an order.
#[utoipa::path(
    patch,
    path = "/api/v1/orders/{id}",
    params(("id" = Uuid, Path, description = "Order ID")),
    request_body = UpdateOrderRequest,
    responses(
        (status = 200, description = "Order updated", body = Order),
        (status = 404, description = "Not found", body = ApiError),
        (status = 401, description = "Unauthorized", body = ApiError),
        (status = 503, description = "Database not configured", body = ApiError),
    ),
    security(("bearer_auth" = []), ("api_key" = [])),
    tag = "Orders"
)]
pub async fn update_order(
    State(state): State<SharedState>,
    Authenticated(auth): Authenticated,
    Path(id): Path<Uuid>,
    Json(req): Json<UpdateOrderRequest>,
) -> impl IntoResponse {
    let Some(ref pool) = state.db_pool else {
        return (
            StatusCode::SERVICE_UNAVAILABLE,
            Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
        )
            .into_response();
    };

    match repository::update_order(pool, auth.owner_id, id, req).await {
        Ok(Some(order)) => Json(order).into_response(),
        Ok(None) => (
            StatusCode::NOT_FOUND,
            Json(ApiError::new("NOT_FOUND", "Order not found")),
        )
            .into_response(),
        Err(e) => {
            tracing::error!("Failed to update order: {}", e);
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(ApiError::new("UPDATE_FAILED", &e.to_string())),
            )
                .into_response()
        }
    }
}

/// Delete an order.
#[utoipa::path(
    delete,
    path = "/api/v1/orders/{id}",
    params(("id" = Uuid, Path, description = "Order ID")),
    responses(
        (status = 204, description = "Deleted"),
        (status = 404, description = "Not found", body = ApiError),
        (status = 401, description = "Unauthorized", body = ApiError),
        (status = 503, description = "Database not configured", body = ApiError),
    ),
    security(("bearer_auth" = []), ("api_key" = [])),
    tag = "Orders"
)]
pub async fn delete_order(
    State(state): State<SharedState>,
    Authenticated(auth): Authenticated,
    Path(id): Path<Uuid>,
) -> impl IntoResponse {
    let Some(ref pool) = state.db_pool else {
        return (
            StatusCode::SERVICE_UNAVAILABLE,
            Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
        )
            .into_response();
    };

    match repository::delete_order(pool, auth.owner_id, id).await {
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
        Ok(false) => (
            StatusCode::NOT_FOUND,
            Json(ApiError::new("NOT_FOUND", "Order not found")),
        )
            .into_response(),
        Err(e) => {
            tracing::error!("Failed to delete order: {}", e);
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(ApiError::new("DELETE_FAILED", &e.to_string())),
            )
                .into_response()
        }
    }
}

// =============================================================================
// Order Linking & Conversion
// =============================================================================

/// Link an order to a directive.
#[utoipa::path(
    post,
    path = "/api/v1/orders/{id}/link-directive",
    params(("id" = Uuid, Path, description = "Order ID")),
    request_body = LinkDirectiveRequest,
    responses(
        (status = 200, description = "Order linked to directive", body = Order),
        (status = 404, description = "Not found", body = ApiError),
        (status = 401, description = "Unauthorized", body = ApiError),
        (status = 503, description = "Database not configured", body = ApiError),
    ),
    security(("bearer_auth" = []), ("api_key" = [])),
    tag = "Orders"
)]
pub async fn link_to_directive(
    State(state): State<SharedState>,
    Authenticated(auth): Authenticated,
    Path(id): Path<Uuid>,
    Json(req): Json<LinkDirectiveRequest>,
) -> impl IntoResponse {
    let Some(ref pool) = state.db_pool else {
        return (
            StatusCode::SERVICE_UNAVAILABLE,
            Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
        )
            .into_response();
    };

    // Verify the directive exists and belongs to this owner
    match repository::get_directive_for_owner(pool, auth.owner_id, req.directive_id).await {
        Ok(Some(_)) => {}
        Ok(None) => {
            return (
                StatusCode::NOT_FOUND,
                Json(ApiError::new("NOT_FOUND", "Directive not found")),
            )
                .into_response();
        }
        Err(e) => {
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(ApiError::new("GET_FAILED", &e.to_string())),
            )
                .into_response();
        }
    }

    match repository::link_order_to_directive(pool, auth.owner_id, id, req.directive_id).await {
        Ok(Some(order)) => Json(order).into_response(),
        Ok(None) => (
            StatusCode::NOT_FOUND,
            Json(ApiError::new("NOT_FOUND", "Order not found")),
        )
            .into_response(),
        Err(e) => {
            tracing::error!("Failed to link order to directive: {}", e);
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(ApiError::new("LINK_FAILED", &e.to_string())),
            )
                .into_response()
        }
    }
}

/// Convert an order to a directive step.
/// Creates a new step in the order's linked directive using the order's title/description,
/// and links the order to the new step. The order must have a directive_id set.
#[utoipa::path(
    post,
    path = "/api/v1/orders/{id}/convert-to-step",
    params(("id" = Uuid, Path, description = "Order ID")),
    responses(
        (status = 201, description = "Directive step created from order", body = DirectiveStep),
        (status = 404, description = "Order or directive not found", body = ApiError),
        (status = 401, description = "Unauthorized", body = ApiError),
        (status = 503, description = "Database not configured", body = ApiError),
    ),
    security(("bearer_auth" = []), ("api_key" = [])),
    tag = "Orders"
)]
pub async fn convert_to_step(
    State(state): State<SharedState>,
    Authenticated(auth): Authenticated,
    Path(id): Path<Uuid>,
) -> impl IntoResponse {
    let Some(ref pool) = state.db_pool else {
        return (
            StatusCode::SERVICE_UNAVAILABLE,
            Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
        )
            .into_response();
    };

    match repository::convert_order_to_step(pool, auth.owner_id, id).await {
        Ok(Some(step)) => (StatusCode::CREATED, Json(step)).into_response(),
        Ok(None) => (
            StatusCode::NOT_FOUND,
            Json(ApiError::new(
                "NOT_FOUND",
                "Order not found or has no linked directive",
            )),
        )
            .into_response(),
        Err(e) => {
            tracing::error!("Failed to convert order to step: {}", e);
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(ApiError::new("CONVERT_FAILED", &e.to_string())),
            )
                .into_response()
        }
    }
}