//! Sprint use cases. use std::collections::{HashMap, HashSet}; use std::sync::Arc; use domain::{ Clock, DomainEvent, EventBus, IdGenerator, IssueActor, IssueListFilter, IssueRef, IssueStore, IssueVersion, Project, Sprint, SprintId, SprintIndexEntry, SprintOrder, SprintStatus, SprintStore, SprintVersion, }; use crate::error::AppError; /// Input for [`CreateSprint::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateSprintInput { /// Project owning the sprint. pub project: Project, /// Sprint name. pub name: String, /// Initial status. Defaults to planned. pub status: Option, /// Actor creating the sprint. pub actor: IssueActor, } /// Output of [`CreateSprint::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateSprintOutput { /// Created sprint. pub sprint: Sprint, } /// Creates a sprint at `max(order)+1`. pub struct CreateSprint { sprints: Arc, ids: Arc, clock: Arc, events: Arc, } impl CreateSprint { /// Builds the use case. #[must_use] pub fn new( sprints: Arc, ids: Arc, clock: Arc, events: Arc, ) -> Self { Self { sprints, ids, clock, events, } } /// Executes creation. /// /// # Errors /// [`AppError`] on invariant or store failure. pub async fn execute(&self, input: CreateSprintInput) -> Result { let rows = self.sprints.list(&input.project.root).await?; let next_order = rows.iter().map(|row| row.order.get()).max().unwrap_or(0) + 1; let sprint = Sprint::new( SprintId::from_uuid(self.ids.new_uuid()), SprintOrder::new(next_order).map_err(|err| AppError::Invalid(err.to_string()))?, input.name, input.status, input.actor, now(&self.clock), ) .map_err(|err| AppError::Invalid(err.to_string()))?; self.sprints.create(&input.project.root, &sprint).await?; self.events.publish(DomainEvent::SprintCreated { sprint_id: sprint.id, order: sprint.order, }); Ok(CreateSprintOutput { sprint }) } } /// Input for [`ListSprints::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListSprintsInput { /// Project owning the sprints. pub project: Project, } /// Sprint list row enriched with ticket count. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SprintListEntry { /// Sprint index projection. pub sprint: SprintIndexEntry, /// Number of tickets assigned to the sprint. pub ticket_count: usize, } /// Output of [`ListSprints::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListSprintsOutput { /// Sprints sorted by order. pub sprints: Vec, } /// Lists sprints with derived ticket counts. pub struct ListSprints { sprints: Arc, issues: Arc, } impl ListSprints { /// Builds the use case. #[must_use] pub fn new(sprints: Arc, issues: Arc) -> Self { Self { sprints, issues } } /// Executes list. /// /// # Errors /// [`AppError`] on store failure. pub async fn execute(&self, input: ListSprintsInput) -> Result { let rows = self.sprints.list(&input.project.root).await?; let mut out = Vec::with_capacity(rows.len()); for row in rows { let ticket_count = self .issues .list( &input.project.root, IssueListFilter { sprint: Some(row.id), ..IssueListFilter::default() }, ) .await? .len(); out.push(SprintListEntry { sprint: row, ticket_count, }); } Ok(ListSprintsOutput { sprints: out }) } } /// Input for [`RenameSprint::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenameSprintInput { /// Project owning the sprint. pub project: Project, /// Sprint id. pub sprint_id: SprintId, /// Expected optimistic version. pub expected_version: SprintVersion, /// New name. pub name: String, /// Actor updating the sprint. pub actor: IssueActor, } /// Output for sprint mutations. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SprintOutput { /// Updated sprint. pub sprint: Sprint, } /// Renames a sprint. pub struct RenameSprint { sprints: Arc, clock: Arc, events: Arc, } impl RenameSprint { /// Builds the use case. #[must_use] pub fn new( sprints: Arc, clock: Arc, events: Arc, ) -> Self { Self { sprints, clock, events, } } /// Executes rename. /// /// # Errors /// [`AppError`] on store failure or conflict. pub async fn execute(&self, input: RenameSprintInput) -> Result { let current = self .sprints .get(&input.project.root, input.sprint_id) .await?; let updated = current .mutate(input.actor, now(&self.clock), |sprint| { sprint.name = input.name; }) .map_err(|err| AppError::Invalid(err.to_string()))?; self.sprints .update(&input.project.root, &updated, input.expected_version) .await?; self.events.publish(DomainEvent::SprintRenamed { sprint_id: updated.id, name: updated.name.clone(), version: updated.version, }); Ok(SprintOutput { sprint: updated }) } } /// Input for [`ReorderSprints::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReorderSprintsInput { /// Project owning the sprints. pub project: Project, /// Complete ordered list of sprint ids. pub ordered_ids: Vec, /// Actor updating the sprints. pub actor: IssueActor, } /// Output of [`ReorderSprints::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReorderSprintsOutput { /// Updated sprints sorted by order. pub sprints: Vec, } /// Reorders all sprints and normalises orders to 1..n. pub struct ReorderSprints { sprints: Arc, clock: Arc, events: Arc, } impl ReorderSprints { /// Builds the use case. #[must_use] pub fn new( sprints: Arc, clock: Arc, events: Arc, ) -> Self { Self { sprints, clock, events, } } /// Executes reorder. /// /// # Errors /// [`AppError`] when ids are incomplete/duplicated or persistence fails. pub async fn execute( &self, input: ReorderSprintsInput, ) -> Result { let rows = self.sprints.list(&input.project.root).await?; let current_ids: HashSet = rows.iter().map(|row| row.id).collect(); let ordered_ids: HashSet = input.ordered_ids.iter().copied().collect(); if ordered_ids.len() != input.ordered_ids.len() || ordered_ids != current_ids { return Err(AppError::Invalid( "ordered sprint ids must contain every sprint exactly once".to_owned(), )); } let mut updated = Vec::with_capacity(input.ordered_ids.len()); for (idx, sprint_id) in input.ordered_ids.into_iter().enumerate() { let current = self.sprints.get(&input.project.root, sprint_id).await?; let expected_version = current.version; let order = SprintOrder::new((idx as u32) + 1) .map_err(|err| AppError::Invalid(err.to_string()))?; let next = current .mutate(input.actor.clone(), now(&self.clock), |sprint| { sprint.order = order; }) .map_err(|err| AppError::Invalid(err.to_string()))?; self.sprints .update(&input.project.root, &next, expected_version) .await?; self.events.publish(DomainEvent::SprintReordered { sprint_id: next.id, order: next.order, version: next.version, }); updated.push(next); } updated.sort_by_key(|sprint| sprint.order.get()); Ok(ReorderSprintsOutput { sprints: updated }) } } /// Input for [`DeleteSprint::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeleteSprintInput { /// Project owning the sprint. pub project: Project, /// Sprint id. pub sprint_id: SprintId, /// Actor deleting the sprint and unassigning tickets. pub actor: IssueActor, } /// Deletes a sprint and unassigns its tickets. pub struct DeleteSprint { sprints: Arc, issues: Arc, clock: Arc, events: Arc, } impl DeleteSprint { /// Builds the use case. #[must_use] pub fn new( sprints: Arc, issues: Arc, clock: Arc, events: Arc, ) -> Self { Self { sprints, issues, clock, events, } } /// Executes deletion. /// /// # Errors /// [`AppError`] on store failure or issue conflict. pub async fn execute(&self, input: DeleteSprintInput) -> Result<(), AppError> { self.sprints .get(&input.project.root, input.sprint_id) .await?; let assigned = self .issues .list( &input.project.root, IssueListFilter { sprint: Some(input.sprint_id), ..IssueListFilter::default() }, ) .await?; for row in assigned { self.assign_issue( &input.project, row.issue_ref, None, input.actor.clone(), now(&self.clock), ) .await?; } self.sprints .delete(&input.project.root, input.sprint_id) .await?; self.events.publish(DomainEvent::SprintDeleted { sprint_id: input.sprint_id, }); Ok(()) } async fn assign_issue( &self, project: &Project, issue_ref: IssueRef, sprint_id: Option, actor: IssueActor, now_ms: u64, ) -> Result<(), AppError> { let current = self.issues.get_by_ref(&project.root, issue_ref).await?; let expected_version = current.version; let from = current.sprint; if from == sprint_id { return Ok(()); } let updated = current .mutate(actor, now_ms, |issue| issue.sprint = sprint_id) .map_err(|err| AppError::Invalid(err.to_string()))?; self.issues .update(&project.root, &updated, expected_version) .await?; publish_issue_sprint_changed(&self.events, issue_ref, from, sprint_id, updated.version); Ok(()) } } /// Input for [`AssignTicketToSprint::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AssignTicketToSprintInput { /// Project owning the issue. pub project: Project, /// Issue reference. pub issue_ref: IssueRef, /// Target sprint. pub sprint_id: SprintId, /// Expected issue optimistic version. pub expected_version: IssueVersion, /// Actor updating the issue. pub actor: IssueActor, } /// Input for [`UnassignTicketFromSprint::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnassignTicketFromSprintInput { /// Project owning the issue. pub project: Project, /// Issue reference. pub issue_ref: IssueRef, /// Expected issue optimistic version. pub expected_version: IssueVersion, /// Actor updating the issue. pub actor: IssueActor, } /// Output of ticket/sprint assignment use cases. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AssignTicketToSprintOutput { /// Updated issue. pub issue: domain::Issue, } /// Assigns a ticket to a sprint via `IssueStore.update`. pub struct AssignTicketToSprint { sprints: Arc, issues: Arc, clock: Arc, events: Arc, } impl AssignTicketToSprint { /// Builds the use case. #[must_use] pub fn new( sprints: Arc, issues: Arc, clock: Arc, events: Arc, ) -> Self { Self { sprints, issues, clock, events, } } /// Executes assignment. /// /// # Errors /// [`AppError`] when the sprint/issue is missing or the issue version conflicts. pub async fn execute( &self, input: AssignTicketToSprintInput, ) -> Result { self.sprints .get(&input.project.root, input.sprint_id) .await?; assign_ticket_sprint( &self.issues, &self.clock, &self.events, input.project, input.issue_ref, Some(input.sprint_id), input.expected_version, input.actor, ) .await } } /// Unassigns a ticket from its sprint via `IssueStore.update`. pub struct UnassignTicketFromSprint { issues: Arc, clock: Arc, events: Arc, } impl UnassignTicketFromSprint { /// Builds the use case. #[must_use] pub fn new( issues: Arc, clock: Arc, events: Arc, ) -> Self { Self { issues, clock, events, } } /// Executes unassignment. /// /// # Errors /// [`AppError`] when the issue is missing or its version conflicts. pub async fn execute( &self, input: UnassignTicketFromSprintInput, ) -> Result { assign_ticket_sprint( &self.issues, &self.clock, &self.events, input.project, input.issue_ref, None, input.expected_version, input.actor, ) .await } } async fn assign_ticket_sprint( issues: &Arc, clock: &Arc, events: &Arc, project: Project, issue_ref: IssueRef, sprint_id: Option, expected_version: IssueVersion, actor: IssueActor, ) -> Result { let current = issues.get_by_ref(&project.root, issue_ref).await?; let from = current.sprint; let updated = current .mutate(actor, now(clock), |issue| issue.sprint = sprint_id) .map_err(|err| AppError::Invalid(err.to_string()))?; issues .update(&project.root, &updated, expected_version) .await?; if from != sprint_id { publish_issue_sprint_changed(events, issue_ref, from, sprint_id, updated.version); } Ok(AssignTicketToSprintOutput { issue: updated }) } fn publish_issue_sprint_changed( events: &Arc, issue_ref: IssueRef, from: Option, to: Option, version: IssueVersion, ) { events.publish(DomainEvent::IssueUpdated { issue_ref, version }); events.publish(DomainEvent::IssueSprintChanged { issue_ref, from, to, version, }); } fn now(clock: &Arc) -> u64 { clock.now_millis().max(0) as u64 } /// Returns sprints in the requested order with contiguous order values. /// /// # Errors /// [`AppError::Invalid`] when the requested ids are not a complete permutation. pub fn normalized_reorder( sprints: &[Sprint], ordered_ids: &[SprintId], actor: IssueActor, now_ms: u64, ) -> Result, AppError> { let by_id: HashMap = sprints .iter() .cloned() .map(|sprint| (sprint.id, sprint)) .collect(); if by_id.len() != sprints.len() || ordered_ids.len() != sprints.len() { return Err(AppError::Invalid( "ordered sprint ids must contain every sprint exactly once".to_owned(), )); } let mut seen = HashSet::new(); let mut out = Vec::with_capacity(ordered_ids.len()); for (idx, sprint_id) in ordered_ids.iter().copied().enumerate() { if !seen.insert(sprint_id) { return Err(AppError::Invalid( "ordered sprint ids must contain every sprint exactly once".to_owned(), )); } let Some(current) = by_id.get(&sprint_id) else { return Err(AppError::Invalid( "ordered sprint ids must contain every sprint exactly once".to_owned(), )); }; let order = SprintOrder::new((idx as u32) + 1).map_err(|err| AppError::Invalid(err.to_string()))?; out.push( current .clone() .mutate(actor.clone(), now_ms, |sprint| sprint.order = order) .map_err(|err| AppError::Invalid(err.to_string()))?, ); } Ok(out) }