//! Problem domain types and DTOs. //! //! GoingsOn is the list of solutions; other systems are lists of problems. A //! [`Problem`] is a candidate pulled from one of those systems that a human //! promotes into a task. Nothing here creates a task on its own. //! //! Problems are a cached mirror keyed on `(source, source_ref)`. Adapters re-pull //! and upsert; the triage decision ([`ProblemStatus`] and `promoted_task_id`) //! survives that because upserts leave it alone. //! //! Urgency is not a stored field. It is the painhours score, computed from //! `pain`, `scale`, and age by the shared `painhours` crate so the ranking //! matches wam's. See [`Problem::painhours`]. use super::shared::{CssClass, DbValue, ParseableEnum}; use crate::id_types::{ProblemId, ProjectId, TaskId}; use chrono::{DateTime, Utc}; /// The painhours color band, re-exported under a name that does not collide /// with GoingsOn's own task [`Priority`](super::Priority). Derived from the /// score, never stored. pub use painhours::Priority as ProblemBand; use serde::{Deserialize, Serialize}; use strum_macros::EnumString; /// Where a problem is in triage. /// /// Only `Open` problems are unaddressed. Everything else is settled and stops /// climbing the list; see [`Problem::age_anchor`]. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, EnumString)] pub enum ProblemStatus { /// Untriaged. Sitting in the inbox, ageing, waiting for a decision. #[strum(serialize = "Open")] #[default] Open, /// A task was created from it. `promoted_task_id` points at the solution. #[strum(serialize = "Promoted")] Promoted, /// Seen and deliberately not acted on. #[strum(serialize = "Dismissed")] Dismissed, /// The source says it is fixed, without GoingsOn having promoted it. #[strum(serialize = "Resolved")] Resolved, } impl ProblemStatus { /// Returns a human-readable display string. pub fn as_str(&self) -> &'static str { match self { ProblemStatus::Open => "Open", ProblemStatus::Promoted => "Promoted", ProblemStatus::Dismissed => "Dismissed", ProblemStatus::Resolved => "Resolved", } } /// Whether the problem has left the open set. Settled problems freeze their /// age anchor, so they stop climbing the ranking. pub fn is_settled(&self) -> bool { !matches!(self, ProblemStatus::Open) } } impl ParseableEnum for ProblemStatus {} impl DbValue for ProblemStatus { fn db_value(&self) -> &'static str { match self { ProblemStatus::Open => "Open", ProblemStatus::Promoted => "Promoted", ProblemStatus::Dismissed => "Dismissed", ProblemStatus::Resolved => "Resolved", } } } impl CssClass for ProblemStatus { fn css_class(&self) -> &'static str { match self { ProblemStatus::Open => "problem-open", ProblemStatus::Promoted => "problem-promoted", ProblemStatus::Dismissed => "problem-dismissed", ProblemStatus::Resolved => "problem-resolved", } } } /// A problem pulled from an external source, awaiting triage. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Problem { /// Unique identifier (GoingsOn's, not the source's). pub id: ProblemId, /// Which adapter this came from: `wam`, `audit`, `fuzz`. pub source: String, /// The problem's id within that source. Unique per source. pub source_ref: String, /// One-line statement of the problem. pub title: String, /// Detail, if the source supplied any. pub body: String, /// How much it hurts a hit user, 1-5. pub pain: u8, /// How broadly it hits, 1-5. pub scale: u8, /// Triage state. pub status: ProblemStatus, /// The GoingsOn project this problem belongs to, once attributed. pub project_id: Option, /// Free-form tags, typically module and lens from the reporting skill. pub tags: Vec, /// The task promoted from this problem, if any. The problem-to-solution /// backlink. pub promoted_task_id: Option, /// When the SOURCE created it, not when we pulled it. Age drives the score. pub created_at: DateTime, /// When the source last changed it. pub updated_at: DateTime, /// When it left the open set. `None` while `Open`. pub settled_at: Option>, /// The last pull that saw it in its source. Older than the source's latest /// pull means the source no longer reports it. pub last_seen_at: DateTime, } impl Problem { /// The instant age is measured to: frozen once the problem is settled, so /// promoted, dismissed, and resolved problems stop climbing the list. /// /// Mirrors wam's `Ticket::age_anchor`. A settled problem missing its /// `settled_at` falls back to `updated_at` rather than ageing forever. fn age_anchor(&self) -> DateTime { if self.status.is_settled() { self.settled_at.unwrap_or(self.updated_at) } else { Utc::now() } } /// Age in whole weeks, measured to [`Self::age_anchor`]. pub fn age_weeks(&self) -> u32 { painhours::age_weeks(self.created_at, self.age_anchor()) } /// The painhours score: a 0-100 urgency number, the ranking key for the /// Problems view. Computed rather than stored, because it moves with the /// clock. pub fn painhours(&self) -> u32 { painhours::painhours(self.pain, self.scale, self.age_weeks()) } /// Color band derived from the painhours score. pub fn band(&self) -> ProblemBand { ProblemBand::from_painhours(self.painhours()) } /// Human-readable age for display, e.g. `3d`. pub fn age(&self) -> String { painhours::age_label(self.created_at, Utc::now()) } /// Whether the source stopped reporting this problem, judged against the /// most recent pull from that source. A stale problem is shown as such /// rather than deleted, since a source being briefly unreachable must not /// erase triage history. pub fn is_stale(&self, source_last_pulled_at: DateTime) -> bool { self.last_seen_at < source_last_pulled_at } } /// Data for ingesting a problem from a source. /// /// Carries no id and no triage state: those belong to GoingsOn, and an upsert /// must not clobber them on re-pull. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewProblem { pub source: String, pub source_ref: String, pub title: String, pub body: String, pub pain: u8, pub scale: u8, pub project_id: Option, pub tags: Vec, /// The source's creation instant. Defaults to now when a source has none. pub created_at: DateTime, /// The source's last-modified instant. pub updated_at: DateTime, /// The source's own view of whether this is still a live problem. `true` /// settles the mirrored row as `Resolved`. pub resolved_upstream: bool, } #[cfg(test)] mod tests { use super::*; use chrono::Duration; fn problem(status: ProblemStatus, age: Duration) -> Problem { let now = Utc::now(); Problem { id: ProblemId::new(), source: "wam".into(), source_ref: "abc123".into(), title: "it breaks".into(), body: String::new(), pain: 3, scale: 3, status, project_id: None, tags: vec![], promoted_task_id: None, created_at: now - age, updated_at: now, settled_at: status.is_settled().then_some(now - age + Duration::days(1)), last_seen_at: now, } } #[test] fn problem_status_db_value_round_trips() { for st in [ ProblemStatus::Open, ProblemStatus::Promoted, ProblemStatus::Dismissed, ProblemStatus::Resolved, ] { assert_eq!(ProblemStatus::from_str_or_default(st.db_value()), st); } } #[test] fn unknown_status_falls_back_to_open() { assert_eq!( ProblemStatus::from_str_or_default("bogus"), ProblemStatus::Open ); } #[test] fn only_open_is_unsettled() { assert!(!ProblemStatus::Open.is_settled()); assert!(ProblemStatus::Promoted.is_settled()); assert!(ProblemStatus::Dismissed.is_settled()); assert!(ProblemStatus::Resolved.is_settled()); } #[test] fn open_problems_climb_with_age() { let fresh = problem(ProblemStatus::Open, Duration::days(1)); let old = problem(ProblemStatus::Open, Duration::days(60)); assert!(old.painhours() > fresh.painhours()); } #[test] fn settling_freezes_the_score() { // Same creation date, same factors: the settled one is pinned to the day // after it was created, the open one keeps climbing. let age = Duration::days(90); let open = problem(ProblemStatus::Open, age); for settled in [ ProblemStatus::Promoted, ProblemStatus::Dismissed, ProblemStatus::Resolved, ] { let p = problem(settled, age); assert_eq!(p.age_weeks(), 1, "{settled:?} must freeze at settled_at"); assert!(p.painhours() < open.painhours(), "{settled:?}"); } } #[test] fn settled_without_a_timestamp_falls_back_to_updated_at() { let mut p = problem(ProblemStatus::Promoted, Duration::days(90)); p.settled_at = None; // updated_at is "now", so it reads as its full age rather than ageing on. assert_eq!(p.age_weeks(), 13); } #[test] fn staleness_is_relative_to_the_last_pull() { let p = problem(ProblemStatus::Open, Duration::days(1)); assert!(!p.is_stale(p.last_seen_at)); assert!(p.is_stale(p.last_seen_at + Duration::seconds(1))); } }