use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::{Json, extract::rejection::JsonRejection};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::daemon::error::DaemonError;
use crate::daemon::state::DaemonState;
use crate::deliverable::manager::UpdateError;
use crate::deliverable::{
Deliverable, DeliverableId, DeliverableKind, DeliverableStatus, EstimationTier, Milestone,
MilestoneId, MilestoneStatus, StoreError, validate_transition,
};
#[cfg(test)]
mod tests;
fn store_internal(e: StoreError) -> DaemonError {
DaemonError::Internal(e.to_string())
}
fn deliverable_lookup_err(e: StoreError, id: &str) -> DaemonError {
match e {
StoreError::NotFound(_) => DaemonError::DeliverableNotFound { id: id.to_string() },
other => store_internal(other),
}
}
fn milestone_lookup_err(e: StoreError, id: &str) -> DaemonError {
match e {
StoreError::NotFound(_) => DaemonError::MilestoneNotFound { id: id.to_string() },
other => store_internal(other),
}
}
fn update_err(e: UpdateError, id: &str) -> DaemonError {
match e {
UpdateError::Store(store_err) => deliverable_lookup_err(store_err, id),
UpdateError::Transition(te) => DaemonError::InvalidTransition {
from: te.from.to_string(),
to: te.to.to_string(),
allowed: te.allowed.iter().map(|s| s.as_str().to_string()).collect(),
},
}
}
fn reject_blank_patch_name(name: &Option<String>, resource: &str) -> Result<(), DaemonError> {
if let Some(n) = name
&& n.trim().is_empty()
{
return Err(DaemonError::InvalidRequest(format!(
"{resource} name must not be empty"
)));
}
Ok(())
}
fn body<T>(body: Result<Json<T>, JsonRejection>) -> Result<T, DaemonError> {
body.map(|Json(v)| v)
.map_err(|e| DaemonError::InvalidRequest(e.body_text()))
}
#[derive(Debug, Deserialize)]
pub struct CreateDeliverable {
pub name: String,
#[serde(default)]
pub description: String,
pub kind: DeliverableKind,
pub estimated_effort: EstimationTier,
#[serde(default)]
pub ticket_ref: Option<String>,
#[serde(default)]
pub spec_ref: Option<String>,
#[serde(default)]
pub target_date: Option<DateTime<Utc>>,
}
#[derive(Debug, Default, Deserialize)]
pub struct PatchDeliverable {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub kind: Option<DeliverableKind>,
#[serde(default)]
pub estimated_effort: Option<EstimationTier>,
#[serde(default)]
pub ticket_ref: Option<String>,
#[serde(default)]
pub spec_ref: Option<String>,
#[serde(default)]
pub status: Option<DeliverableStatus>,
#[serde(default)]
pub target_date: Option<DateTime<Utc>>,
}
#[derive(Debug, Default, Deserialize)]
pub struct DeliverableListQuery {
#[serde(default)]
pub status: Option<DeliverableStatus>,
}
#[derive(Debug, Serialize)]
pub struct DeliverablesResponse {
pub deliverables: Vec<Deliverable>,
}
pub async fn create_deliverable(
State(state): State<Arc<DaemonState>>,
Path(project): Path<String>,
body_in: Result<Json<CreateDeliverable>, JsonRejection>,
) -> Result<(StatusCode, Json<Deliverable>), DaemonError> {
let req = body(body_in)?;
Ok((
StatusCode::CREATED,
Json(create_deliverable_op(&state, project, req).await?),
))
}
pub async fn create_deliverable_op(
state: &Arc<DaemonState>,
project: String,
req: CreateDeliverable,
) -> Result<Deliverable, DaemonError> {
if req.name.trim().is_empty() {
return Err(DaemonError::InvalidRequest(
"deliverable name must not be empty".into(),
));
}
let deliverable = Deliverable {
id: DeliverableId::new(),
project_name: project,
name: req.name,
description: req.description,
kind: req.kind,
ticket_ref: req.ticket_ref,
spec_ref: req.spec_ref,
status: DeliverableStatus::Proposed,
estimated_effort: req.estimated_effort,
created_at: Utc::now(),
target_date: req.target_date,
};
let mgr = state.deliverable_manager().await;
mgr.upsert_deliverable(deliverable.clone())
.await
.map_err(store_internal)?;
Ok(deliverable)
}
pub async fn list_deliverables(
State(state): State<Arc<DaemonState>>,
Path(project): Path<String>,
Query(query): Query<DeliverableListQuery>,
) -> Result<Json<DeliverablesResponse>, DaemonError> {
Ok(Json(list_deliverables_op(&state, &project, query).await?))
}
pub async fn list_deliverables_op(
state: &Arc<DaemonState>,
project: &str,
query: DeliverableListQuery,
) -> Result<DeliverablesResponse, DaemonError> {
let mgr = state.deliverable_manager().await;
let mut deliverables = mgr
.deliverables_by_project(project)
.await
.map_err(store_internal)?;
if let Some(status) = query.status {
deliverables.retain(|d| d.status == status);
}
Ok(DeliverablesResponse { deliverables })
}
pub(crate) async fn fetch_scoped(
state: &Arc<DaemonState>,
project: &str,
id: &str,
) -> Result<Deliverable, DaemonError> {
let mgr = state.deliverable_manager().await;
let d = mgr
.get_deliverable(id)
.await
.map_err(|e| deliverable_lookup_err(e, id))?;
if d.project_name != project {
return Err(DaemonError::DeliverableNotFound { id: id.to_string() });
}
Ok(d)
}
pub async fn get_deliverable(
State(state): State<Arc<DaemonState>>,
Path((project, id)): Path<(String, String)>,
) -> Result<Json<Deliverable>, DaemonError> {
Ok(Json(fetch_scoped(&state, &project, &id).await?))
}
pub async fn patch_deliverable(
State(state): State<Arc<DaemonState>>,
Path((project, id)): Path<(String, String)>,
body_in: Result<Json<PatchDeliverable>, JsonRejection>,
) -> Result<Json<Deliverable>, DaemonError> {
let patch = body(body_in)?;
Ok(Json(
patch_deliverable_op(&state, &project, &id, patch).await?,
))
}
pub async fn patch_deliverable_op(
state: &Arc<DaemonState>,
project: &str,
id: &str,
patch: PatchDeliverable,
) -> Result<Deliverable, DaemonError> {
reject_blank_patch_name(&patch.name, "deliverable")?;
let mgr = state.deliverable_manager().await;
let updated = mgr
.update_deliverable_with(id, project, move |mut d| {
if let Some(new_status) = patch.status
&& new_status != d.status
{
validate_transition(d.status, new_status)?;
d.status = new_status;
}
if let Some(name) = patch.name {
d.name = name;
}
if let Some(description) = patch.description {
d.description = description;
}
if let Some(kind) = patch.kind {
d.kind = kind;
}
if let Some(effort) = patch.estimated_effort {
d.estimated_effort = effort;
}
if patch.ticket_ref.is_some() {
d.ticket_ref = patch.ticket_ref;
}
if patch.spec_ref.is_some() {
d.spec_ref = patch.spec_ref;
}
if patch.target_date.is_some() {
d.target_date = patch.target_date;
}
Ok(d)
})
.await
.map_err(|e| update_err(e, id))?;
Ok(updated)
}
#[derive(Debug, Deserialize)]
pub struct CreateMilestone {
pub name: String,
#[serde(default)]
pub description: String,
pub target_date: DateTime<Utc>,
#[serde(default)]
pub deliverables: Vec<DeliverableId>,
#[serde(default)]
pub status: MilestoneStatus,
}
#[derive(Debug, Default, Deserialize)]
pub struct PatchMilestone {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub target_date: Option<DateTime<Utc>>,
#[serde(default)]
pub status: Option<MilestoneStatus>,
#[serde(default)]
pub deliverables: Option<Vec<DeliverableId>>,
}
#[derive(Debug, Serialize)]
pub struct MilestonesResponse {
pub milestones: Vec<Milestone>,
}
pub async fn create_milestone(
State(state): State<Arc<DaemonState>>,
Path(project): Path<String>,
body_in: Result<Json<CreateMilestone>, JsonRejection>,
) -> Result<(StatusCode, Json<Milestone>), DaemonError> {
let req = body(body_in)?;
Ok((
StatusCode::CREATED,
Json(create_milestone_op(&state, project, req).await?),
))
}
pub async fn create_milestone_op(
state: &Arc<DaemonState>,
project: String,
req: CreateMilestone,
) -> Result<Milestone, DaemonError> {
if req.name.trim().is_empty() {
return Err(DaemonError::InvalidRequest(
"milestone name must not be empty".into(),
));
}
let milestone = Milestone {
id: MilestoneId::new(),
project_name: project,
name: req.name,
description: req.description,
target_date: req.target_date,
status: req.status,
deliverables: req.deliverables,
created_at: Utc::now(),
};
let mgr = state.deliverable_manager().await;
mgr.upsert_milestone(milestone.clone())
.await
.map_err(store_internal)?;
Ok(milestone)
}
pub async fn list_milestones(
State(state): State<Arc<DaemonState>>,
Path(project): Path<String>,
) -> Result<Json<MilestonesResponse>, DaemonError> {
Ok(Json(list_milestones_op(&state, &project).await?))
}
pub async fn list_milestones_op(
state: &Arc<DaemonState>,
project: &str,
) -> Result<MilestonesResponse, DaemonError> {
let mgr = state.deliverable_manager().await;
let milestones = mgr
.milestones_by_project(project)
.await
.map_err(store_internal)?;
Ok(MilestonesResponse { milestones })
}
pub(crate) async fn fetch_scoped_milestone(
state: &Arc<DaemonState>,
project: &str,
id: &str,
) -> Result<Milestone, DaemonError> {
let mgr = state.deliverable_manager().await;
let m = mgr
.get_milestone(id)
.await
.map_err(|e| milestone_lookup_err(e, id))?;
if m.project_name != project {
return Err(DaemonError::MilestoneNotFound { id: id.to_string() });
}
Ok(m)
}
pub async fn get_milestone(
State(state): State<Arc<DaemonState>>,
Path((project, id)): Path<(String, String)>,
) -> Result<Json<Milestone>, DaemonError> {
Ok(Json(fetch_scoped_milestone(&state, &project, &id).await?))
}
pub async fn patch_milestone(
State(state): State<Arc<DaemonState>>,
Path((project, id)): Path<(String, String)>,
body_in: Result<Json<PatchMilestone>, JsonRejection>,
) -> Result<Json<Milestone>, DaemonError> {
let patch = body(body_in)?;
Ok(Json(
patch_milestone_op(&state, &project, &id, patch).await?,
))
}
pub async fn patch_milestone_op(
state: &Arc<DaemonState>,
project: &str,
id: &str,
patch: PatchMilestone,
) -> Result<Milestone, DaemonError> {
reject_blank_patch_name(&patch.name, "milestone")?;
let mgr = state.deliverable_manager().await;
let updated = mgr
.update_milestone_with(id, project, move |mut m| {
if let Some(name) = patch.name {
m.name = name;
}
if let Some(description) = patch.description {
m.description = description;
}
if let Some(target_date) = patch.target_date {
m.target_date = target_date;
}
if let Some(status) = patch.status {
m.status = status;
}
if let Some(deliverables) = patch.deliverables {
m.deliverables = deliverables;
}
m
})
.await
.map_err(|e| milestone_lookup_err(e, id))?;
Ok(updated)
}