max / goingson
25 files changed,
+1299 insertions,
-26 deletions
| @@ -2224,6 +2224,7 @@ dependencies = [ | |||
| 2224 | 2224 | "async-trait", | |
| 2225 | 2225 | "chrono", | |
| 2226 | 2226 | "chrono-tz", | |
| 2227 | + | "painhours", | |
| 2227 | 2228 | "serde", | |
| 2228 | 2229 | "serde_json", | |
| 2229 | 2230 | "sha2 0.11.0", | |
| @@ -4075,6 +4076,14 @@ dependencies = [ | |||
| 4075 | 4076 | ] | |
| 4076 | 4077 | ||
| 4077 | 4078 | [[package]] | |
| 4079 | + | name = "painhours" | |
| 4080 | + | version = "0.1.0" | |
| 4081 | + | dependencies = [ | |
| 4082 | + | "chrono", | |
| 4083 | + | "serde", | |
| 4084 | + | ] | |
| 4085 | + | ||
| 4086 | + | [[package]] | |
| 4078 | 4087 | name = "palette" | |
| 4079 | 4088 | version = "0.7.6" | |
| 4080 | 4089 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -112,6 +112,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } | |||
| 112 | 112 | # Tag standard | |
| 113 | 113 | tagtree = { path = "../../MNW/shared/tagtree" } | |
| 114 | 114 | ||
| 115 | + | # Shared urgency model (also used by MNW/wam), so problems and tickets rank alike | |
| 116 | + | painhours = { path = "../../MNW/shared/painhours" } | |
| 117 | + | ||
| 115 | 118 | # MCP bridge (go-mcp) | |
| 116 | 119 | kberg = { path = "../../MNW/shared/kberg", default-features = false, features = ["server"] } | |
| 117 | 120 |
| @@ -18,6 +18,7 @@ strum = { workspace = true } | |||
| 18 | 18 | strum_macros = { workspace = true } | |
| 19 | 19 | sqlx = { workspace = true, features = ["sqlite", "uuid"], optional = true } | |
| 20 | 20 | tagtree = { workspace = true } | |
| 21 | + | painhours = { workspace = true } | |
| 21 | 22 | sha2 = { workspace = true } | |
| 22 | 23 | ||
| 23 | 24 | [lints] |
| @@ -8,8 +8,8 @@ | |||
| 8 | 8 | ||
| 9 | 9 | use crate::Contact; | |
| 10 | 10 | use crate::models::{ | |
| 11 | - | Attachment, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection, Project, | |
| 12 | - | SavedView, SyncAccount, Task, TimeSession, WeeklyReview, | |
| 11 | + | Attachment, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection, Problem, | |
| 12 | + | Project, SavedView, SyncAccount, Task, TimeSession, WeeklyReview, | |
| 13 | 13 | }; | |
| 14 | 14 | ||
| 15 | 15 | /// Result of a restore operation. | |
| @@ -49,6 +49,10 @@ pub struct RestoreResult { | |||
| 49 | 49 | pub monthly_goals_restored: usize, | |
| 50 | 50 | /// Number of monthly reflections restored. | |
| 51 | 51 | pub monthly_reflections_restored: usize, | |
| 52 | + | /// Number of problems restored. The mirrored rows re-pull on their own, but | |
| 53 | + | /// the triage state on them (dismissed, promoted, and the task backlink) | |
| 54 | + | /// does not, so problems are backed up rather than excluded. | |
| 55 | + | pub problems_restored: usize, | |
| 52 | 56 | } | |
| 53 | 57 | ||
| 54 | 58 | /// Pre-parsed backup data for restoration. | |
| @@ -67,6 +71,7 @@ pub struct RestoreInput { | |||
| 67 | 71 | pub weekly_reviews: Vec<WeeklyReview>, | |
| 68 | 72 | pub monthly_goals: Vec<MonthlyGoal>, | |
| 69 | 73 | pub monthly_reflections: Vec<MonthlyReflection>, | |
| 74 | + | pub problems: Vec<Problem>, | |
| 70 | 75 | } | |
| 71 | 76 | ||
| 72 | 77 | #[cfg(test)] | |
| @@ -99,6 +104,7 @@ mod tests { | |||
| 99 | 104 | weekly_reviews: vec![], | |
| 100 | 105 | monthly_goals: vec![], | |
| 101 | 106 | monthly_reflections: vec![], | |
| 107 | + | problems: vec![], | |
| 102 | 108 | }; | |
| 103 | 109 | assert!(input.projects.is_empty()); | |
| 104 | 110 | assert!(input.tasks.is_empty()); |
| @@ -122,6 +122,7 @@ define_uuid_id!( | |||
| 122 | 122 | SavedViewId, | |
| 123 | 123 | EmailAccountId, | |
| 124 | 124 | UserId, | |
| 125 | + | ProblemId, | |
| 125 | 126 | ); | |
| 126 | 127 | ||
| 127 | 128 | // Sub-entity IDs |
| @@ -69,8 +69,8 @@ pub use error::CoreError; | |||
| 69 | 69 | pub use id_types::{ | |
| 70 | 70 | AnnotationId, AttachmentId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, | |
| 71 | 71 | DailyNoteId, EmailAccountId, EmailId, EventId, MilestoneId, MonthlyGoalId, MonthlyReflectionId, | |
| 72 | - | ProjectId, SavedViewId, SocialHandleId, StatusTokenId, SubtaskId, SyncAccountId, TaskId, | |
| 73 | - | TimeSessionId, UserId, WeeklyReviewId, | |
| 72 | + | ProblemId, ProjectId, SavedViewId, SocialHandleId, StatusTokenId, SubtaskId, SyncAccountId, | |
| 73 | + | TaskId, TimeSessionId, UserId, WeeklyReviewId, | |
| 74 | 74 | }; | |
| 75 | 75 | pub use import::{ | |
| 76 | 76 | ImportEntityType, ImportEventData, ImportExecuteResult, ImportFailure, ImportItem, | |
| @@ -81,13 +81,13 @@ pub use models::{ | |||
| 81 | 81 | DbValue, Email, EmailAccount, EmailAuthType, EmailThread, Event, FolderSyncState, Milestone, | |
| 82 | 82 | MilestoneStatus, MonthlyGoal, MonthlyGoalStatus, MonthlyReflection, MonthlySpec, NewAttachment, | |
| 83 | 83 | NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder, NewMilestone, | |
| 84 | - | NewProject, NewSavedView, NewTask, NewTaskBuilder, ParseableEnum, PositiveMinutes, Priority, | |
| 85 | - | Project, ProjectStatus, ProjectType, Recurrence, RecurrenceRule, SavedView, SortDirection, | |
| 86 | - | SortField, StatusToken, Subtask, SyncAccount, TOKEN_KIND_COMMIT, Task, TaskFilterQuery, | |
| 87 | - | TaskSortColumn, TaskStatus, TimeSession, TimeSummaryPanel, TimeSummaryProject, | |
| 88 | - | TimeTrackingSummary, TokenState, UpdateEvent, UpdateProject, UpdateTask, User, ViewFilters, | |
| 89 | - | ViewType, WeeklyReview, format_file_size, mime_from_extension, roll_up_time_summary, | |
| 90 | - | snap_all_day_span, | |
| 84 | + | NewProblem, NewProject, NewSavedView, NewTask, NewTaskBuilder, ParseableEnum, PositiveMinutes, | |
| 85 | + | Priority, Problem, ProblemBand, ProblemStatus, Project, ProjectStatus, ProjectType, Recurrence, | |
| 86 | + | RecurrenceRule, SavedView, SortDirection, SortField, StatusToken, Subtask, SyncAccount, | |
| 87 | + | TOKEN_KIND_COMMIT, Task, TaskFilterQuery, TaskSortColumn, TaskStatus, TimeSession, | |
| 88 | + | TimeSummaryPanel, TimeSummaryProject, TimeTrackingSummary, TokenState, UpdateEvent, | |
| 89 | + | UpdateProject, UpdateTask, User, ViewFilters, ViewType, WeeklyReview, format_file_size, | |
| 90 | + | mime_from_extension, roll_up_time_summary, snap_all_day_span, | |
| 91 | 91 | }; | |
| 92 | 92 | pub use parser::{ParseResult, ParsedTask, parse_quick_add, parse_quick_add_with_warnings}; | |
| 93 | 93 | pub use recurrence::{ |
| @@ -8,6 +8,7 @@ mod email_account; | |||
| 8 | 8 | mod event; | |
| 9 | 9 | mod milestone; | |
| 10 | 10 | mod monthly_review; | |
| 11 | + | mod problem; | |
| 11 | 12 | mod project; | |
| 12 | 13 | mod saved_view; | |
| 13 | 14 | mod shared; | |
| @@ -25,6 +26,7 @@ pub use email_account::*; | |||
| 25 | 26 | pub use event::*; | |
| 26 | 27 | pub use milestone::*; | |
| 27 | 28 | pub use monthly_review::*; | |
| 29 | + | pub use problem::*; | |
| 28 | 30 | pub use project::*; | |
| 29 | 31 | pub use saved_view::*; | |
| 30 | 32 | pub use shared::*; |
| @@ -0,0 +1,286 @@ | |||
| 1 | + | //! Problem domain types and DTOs. | |
| 2 | + | //! | |
| 3 | + | //! GoingsOn is the list of solutions; other systems are lists of problems. A | |
| 4 | + | //! [`Problem`] is a candidate pulled from one of those systems that a human | |
| 5 | + | //! promotes into a task. Nothing here creates a task on its own. | |
| 6 | + | //! | |
| 7 | + | //! Problems are a cached mirror keyed on `(source, source_ref)`. Adapters re-pull | |
| 8 | + | //! and upsert; the triage decision ([`ProblemStatus`] and `promoted_task_id`) | |
| 9 | + | //! survives that because upserts leave it alone. | |
| 10 | + | //! | |
| 11 | + | //! Urgency is not a stored field. It is the painhours score, computed from | |
| 12 | + | //! `pain`, `scale`, and age by the shared `painhours` crate so the ranking | |
| 13 | + | //! matches wam's. See [`Problem::painhours`]. | |
| 14 | + | ||
| 15 | + | use super::shared::{CssClass, DbValue, ParseableEnum}; | |
| 16 | + | use crate::id_types::{ProblemId, ProjectId, TaskId}; | |
| 17 | + | use chrono::{DateTime, Utc}; | |
| 18 | + | /// The painhours color band, re-exported under a name that does not collide | |
| 19 | + | /// with GoingsOn's own task [`Priority`](super::Priority). Derived from the | |
| 20 | + | /// score, never stored. | |
| 21 | + | pub use painhours::Priority as ProblemBand; | |
| 22 | + | use serde::{Deserialize, Serialize}; | |
| 23 | + | use strum_macros::EnumString; | |
| 24 | + | ||
| 25 | + | /// Where a problem is in triage. | |
| 26 | + | /// | |
| 27 | + | /// Only `Open` problems are unaddressed. Everything else is settled and stops | |
| 28 | + | /// climbing the list; see [`Problem::age_anchor`]. | |
| 29 | + | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, EnumString)] | |
| 30 | + | pub enum ProblemStatus { | |
| 31 | + | /// Untriaged. Sitting in the inbox, ageing, waiting for a decision. | |
| 32 | + | #[strum(serialize = "Open")] | |
| 33 | + | #[default] | |
| 34 | + | Open, | |
| 35 | + | /// A task was created from it. `promoted_task_id` points at the solution. | |
| 36 | + | #[strum(serialize = "Promoted")] | |
| 37 | + | Promoted, | |
| 38 | + | /// Seen and deliberately not acted on. | |
| 39 | + | #[strum(serialize = "Dismissed")] | |
| 40 | + | Dismissed, | |
| 41 | + | /// The source says it is fixed, without GoingsOn having promoted it. | |
| 42 | + | #[strum(serialize = "Resolved")] | |
| 43 | + | Resolved, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl ProblemStatus { | |
| 47 | + | /// Returns a human-readable display string. | |
| 48 | + | pub fn as_str(&self) -> &'static str { | |
| 49 | + | match self { | |
| 50 | + | ProblemStatus::Open => "Open", | |
| 51 | + | ProblemStatus::Promoted => "Promoted", | |
| 52 | + | ProblemStatus::Dismissed => "Dismissed", | |
| 53 | + | ProblemStatus::Resolved => "Resolved", | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Whether the problem has left the open set. Settled problems freeze their | |
| 58 | + | /// age anchor, so they stop climbing the ranking. | |
| 59 | + | pub fn is_settled(&self) -> bool { | |
| 60 | + | !matches!(self, ProblemStatus::Open) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | impl ParseableEnum for ProblemStatus {} | |
| 65 | + | ||
| 66 | + | impl DbValue for ProblemStatus { | |
| 67 | + | fn db_value(&self) -> &'static str { | |
| 68 | + | match self { | |
| 69 | + | ProblemStatus::Open => "Open", | |
| 70 | + | ProblemStatus::Promoted => "Promoted", | |
| 71 | + | ProblemStatus::Dismissed => "Dismissed", | |
| 72 | + | ProblemStatus::Resolved => "Resolved", | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | impl CssClass for ProblemStatus { | |
| 78 | + | fn css_class(&self) -> &'static str { | |
| 79 | + | match self { | |
| 80 | + | ProblemStatus::Open => "problem-open", | |
| 81 | + | ProblemStatus::Promoted => "problem-promoted", | |
| 82 | + | ProblemStatus::Dismissed => "problem-dismissed", | |
| 83 | + | ProblemStatus::Resolved => "problem-resolved", | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// A problem pulled from an external source, awaiting triage. | |
| 89 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 90 | + | #[serde(rename_all = "camelCase")] | |
| 91 | + | pub struct Problem { | |
| 92 | + | /// Unique identifier (GoingsOn's, not the source's). | |
| 93 | + | pub id: ProblemId, | |
| 94 | + | /// Which adapter this came from: `wam`, `audit`, `fuzz`. | |
| 95 | + | pub source: String, | |
| 96 | + | /// The problem's id within that source. Unique per source. | |
| 97 | + | pub source_ref: String, | |
| 98 | + | /// One-line statement of the problem. | |
| 99 | + | pub title: String, | |
| 100 | + | /// Detail, if the source supplied any. | |
| 101 | + | pub body: String, | |
| 102 | + | /// How much it hurts a hit user, 1-5. | |
| 103 | + | pub pain: u8, | |
| 104 | + | /// How broadly it hits, 1-5. | |
| 105 | + | pub scale: u8, | |
| 106 | + | /// Triage state. | |
| 107 | + | pub status: ProblemStatus, | |
| 108 | + | /// The GoingsOn project this problem belongs to, once attributed. | |
| 109 | + | pub project_id: Option<ProjectId>, | |
| 110 | + | /// Free-form tags, typically module and lens from the reporting skill. | |
| 111 | + | pub tags: Vec<String>, | |
| 112 | + | /// The task promoted from this problem, if any. The problem-to-solution | |
| 113 | + | /// backlink. | |
| 114 | + | pub promoted_task_id: Option<TaskId>, | |
| 115 | + | /// When the SOURCE created it, not when we pulled it. Age drives the score. | |
| 116 | + | pub created_at: DateTime<Utc>, | |
| 117 | + | /// When the source last changed it. | |
| 118 | + | pub updated_at: DateTime<Utc>, | |
| 119 | + | /// When it left the open set. `None` while `Open`. | |
| 120 | + | pub settled_at: Option<DateTime<Utc>>, | |
| 121 | + | /// The last pull that saw it in its source. Older than the source's latest | |
| 122 | + | /// pull means the source no longer reports it. | |
| 123 | + | pub last_seen_at: DateTime<Utc>, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | impl Problem { | |
| 127 | + | /// The instant age is measured to: frozen once the problem is settled, so | |
| 128 | + | /// promoted, dismissed, and resolved problems stop climbing the list. | |
| 129 | + | /// | |
| 130 | + | /// Mirrors wam's `Ticket::age_anchor`. A settled problem missing its | |
| 131 | + | /// `settled_at` falls back to `updated_at` rather than ageing forever. | |
| 132 | + | fn age_anchor(&self) -> DateTime<Utc> { | |
| 133 | + | if self.status.is_settled() { | |
| 134 | + | self.settled_at.unwrap_or(self.updated_at) | |
| 135 | + | } else { | |
| 136 | + | Utc::now() | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// Age in whole weeks, measured to [`Self::age_anchor`]. | |
| 141 | + | pub fn age_weeks(&self) -> u32 { | |
| 142 | + | painhours::age_weeks(self.created_at, self.age_anchor()) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// The painhours score: a 0-100 urgency number, the ranking key for the | |
| 146 | + | /// Problems view. Computed rather than stored, because it moves with the | |
| 147 | + | /// clock. | |
| 148 | + | pub fn painhours(&self) -> u32 { | |
| 149 | + | painhours::painhours(self.pain, self.scale, self.age_weeks()) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// Color band derived from the painhours score. | |
| 153 | + | pub fn band(&self) -> ProblemBand { | |
| 154 | + | ProblemBand::from_painhours(self.painhours()) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Human-readable age for display, e.g. `3d`. | |
| 158 | + | pub fn age(&self) -> String { | |
| 159 | + | painhours::age_label(self.created_at, Utc::now()) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// Whether the source stopped reporting this problem, judged against the | |
| 163 | + | /// most recent pull from that source. A stale problem is shown as such | |
| 164 | + | /// rather than deleted, since a source being briefly unreachable must not | |
| 165 | + | /// erase triage history. | |
| 166 | + | pub fn is_stale(&self, source_last_pulled_at: DateTime<Utc>) -> bool { | |
| 167 | + | self.last_seen_at < source_last_pulled_at | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Data for ingesting a problem from a source. | |
| 172 | + | /// | |
| 173 | + | /// Carries no id and no triage state: those belong to GoingsOn, and an upsert | |
| 174 | + | /// must not clobber them on re-pull. | |
| 175 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 176 | + | pub struct NewProblem { | |
| 177 | + | pub source: String, | |
| 178 | + | pub source_ref: String, | |
| 179 | + | pub title: String, | |
| 180 | + | pub body: String, | |
| 181 | + | pub pain: u8, | |
| 182 | + | pub scale: u8, | |
| 183 | + | pub project_id: Option<ProjectId>, | |
| 184 | + | pub tags: Vec<String>, | |
| 185 | + | /// The source's creation instant. Defaults to now when a source has none. | |
| 186 | + | pub created_at: DateTime<Utc>, | |
| 187 | + | /// The source's last-modified instant. | |
| 188 | + | pub updated_at: DateTime<Utc>, | |
| 189 | + | /// The source's own view of whether this is still a live problem. `true` | |
| 190 | + | /// settles the mirrored row as `Resolved`. | |
| 191 | + | pub resolved_upstream: bool, | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[cfg(test)] | |
| 195 | + | mod tests { | |
| 196 | + | use super::*; | |
| 197 | + | use chrono::Duration; | |
| 198 | + | ||
| 199 | + | fn problem(status: ProblemStatus, age: Duration) -> Problem { | |
| 200 | + | let now = Utc::now(); | |
| 201 | + | Problem { | |
| 202 | + | id: ProblemId::new(), | |
| 203 | + | source: "wam".into(), | |
| 204 | + | source_ref: "abc123".into(), | |
| 205 | + | title: "it breaks".into(), | |
| 206 | + | body: String::new(), | |
| 207 | + | pain: 3, | |
| 208 | + | scale: 3, | |
| 209 | + | status, | |
| 210 | + | project_id: None, | |
| 211 | + | tags: vec![], | |
| 212 | + | promoted_task_id: None, | |
| 213 | + | created_at: now - age, | |
| 214 | + | updated_at: now, | |
| 215 | + | settled_at: status.is_settled().then_some(now - age + Duration::days(1)), | |
| 216 | + | last_seen_at: now, | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | #[test] | |
| 221 | + | fn problem_status_db_value_round_trips() { | |
| 222 | + | for st in [ | |
| 223 | + | ProblemStatus::Open, | |
| 224 | + | ProblemStatus::Promoted, | |
| 225 | + | ProblemStatus::Dismissed, | |
| 226 | + | ProblemStatus::Resolved, | |
| 227 | + | ] { | |
| 228 | + | assert_eq!(ProblemStatus::from_str_or_default(st.db_value()), st); | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[test] | |
| 233 | + | fn unknown_status_falls_back_to_open() { | |
| 234 | + | assert_eq!( | |
| 235 | + | ProblemStatus::from_str_or_default("bogus"), | |
| 236 | + | ProblemStatus::Open | |
| 237 | + | ); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | #[test] | |
| 241 | + | fn only_open_is_unsettled() { | |
| 242 | + | assert!(!ProblemStatus::Open.is_settled()); | |
| 243 | + | assert!(ProblemStatus::Promoted.is_settled()); | |
| 244 | + | assert!(ProblemStatus::Dismissed.is_settled()); | |
| 245 | + | assert!(ProblemStatus::Resolved.is_settled()); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[test] | |
| 249 | + | fn open_problems_climb_with_age() { | |
| 250 | + | let fresh = problem(ProblemStatus::Open, Duration::days(1)); | |
| 251 | + | let old = problem(ProblemStatus::Open, Duration::days(60)); | |
| 252 | + | assert!(old.painhours() > fresh.painhours()); | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | #[test] | |
| 256 | + | fn settling_freezes_the_score() { | |
| 257 | + | // Same creation date, same factors: the settled one is pinned to the day | |
| 258 | + | // after it was created, the open one keeps climbing. | |
| 259 | + | let age = Duration::days(90); | |
| 260 | + | let open = problem(ProblemStatus::Open, age); | |
| 261 | + | for settled in [ | |
| 262 | + | ProblemStatus::Promoted, | |
| 263 | + | ProblemStatus::Dismissed, | |
| 264 | + | ProblemStatus::Resolved, | |
| 265 | + | ] { | |
| 266 | + | let p = problem(settled, age); | |
| 267 | + | assert_eq!(p.age_weeks(), 1, "{settled:?} must freeze at settled_at"); | |
| 268 | + | assert!(p.painhours() < open.painhours(), "{settled:?}"); | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn settled_without_a_timestamp_falls_back_to_updated_at() { | |
| 274 | + | let mut p = problem(ProblemStatus::Promoted, Duration::days(90)); | |
| 275 | + | p.settled_at = None; | |
| 276 | + | // updated_at is "now", so it reads as its full age rather than ageing on. | |
| 277 | + | assert_eq!(p.age_weeks(), 13); | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | #[test] | |
| 281 | + | fn staleness_is_relative_to_the_last_pull() { | |
| 282 | + | let p = problem(ProblemStatus::Open, Duration::days(1)); | |
| 283 | + | assert!(!p.is_stale(p.last_seen_at)); | |
| 284 | + | assert!(p.is_stale(p.last_seen_at + Duration::seconds(1))); | |
| 285 | + | } | |
| 286 | + | } |
| @@ -5,8 +5,8 @@ | |||
| 5 | 5 | ||
| 6 | 6 | use crate::id_types::{ | |
| 7 | 7 | AnnotationId, AttachmentId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, | |
| 8 | - | EmailAccountId, EmailId, EventId, MilestoneId, ProjectId, SavedViewId, SocialHandleId, | |
| 9 | - | StatusTokenId, SubtaskId, SyncAccountId, TaskId, UserId, | |
| 8 | + | EmailAccountId, EmailId, EventId, MilestoneId, ProblemId, ProjectId, SavedViewId, | |
| 9 | + | SocialHandleId, StatusTokenId, SubtaskId, SyncAccountId, TaskId, UserId, | |
| 10 | 10 | }; | |
| 11 | 11 | use async_trait::async_trait; | |
| 12 | 12 | use chrono::{DateTime, NaiveDate, Utc}; | |
| @@ -21,9 +21,10 @@ use crate::contact::{ | |||
| 21 | 21 | use crate::error::CoreError; | |
| 22 | 22 | use crate::models::{ | |
| 23 | 23 | Annotation, Attachment, Email, EmailAccount, EmailAuthType, EmailThread, Event, | |
| 24 | - | FolderSyncState, NewAttachment, NewEmail, NewEmailWithTracking, NewEvent, NewProject, | |
| 25 | - | NewSavedView, NewTask, PositiveMinutes, Project, SavedView, StatusToken, Subtask, Task, | |
| 26 | - | TaskFilterQuery, TimeSession, TimeTrackingSummary, TokenState, UpdateTask, User, | |
| 24 | + | FolderSyncState, NewAttachment, NewEmail, NewEmailWithTracking, NewEvent, NewProblem, | |
| 25 | + | NewProject, NewSavedView, NewTask, PositiveMinutes, Problem, ProblemStatus, Project, SavedView, | |
| 26 | + | StatusToken, Subtask, Task, TaskFilterQuery, TimeSession, TimeTrackingSummary, TokenState, | |
| 27 | + | UpdateTask, User, | |
| 27 | 28 | }; | |
| 28 | 29 | ||
| 29 | 30 | /// Convenience type alias for repository operation results. | |
| @@ -33,6 +34,7 @@ mod contact; | |||
| 33 | 34 | mod email; | |
| 34 | 35 | mod event; | |
| 35 | 36 | mod misc; | |
| 37 | + | mod problem; | |
| 36 | 38 | mod project; | |
| 37 | 39 | mod review; | |
| 38 | 40 | mod search; | |
| @@ -44,6 +46,7 @@ pub use contact::*; | |||
| 44 | 46 | pub use email::*; | |
| 45 | 47 | pub use event::*; | |
| 46 | 48 | pub use misc::*; | |
| 49 | + | pub use problem::*; | |
| 47 | 50 | pub use project::*; | |
| 48 | 51 | pub use review::*; | |
| 49 | 52 | pub use search::*; |
| @@ -0,0 +1,93 @@ | |||
| 1 | + | use super::{ | |
| 2 | + | DateTime, NewProblem, Problem, ProblemId, ProblemStatus, ProjectId, Result, TaskId, UserId, | |
| 3 | + | Utc, async_trait, | |
| 4 | + | }; | |
| 5 | + | ||
| 6 | + | /// Filters for listing problems. All fields are ANDed; `None` means unfiltered. | |
| 7 | + | #[derive(Debug, Clone, Default)] | |
| 8 | + | pub struct ProblemFilter { | |
| 9 | + | /// Restrict to one adapter, e.g. `wam`. | |
| 10 | + | pub source: Option<String>, | |
| 11 | + | /// Restrict to one triage state. The inbox passes `Open`. | |
| 12 | + | pub status: Option<ProblemStatus>, | |
| 13 | + | /// Restrict to problems attributed to one project. | |
| 14 | + | pub project_id: Option<ProjectId>, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// Repository for the Problems inbox. | |
| 18 | + | /// | |
| 19 | + | /// Problems are a mirror of external sources, so the write surface splits in | |
| 20 | + | /// two: [`ProblemRepository::ingest`] is for adapters and upserts on | |
| 21 | + | /// `(source, source_ref)`, while the triage methods are for the human and are | |
| 22 | + | /// never touched by a re-pull. | |
| 23 | + | /// | |
| 24 | + | /// All operations are scoped to a specific user for multi-tenancy support. | |
| 25 | + | #[async_trait] | |
| 26 | + | pub trait ProblemRepository: Send + Sync { | |
| 27 | + | /// Lists problems matching a filter. | |
| 28 | + | /// | |
| 29 | + | /// Ordered by painhours descending, ties broken by newest. That score moves | |
| 30 | + | /// with the clock, so it is computed and sorted after the fetch rather than | |
| 31 | + | /// in SQL. | |
| 32 | + | async fn list(&self, user_id: UserId, filter: &ProblemFilter) -> Result<Vec<Problem>>; | |
| 33 | + | ||
| 34 | + | /// Retrieves a problem by ID, returning `None` if not found. | |
| 35 | + | async fn get_by_id(&self, id: ProblemId, user_id: UserId) -> Result<Option<Problem>>; | |
| 36 | + | ||
| 37 | + | /// Retrieves a problem by its provenance key. | |
| 38 | + | async fn find_by_source_ref( | |
| 39 | + | &self, | |
| 40 | + | user_id: UserId, | |
| 41 | + | source: &str, | |
| 42 | + | source_ref: &str, | |
| 43 | + | ) -> Result<Option<Problem>>; | |
| 44 | + | ||
| 45 | + | /// Ingests a problem from a source, inserting it or updating the existing | |
| 46 | + | /// row with the same `(source, source_ref)`. | |
| 47 | + | /// | |
| 48 | + | /// Upserts refresh what the source owns (title, body, pain, scale, | |
| 49 | + | /// timestamps, `last_seen_at`) and leave what GoingsOn owns alone, so | |
| 50 | + | /// re-pulling never undoes a triage decision. The one exception is a problem | |
| 51 | + | /// the source reports as fixed, which settles an untriaged row as | |
| 52 | + | /// `Resolved`; a problem already promoted or dismissed keeps that state. | |
| 53 | + | async fn ingest(&self, user_id: UserId, problem: NewProblem) -> Result<Problem>; | |
| 54 | + | ||
| 55 | + | /// Records that a task was created from this problem: sets `Promoted`, the | |
| 56 | + | /// backlink, and the settle instant that freezes its score. | |
| 57 | + | /// | |
| 58 | + | /// Returns `None` if the problem does not exist. | |
| 59 | + | async fn promote( | |
| 60 | + | &self, | |
| 61 | + | id: ProblemId, | |
| 62 | + | user_id: UserId, | |
| 63 | + | task_id: TaskId, | |
| 64 | + | ) -> Result<Option<Problem>>; | |
| 65 | + | ||
| 66 | + | /// Sets triage state directly, for dismissing and for reopening something | |
| 67 | + | /// settled by mistake. Clears the backlink and the settle instant when | |
| 68 | + | /// moving back to `Open`. | |
| 69 | + | async fn set_status( | |
| 70 | + | &self, | |
| 71 | + | id: ProblemId, | |
| 72 | + | user_id: UserId, | |
| 73 | + | status: ProblemStatus, | |
| 74 | + | ) -> Result<Option<Problem>>; | |
| 75 | + | ||
| 76 | + | /// Attributes a problem to a project, or clears the attribution. | |
| 77 | + | async fn set_project( | |
| 78 | + | &self, | |
| 79 | + | id: ProblemId, | |
| 80 | + | user_id: UserId, | |
| 81 | + | project_id: Option<ProjectId>, | |
| 82 | + | ) -> Result<Option<Problem>>; | |
| 83 | + | ||
| 84 | + | /// Deletes a problem. Triage history is worth keeping, so prefer dismissing; | |
| 85 | + | /// this exists for genuinely bad ingests. | |
| 86 | + | async fn delete(&self, id: ProblemId, user_id: UserId) -> Result<bool>; | |
| 87 | + | ||
| 88 | + | /// The most recent `last_seen_at` across a source's problems, i.e. when that | |
| 89 | + | /// adapter last completed a pull. Callers compare a problem's `last_seen_at` | |
| 90 | + | /// against this to decide staleness; `None` means the source has never been | |
| 91 | + | /// pulled. | |
| 92 | + | async fn last_pulled_at(&self, user_id: UserId, source: &str) -> Result<Option<DateTime<Utc>>>; | |
| 93 | + | } |
| @@ -69,9 +69,9 @@ pub use repository::{ | |||
| 69 | 69 | SqliteAttachmentRepository, SqliteBackupSettingsRepository, SqliteContactRepository, | |
| 70 | 70 | SqliteDailyNoteRepository, SqliteEmailAccountRepository, SqliteEmailRepository, | |
| 71 | 71 | SqliteEventRepository, SqliteMilestoneRepository, SqliteMonthlyReviewRepository, | |
| 72 | - | SqliteProjectRepository, SqliteSavedViewRepository, SqliteSearchRepository, | |
| 73 | - | SqliteStatsRepository, SqliteSyncAccountRepository, SqliteTaskRepository, SqliteUserRepository, | |
| 74 | - | SqliteWeeklyReviewRepository, | |
| 72 | + | SqliteProblemRepository, SqliteProjectRepository, SqliteSavedViewRepository, | |
| 73 | + | SqliteSearchRepository, SqliteStatsRepository, SqliteSyncAccountRepository, | |
| 74 | + | SqliteTaskRepository, SqliteUserRepository, SqliteWeeklyReviewRepository, | |
| 75 | 75 | }; | |
| 76 | 76 | ||
| 77 | 77 | pub use repository::restore::{BACKUP_TABLES, EXCLUDED_TABLES, restore_all}; |
| @@ -13,6 +13,7 @@ mod email_repo; | |||
| 13 | 13 | mod event_repo; | |
| 14 | 14 | mod milestone_repo; | |
| 15 | 15 | mod monthly_review_repo; | |
| 16 | + | mod problem_repo; | |
| 16 | 17 | mod project_repo; | |
| 17 | 18 | pub mod restore; | |
| 18 | 19 | mod saved_view_repo; | |
| @@ -36,6 +37,7 @@ pub use email_repo::SqliteEmailRepository; | |||
| 36 | 37 | pub use event_repo::SqliteEventRepository; | |
| 37 | 38 | pub use milestone_repo::SqliteMilestoneRepository; | |
| 38 | 39 | pub use monthly_review_repo::SqliteMonthlyReviewRepository; | |
| 40 | + | pub use problem_repo::SqliteProblemRepository; | |
| 39 | 41 | pub use project_repo::SqliteProjectRepository; | |
| 40 | 42 | pub use saved_view_repo::SqliteSavedViewRepository; | |
| 41 | 43 | pub use search_repo::SqliteSearchRepository; |
| @@ -0,0 +1,355 @@ | |||
| 1 | + | //! SQLite implementation of the ProblemRepository. | |
| 2 | + | //! | |
| 3 | + | //! Backs the Problems inbox: a mirror of problems pulled from external sources | |
| 4 | + | //! (wam tickets, /audit and /fuzz findings), ranked by painhours and promoted by | |
| 5 | + | //! hand into tasks. | |
| 6 | + | //! | |
| 7 | + | //! Two rules shape the SQL here. Ingest upserts on the provenance key and | |
| 8 | + | //! touches only the columns the source owns, so a re-pull never undoes triage. | |
| 9 | + | //! And ordering happens in Rust, not `ORDER BY`, because painhours depends on | |
| 10 | + | //! the current time. | |
| 11 | + | ||
| 12 | + | use async_trait::async_trait; | |
| 13 | + | use chrono::{DateTime, Utc}; | |
| 14 | + | use goingson_core::{ | |
| 15 | + | CoreError, DbValue, NewProblem, ParseableEnum, Problem, ProblemFilter, ProblemId, | |
| 16 | + | ProblemRepository, ProblemStatus, ProjectId, Result, TaskId, UserId, | |
| 17 | + | }; | |
| 18 | + | use sqlx::SqlitePool; | |
| 19 | + | ||
| 20 | + | use crate::utils::{ | |
| 21 | + | format_datetime, format_datetime_now, parse_datetime, parse_tags, parse_uuid, parse_uuid_opt, | |
| 22 | + | }; | |
| 23 | + | ||
| 24 | + | const COLUMNS: &str = "id, source, source_ref, title, body, pain, scale, status, project_id, \ | |
| 25 | + | tags, promoted_task_id, created_at, updated_at, settled_at, last_seen_at"; | |
| 26 | + | ||
| 27 | + | /// Database row struct for Problem | |
| 28 | + | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 29 | + | struct ProblemRow { | |
| 30 | + | pub id: String, | |
| 31 | + | pub source: String, | |
| 32 | + | pub source_ref: String, | |
| 33 | + | pub title: String, | |
| 34 | + | pub body: String, | |
| 35 | + | pub pain: i64, | |
| 36 | + | pub scale: i64, | |
| 37 | + | pub status: String, | |
| 38 | + | pub project_id: Option<String>, | |
| 39 | + | pub tags: String, | |
| 40 | + | pub promoted_task_id: Option<String>, | |
| 41 | + | pub created_at: String, | |
| 42 | + | pub updated_at: String, | |
| 43 | + | pub settled_at: Option<String>, | |
| 44 | + | pub last_seen_at: String, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | impl TryFrom<ProblemRow> for Problem { | |
| 48 | + | type Error = CoreError; | |
| 49 | + | ||
| 50 | + | fn try_from(row: ProblemRow) -> std::result::Result<Self, Self::Error> { | |
| 51 | + | Ok(Problem { | |
| 52 | + | id: parse_uuid(&row.id)?.into(), | |
| 53 | + | source: row.source, | |
| 54 | + | source_ref: row.source_ref, | |
| 55 | + | title: row.title, | |
| 56 | + | body: row.body, | |
| 57 | + | // The 1-5 factors are clamped by the painhours crate on use, so a | |
| 58 | + | // source that wrote something wild ranks as the nearest valid | |
| 59 | + | // factor instead of failing the whole fetch. | |
| 60 | + | pain: row.pain.clamp(0, 255) as u8, | |
| 61 | + | scale: row.scale.clamp(0, 255) as u8, | |
| 62 | + | status: ProblemStatus::from_str_or_default(&row.status), | |
| 63 | + | project_id: parse_uuid_opt(row.project_id.as_deref())?.map(Into::into), | |
| 64 | + | tags: parse_tags(&row.tags), | |
| 65 | + | promoted_task_id: parse_uuid_opt(row.promoted_task_id.as_deref())?.map(Into::into), | |
| 66 | + | created_at: parse_datetime(&row.created_at)?, | |
| 67 | + | updated_at: parse_datetime(&row.updated_at)?, | |
| 68 | + | settled_at: row.settled_at.as_deref().map(parse_datetime).transpose()?, | |
| 69 | + | last_seen_at: parse_datetime(&row.last_seen_at)?, | |
| 70 | + | }) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// SQLite-backed implementation of [`ProblemRepository`]. | |
| 75 | + | pub struct SqliteProblemRepository { | |
| 76 | + | pool: SqlitePool, | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | impl SqliteProblemRepository { | |
| 80 | + | /// Creates a new repository instance with the given connection pool. | |
| 81 | + | #[tracing::instrument(skip_all)] | |
| 82 | + | pub fn new(pool: SqlitePool) -> Self { | |
| 83 | + | Self { pool } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Applies a status transition and returns the updated problem. | |
| 87 | + | /// | |
| 88 | + | /// Shared by [`ProblemRepository::promote`] and | |
| 89 | + | /// [`ProblemRepository::set_status`], because both have to keep `status`, | |
| 90 | + | /// `settled_at`, and `promoted_task_id` consistent: settling stamps the | |
| 91 | + | /// freeze instant, reopening clears both it and the backlink. | |
| 92 | + | async fn transition( | |
| 93 | + | &self, | |
| 94 | + | id: ProblemId, | |
| 95 | + | user_id: UserId, | |
| 96 | + | status: ProblemStatus, | |
| 97 | + | task_id: Option<TaskId>, | |
| 98 | + | ) -> Result<Option<Problem>> { | |
| 99 | + | let settled_at = status.is_settled().then(format_datetime_now); | |
| 100 | + | ||
| 101 | + | let result = sqlx::query( | |
| 102 | + | r" | |
| 103 | + | UPDATE problems | |
| 104 | + | SET status = ?, settled_at = ?, promoted_task_id = ?, updated_at = ? | |
| 105 | + | WHERE id = ? AND user_id = ? | |
| 106 | + | ", | |
| 107 | + | ) | |
| 108 | + | .bind(status.db_value()) | |
| 109 | + | .bind(&settled_at) | |
| 110 | + | .bind(task_id.map(|t| t.to_string())) | |
| 111 | + | .bind(format_datetime_now()) | |
| 112 | + | .bind(id.to_string()) | |
| 113 | + | .bind(user_id.to_string()) | |
| 114 | + | .execute(&self.pool) | |
| 115 | + | .await | |
| 116 | + | .map_err(CoreError::database)?; | |
| 117 | + | ||
| 118 | + | if result.rows_affected() > 0 { | |
| 119 | + | self.get_by_id(id, user_id).await | |
| 120 | + | } else { | |
| 121 | + | Ok(None) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[async_trait] | |
| 127 | + | impl ProblemRepository for SqliteProblemRepository { | |
| 128 | + | #[tracing::instrument(skip_all)] | |
| 129 | + | async fn list(&self, user_id: UserId, filter: &ProblemFilter) -> Result<Vec<Problem>> { | |
| 130 | + | let mut sql = format!("SELECT {COLUMNS} FROM problems WHERE user_id = ?"); | |
| 131 | + | if filter.source.is_some() { | |
| 132 | + | sql.push_str(" AND source = ?"); | |
| 133 | + | } | |
| 134 | + | if filter.status.is_some() { | |
| 135 | + | sql.push_str(" AND status = ?"); | |
| 136 | + | } | |
| 137 | + | if filter.project_id.is_some() { | |
| 138 | + | sql.push_str(" AND project_id = ?"); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | let mut query = | |
| 142 | + | sqlx::query_as::<_, ProblemRow>(sqlx::AssertSqlSafe(sql)).bind(user_id.to_string()); | |
| 143 | + | if let Some(source) = &filter.source { | |
| 144 | + | query = query.bind(source.clone()); | |
| 145 | + | } | |
| 146 | + | if let Some(status) = filter.status { | |
| 147 | + | query = query.bind(status.db_value()); | |
| 148 | + | } | |
| 149 | + | if let Some(project_id) = filter.project_id { | |
| 150 | + | query = query.bind(project_id.to_string()); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | let rows = query | |
| 154 | + | .fetch_all(&self.pool) | |
| 155 | + | .await | |
| 156 | + | .map_err(CoreError::database)?; | |
| 157 | + | ||
| 158 | + | let mut problems: Vec<Problem> = rows | |
| 159 | + | .into_iter() | |
| 160 | + | .map(Problem::try_from) | |
| 161 | + | .collect::<Result<_>>()?; | |
| 162 | + | ||
| 163 | + | // Highest painhours first, ties broken by newest. Sorted here rather | |
| 164 | + | // than in SQL because the score depends on the current time. | |
| 165 | + | problems.sort_by(|a, b| { | |
| 166 | + | b.painhours() | |
| 167 | + | .cmp(&a.painhours()) | |
| 168 | + | .then_with(|| b.created_at.cmp(&a.created_at)) | |
| 169 | + | }); | |
| 170 | + | ||
| 171 | + | Ok(problems) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | #[tracing::instrument(skip_all)] | |
| 175 | + | async fn get_by_id(&self, id: ProblemId, user_id: UserId) -> Result<Option<Problem>> { | |
| 176 | + | let row = sqlx::query_as::<_, ProblemRow>(sqlx::AssertSqlSafe(format!( | |
| 177 | + | "SELECT {COLUMNS} FROM problems WHERE id = ? AND user_id = ?" | |
| 178 | + | ))) | |
| 179 | + | .bind(id.to_string()) | |
| 180 | + | .bind(user_id.to_string()) | |
| 181 | + | .fetch_optional(&self.pool) | |
| 182 | + | .await | |
| 183 | + | .map_err(CoreError::database)?; | |
| 184 | + | ||
| 185 | + | row.map(Problem::try_from).transpose() | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | #[tracing::instrument(skip_all)] | |
| 189 | + | async fn find_by_source_ref( | |
| 190 | + | &self, | |
| 191 | + | user_id: UserId, | |
| 192 | + | source: &str, | |
| 193 | + | source_ref: &str, | |
| 194 | + | ) -> Result<Option<Problem>> { | |
| 195 | + | let row = sqlx::query_as::<_, ProblemRow>(sqlx::AssertSqlSafe(format!( | |
| 196 | + | "SELECT {COLUMNS} FROM problems WHERE user_id = ? AND source = ? AND source_ref = ?" | |
| 197 | + | ))) | |
| 198 | + | .bind(user_id.to_string()) | |
| 199 | + | .bind(source) | |
| 200 | + | .bind(source_ref) | |
| 201 | + | .fetch_optional(&self.pool) | |
| 202 | + | .await | |
| 203 | + | .map_err(CoreError::database)?; | |
| 204 | + | ||
| 205 | + | row.map(Problem::try_from).transpose() | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[tracing::instrument(skip_all)] | |
| 209 | + | async fn ingest(&self, user_id: UserId, problem: NewProblem) -> Result<Problem> { | |
| 210 | + | let tags_json = serde_json::to_string(&problem.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 211 | + | let now = format_datetime_now(); | |
| 212 | + | ||
| 213 | + | // A source reporting the problem as fixed settles it, but only if the | |
| 214 | + | // human has not already ruled on it: `status = 'Open'` in the CASE | |
| 215 | + | // guards promoted and dismissed rows from being overwritten. | |
| 216 | + | let (status, settled_at) = if problem.resolved_upstream { | |
| 217 | + | (ProblemStatus::Resolved, Some(now.clone())) | |
| 218 | + | } else { | |
| 219 | + | (ProblemStatus::Open, None) | |
| 220 | + | }; | |
| 221 | + | ||
| 222 | + | sqlx::query( | |
| 223 | + | r" | |
| 224 | + | INSERT INTO problems ( | |
| 225 | + | id, user_id, source, source_ref, title, body, pain, scale, status, | |
| 226 | + | project_id, tags, created_at, updated_at, settled_at, last_seen_at | |
| 227 | + | ) | |
| 228 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 229 | + | ON CONFLICT(user_id, source, source_ref) DO UPDATE SET | |
| 230 | + | title = excluded.title, | |
| 231 | + | body = excluded.body, | |
| 232 | + | pain = excluded.pain, | |
| 233 | + | scale = excluded.scale, | |
| 234 | + | tags = excluded.tags, | |
| 235 | + | created_at = excluded.created_at, | |
| 236 | + | updated_at = excluded.updated_at, | |
| 237 | + | last_seen_at = excluded.last_seen_at, | |
| 238 | + | project_id = COALESCE(problems.project_id, excluded.project_id), | |
| 239 | + | status = CASE | |
| 240 | + | WHEN excluded.status = 'Resolved' AND problems.status = 'Open' | |
| 241 | + | THEN 'Resolved' | |
| 242 | + | ELSE problems.status | |
| 243 | + | END, | |
| 244 | + | settled_at = CASE | |
| 245 | + | WHEN excluded.status = 'Resolved' AND problems.status = 'Open' | |
| 246 | + | THEN excluded.settled_at | |
| 247 | + | ELSE problems.settled_at | |
| 248 | + | END | |
| 249 | + | ", | |
| 250 | + | ) | |
| 251 | + | .bind(ProblemId::new().to_string()) | |
| 252 | + | .bind(user_id.to_string()) | |
| 253 | + | .bind(&problem.source) | |
| 254 | + | .bind(&problem.source_ref) | |
| 255 | + | .bind(&problem.title) | |
| 256 | + | .bind(&problem.body) | |
| 257 | + | .bind(i64::from(problem.pain)) | |
| 258 | + | .bind(i64::from(problem.scale)) | |
| 259 | + | .bind(status.db_value()) | |
| 260 | + | .bind(problem.project_id.map(|p| p.to_string())) | |
| 261 | + | .bind(&tags_json) | |
| 262 | + | .bind(format_datetime(&problem.created_at)) | |
| 263 | + | .bind(format_datetime(&problem.updated_at)) | |
| 264 | + | .bind(&settled_at) | |
| 265 | + | .bind(&now) | |
| 266 | + | .execute(&self.pool) | |
| 267 | + | .await | |
| 268 | + | .map_err(CoreError::database)?; | |
| 269 | + | ||
| 270 | + | // Re-read by provenance key rather than by the id above: on a conflict | |
| 271 | + | // that id was discarded and the existing row kept its own. | |
| 272 | + | self.find_by_source_ref(user_id, &problem.source, &problem.source_ref) | |
| 273 | + | .await? | |
| 274 | + | .ok_or_else(|| CoreError::internal("Failed to retrieve ingested problem")) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | #[tracing::instrument(skip_all)] | |
| 278 | + | async fn promote( | |
| 279 | + | &self, | |
| 280 | + | id: ProblemId, | |
| 281 | + | user_id: UserId, | |
| 282 | + | task_id: TaskId, | |
| 283 | + | ) -> Result<Option<Problem>> { | |
| 284 | + | self.transition(id, user_id, ProblemStatus::Promoted, Some(task_id)) | |
| 285 | + | .await | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | #[tracing::instrument(skip_all)] | |
| 289 | + | async fn set_status( | |
| 290 | + | &self, | |
| 291 | + | id: ProblemId, | |
| 292 | + | user_id: UserId, | |
| 293 | + | status: ProblemStatus, | |
| 294 | + | ) -> Result<Option<Problem>> { | |
| 295 | + | // Reopening drops the backlink; a problem sent back to triage has no | |
| 296 | + | // solution attached. Promoting through this path is a caller error | |
| 297 | + | // (there is no task to link), so it lands as Promoted with no backlink | |
| 298 | + | // rather than silently inventing one. | |
| 299 | + | self.transition(id, user_id, status, None).await | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | #[tracing::instrument(skip_all)] | |
| 303 | + | async fn set_project( | |
| 304 | + | &self, | |
| 305 | + | id: ProblemId, | |
| 306 | + | user_id: UserId, | |
| 307 | + | project_id: Option<ProjectId>, | |
| 308 | + | ) -> Result<Option<Problem>> { | |
| 309 | + | let result = sqlx::query( | |
| 310 | + | r" | |
| 311 | + | UPDATE problems SET project_id = ?, updated_at = ? | |
| 312 | + | WHERE id = ? AND user_id = ? | |
| 313 | + | ", | |
| 314 | + | ) | |
| 315 | + | .bind(project_id.map(|p| p.to_string())) | |
| 316 | + | .bind(format_datetime_now()) | |
| 317 | + | .bind(id.to_string()) | |
| 318 | + | .bind(user_id.to_string()) | |
| 319 | + | .execute(&self.pool) | |
| 320 | + | .await | |
| 321 | + | .map_err(CoreError::database)?; | |
| 322 | + | ||
| 323 | + | if result.rows_affected() > 0 { | |
| 324 | + | self.get_by_id(id, user_id).await | |
| 325 | + | } else { | |
| 326 | + | Ok(None) | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | #[tracing::instrument(skip_all)] | |
| 331 | + | async fn delete(&self, id: ProblemId, user_id: UserId) -> Result<bool> { | |
| 332 | + | let result = sqlx::query("DELETE FROM problems WHERE id = ? AND user_id = ?") | |
| 333 | + | .bind(id.to_string()) | |
| 334 | + | .bind(user_id.to_string()) | |
| 335 | + | .execute(&self.pool) | |
| 336 | + | .await | |
| 337 | + | .map_err(CoreError::database)?; | |
| 338 | + | ||
| 339 | + | Ok(result.rows_affected() > 0) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | #[tracing::instrument(skip_all)] | |
| 343 | + | async fn last_pulled_at(&self, user_id: UserId, source: &str) -> Result<Option<DateTime<Utc>>> { | |
| 344 | + | let raw: Option<String> = sqlx::query_scalar( | |
| 345 | + | "SELECT MAX(last_seen_at) FROM problems WHERE user_id = ? AND source = ?", | |
| 346 | + | ) | |
| 347 | + | .bind(user_id.to_string()) | |
| 348 | + | .bind(source) | |
| 349 | + | .fetch_one(&self.pool) | |
| 350 | + | .await | |
| 351 | + | .map_err(CoreError::database)?; | |
| 352 | + | ||
| 353 | + | raw.as_deref().map(parse_datetime).transpose() | |
| 354 | + | } | |
| 355 | + | } |
| @@ -49,6 +49,7 @@ pub const BACKUP_TABLES: &[&str] = &[ | |||
| 49 | 49 | "weekly_reviews", | |
| 50 | 50 | "monthly_goals", | |
| 51 | 51 | "monthly_reflections", | |
| 52 | + | "problems", | |
| 52 | 53 | ]; | |
| 53 | 54 | ||
| 54 | 55 | /// Tables deliberately excluded from backups, each for a documented reason. A table | |
| @@ -102,6 +103,7 @@ pub async fn restore_all( | |||
| 102 | 103 | restore_emails(&mut tx, user_id, input, &mut result).await?; | |
| 103 | 104 | restore_tasks_with_children(&mut tx, user_id, input, &mut result).await?; | |
| 104 | 105 | restore_time_sessions(&mut tx, user_id, input, &mut result).await?; | |
| 106 | + | restore_problems(&mut tx, user_id, input, &mut result).await?; | |
| 105 | 107 | restore_events(&mut tx, user_id, input, &mut result).await?; | |
| 106 | 108 | restore_attachments(&mut tx, user_id, input, &mut result).await?; | |
| 107 | 109 | restore_daily_notes(&mut tx, user_id, input, &mut result).await?; | |
| @@ -744,6 +746,47 @@ async fn restore_saved_views( | |||
| 744 | 746 | Ok(()) | |
| 745 | 747 | } | |
| 746 | 748 | ||
| 749 | + | async fn restore_problems( | |
| 750 | + | tx: &mut Transaction<'_, Sqlite>, | |
| 751 | + | user_id: UserId, | |
| 752 | + | input: &RestoreInput, | |
| 753 | + | result: &mut RestoreResult, | |
| 754 | + | ) -> Result<()> { | |
| 755 | + | for problem in &input.problems { | |
| 756 | + | let tags_json = serde_json::to_string(&problem.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 757 | + | let affected = sqlx::query( | |
| 758 | + | "INSERT OR IGNORE INTO problems (\ | |
| 759 | + | id, user_id, source, source_ref, title, body, pain, scale, status, project_id, \ | |
| 760 | + | tags, promoted_task_id, created_at, updated_at, settled_at, last_seen_at\ | |
| 761 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 762 | + | ) | |
| 763 | + | .bind(problem.id.to_string()) | |
| 764 | + | .bind(user_id.to_string()) | |
| 765 | + | .bind(&problem.source) | |
| 766 | + | .bind(&problem.source_ref) | |
| 767 | + | .bind(&problem.title) | |
| 768 | + | .bind(&problem.body) | |
| 769 | + | .bind(i64::from(problem.pain)) | |
| 770 | + | .bind(i64::from(problem.scale)) | |
| 771 | + | .bind(problem.status.db_value()) | |
| 772 | + | .bind(problem.project_id.map(|p| p.to_string())) | |
| 773 | + | .bind(&tags_json) | |
| 774 | + | .bind(problem.promoted_task_id.map(|t| t.to_string())) | |
| 775 | + | .bind(format_datetime(&problem.created_at)) | |
| 776 | + | .bind(format_datetime(&problem.updated_at)) | |
| 777 | + | .bind(format_datetime_opt(problem.settled_at)) | |
| 778 | + | .bind(format_datetime(&problem.last_seen_at)) | |
| 779 | + | .execute(&mut **tx) | |
| 780 | + | .await | |
| 781 | + | .map_err(CoreError::database)? | |
| 782 | + | .rows_affected(); | |
| 783 | + | if affected > 0 { | |
| 784 | + | result.problems_restored += 1; | |
| 785 | + | } | |
| 786 | + | } | |
| 787 | + | Ok(()) | |
| 788 | + | } | |
| 789 | + | ||
| 747 | 790 | async fn restore_weekly_reviews( | |
| 748 | 791 | tx: &mut Transaction<'_, Sqlite>, | |
| 749 | 792 | user_id: UserId, |
| @@ -31,6 +31,7 @@ fn empty_input() -> RestoreInput { | |||
| 31 | 31 | weekly_reviews: vec![], | |
| 32 | 32 | monthly_goals: vec![], | |
| 33 | 33 | monthly_reflections: vec![], | |
| 34 | + | problems: vec![], | |
| 34 | 35 | } | |
| 35 | 36 | } | |
| 36 | 37 |
| @@ -0,0 +1,364 @@ | |||
| 1 | + | //! Integration tests for SqliteProblemRepository. | |
| 2 | + | //! | |
| 3 | + | //! The interesting surface is ingest. Problems are a mirror, so every adapter | |
| 4 | + | //! re-pull runs the same rows through `ingest` again, and the contract is that | |
| 5 | + | //! source-owned fields refresh while triage decisions survive. Most of what | |
| 6 | + | //! follows pins that boundary. | |
| 7 | + | ||
| 8 | + | mod common; | |
| 9 | + | ||
| 10 | + | use chrono::{Duration, Utc}; | |
| 11 | + | use goingson_core::{ | |
| 12 | + | NewProblem, ProblemFilter, ProblemId, ProblemRepository, ProblemStatus, UserId, | |
| 13 | + | }; | |
| 14 | + | use goingson_db_sqlite::SqliteProblemRepository; | |
| 15 | + | ||
| 16 | + | fn new_problem(source_ref: &str) -> NewProblem { | |
| 17 | + | let now = Utc::now(); | |
| 18 | + | NewProblem { | |
| 19 | + | source: "wam".to_string(), | |
| 20 | + | source_ref: source_ref.to_string(), | |
| 21 | + | title: "sync drops attachments".to_string(), | |
| 22 | + | body: "seen on two devices".to_string(), | |
| 23 | + | pain: 3, | |
| 24 | + | scale: 3, | |
| 25 | + | project_id: None, | |
| 26 | + | tags: vec!["sync".to_string()], | |
| 27 | + | created_at: now, | |
| 28 | + | updated_at: now, | |
| 29 | + | resolved_upstream: false, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | #[tokio::test] | |
| 34 | + | async fn ingest_creates_then_finds_by_provenance() { | |
| 35 | + | let pool = common::setup_test_db().await; | |
| 36 | + | let user_id = common::create_test_user(&pool).await; | |
| 37 | + | let repo = SqliteProblemRepository::new(pool); | |
| 38 | + | ||
| 39 | + | let created = repo | |
| 40 | + | .ingest(user_id, new_problem("t-1")) | |
| 41 | + | .await | |
| 42 | + | .expect("ingest failed"); | |
| 43 | + | assert_eq!(created.title, "sync drops attachments"); | |
| 44 | + | assert_eq!(created.status, ProblemStatus::Open); | |
| 45 | + | assert_eq!(created.tags, vec!["sync".to_string()]); | |
| 46 | + | assert!(created.promoted_task_id.is_none()); | |
| 47 | + | assert!(created.settled_at.is_none()); | |
| 48 | + | ||
| 49 | + | let found = repo | |
| 50 | + | .find_by_source_ref(user_id, "wam", "t-1") | |
| 51 | + | .await | |
| 52 | + | .expect("lookup failed") | |
| 53 | + | .expect("problem missing"); | |
| 54 | + | assert_eq!(found.id, created.id); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[tokio::test] | |
| 58 | + | async fn re_ingest_updates_in_place_instead_of_duplicating() { | |
| 59 | + | let pool = common::setup_test_db().await; | |
| 60 | + | let user_id = common::create_test_user(&pool).await; | |
| 61 | + | let repo = SqliteProblemRepository::new(pool); | |
| 62 | + | ||
| 63 | + | let first = repo.ingest(user_id, new_problem("t-1")).await.unwrap(); | |
| 64 | + | ||
| 65 | + | let mut changed = new_problem("t-1"); | |
| 66 | + | changed.title = "sync drops attachments on mobile".to_string(); | |
| 67 | + | changed.pain = 5; | |
| 68 | + | changed.scale = 4; | |
| 69 | + | let second = repo.ingest(user_id, changed).await.unwrap(); | |
| 70 | + | ||
| 71 | + | // Same row, refreshed with what the source now says. | |
| 72 | + | assert_eq!(second.id, first.id); | |
| 73 | + | assert_eq!(second.title, "sync drops attachments on mobile"); | |
| 74 | + | assert_eq!(second.pain, 5); | |
| 75 | + | assert_eq!(second.scale, 4); | |
| 76 | + | ||
| 77 | + | let all = repo.list(user_id, &ProblemFilter::default()).await.unwrap(); | |
| 78 | + | assert_eq!(all.len(), 1, "upsert must not duplicate on provenance key"); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | #[tokio::test] | |
| 82 | + | async fn re_ingest_does_not_undo_triage() { | |
| 83 | + | let pool = common::setup_test_db().await; | |
| 84 | + | let user_id = common::create_test_user(&pool).await; | |
| 85 | + | let task_id = common::create_test_task(&pool, user_id).await; | |
| 86 | + | let repo = SqliteProblemRepository::new(pool); | |
| 87 | + | ||
| 88 | + | let promoted_ref = "t-promoted"; | |
| 89 | + | let dismissed_ref = "t-dismissed"; | |
| 90 | + | let p = repo | |
| 91 | + | .ingest(user_id, new_problem(promoted_ref)) | |
| 92 | + | .await | |
| 93 | + | .unwrap(); | |
| 94 | + | let d = repo | |
| 95 | + | .ingest(user_id, new_problem(dismissed_ref)) | |
| 96 | + | .await | |
| 97 | + | .unwrap(); | |
| 98 | + | repo.promote(p.id, user_id, task_id).await.unwrap(); | |
| 99 | + | repo.set_status(d.id, user_id, ProblemStatus::Dismissed) | |
| 100 | + | .await | |
| 101 | + | .unwrap(); | |
| 102 | + | ||
| 103 | + | // The source re-reports both, and now calls them fixed. A human already | |
| 104 | + | // ruled on each, so neither ruling is overwritten. | |
| 105 | + | for source_ref in [promoted_ref, dismissed_ref] { | |
| 106 | + | let mut again = new_problem(source_ref); | |
| 107 | + | again.resolved_upstream = true; | |
| 108 | + | repo.ingest(user_id, again).await.unwrap(); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | let after_promote = repo | |
| 112 | + | .find_by_source_ref(user_id, "wam", promoted_ref) | |
| 113 | + | .await | |
| 114 | + | .unwrap() | |
| 115 | + | .unwrap(); | |
| 116 | + | assert_eq!(after_promote.status, ProblemStatus::Promoted); | |
| 117 | + | assert_eq!(after_promote.promoted_task_id, Some(task_id)); | |
| 118 | + | assert!(after_promote.settled_at.is_some()); | |
| 119 | + | ||
| 120 | + | let after_dismiss = repo | |
| 121 | + | .find_by_source_ref(user_id, "wam", dismissed_ref) | |
| 122 | + | .await | |
| 123 | + | .unwrap() | |
| 124 | + | .unwrap(); | |
| 125 | + | assert_eq!(after_dismiss.status, ProblemStatus::Dismissed); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[tokio::test] | |
| 129 | + | async fn upstream_resolution_settles_an_untriaged_problem() { | |
| 130 | + | let pool = common::setup_test_db().await; | |
| 131 | + | let user_id = common::create_test_user(&pool).await; | |
| 132 | + | let repo = SqliteProblemRepository::new(pool); | |
| 133 | + | ||
| 134 | + | repo.ingest(user_id, new_problem("t-1")).await.unwrap(); | |
| 135 | + | ||
| 136 | + | let mut fixed = new_problem("t-1"); | |
| 137 | + | fixed.resolved_upstream = true; | |
| 138 | + | let settled = repo.ingest(user_id, fixed).await.unwrap(); | |
| 139 | + | ||
| 140 | + | assert_eq!(settled.status, ProblemStatus::Resolved); | |
| 141 | + | assert!(settled.settled_at.is_some()); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | #[tokio::test] | |
| 145 | + | async fn promote_links_the_task_and_freezes_the_score() { | |
| 146 | + | let pool = common::setup_test_db().await; | |
| 147 | + | let user_id = common::create_test_user(&pool).await; | |
| 148 | + | let task_id = common::create_test_task(&pool, user_id).await; | |
| 149 | + | let repo = SqliteProblemRepository::new(pool); | |
| 150 | + | ||
| 151 | + | // Backdate it so there is a score to freeze. | |
| 152 | + | let mut old = new_problem("t-1"); | |
| 153 | + | old.created_at = Utc::now() - Duration::days(120); | |
| 154 | + | let problem = repo.ingest(user_id, old).await.unwrap(); | |
| 155 | + | let open_score = problem.painhours(); | |
| 156 | + | ||
| 157 | + | let promoted = repo | |
| 158 | + | .promote(problem.id, user_id, task_id) | |
| 159 | + | .await | |
| 160 | + | .unwrap() | |
| 161 | + | .expect("promote returned None"); | |
| 162 | + | ||
| 163 | + | assert_eq!(promoted.status, ProblemStatus::Promoted); | |
| 164 | + | assert_eq!(promoted.promoted_task_id, Some(task_id)); | |
| 165 | + | ||
| 166 | + | // Settling anchors age at the moment of promotion, so the score is the same | |
| 167 | + | // right now and stops growing from here. It does not drop: the problem was | |
| 168 | + | // genuinely that urgent when it got promoted. | |
| 169 | + | assert_eq!(promoted.painhours(), open_score); | |
| 170 | + | let frozen_weeks = promoted.age_weeks(); | |
| 171 | + | assert!(promoted.settled_at.is_some()); | |
| 172 | + | ||
| 173 | + | // Re-reading later must give the same age, which is what "frozen" means. An | |
| 174 | + | // open problem recomputes against the clock; this one recomputes against | |
| 175 | + | // settled_at. | |
| 176 | + | let reread = repo.get_by_id(promoted.id, user_id).await.unwrap().unwrap(); | |
| 177 | + | assert_eq!(reread.age_weeks(), frozen_weeks); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[tokio::test] | |
| 181 | + | async fn reopening_clears_the_backlink_and_resumes_the_climb() { | |
| 182 | + | let pool = common::setup_test_db().await; | |
| 183 | + | let user_id = common::create_test_user(&pool).await; | |
| 184 | + | let task_id = common::create_test_task(&pool, user_id).await; | |
| 185 | + | let repo = SqliteProblemRepository::new(pool); | |
| 186 | + | ||
| 187 | + | let mut old = new_problem("t-1"); | |
| 188 | + | old.created_at = Utc::now() - Duration::days(120); | |
| 189 | + | let problem = repo.ingest(user_id, old).await.unwrap(); | |
| 190 | + | let open_score = problem.painhours(); | |
| 191 | + | ||
| 192 | + | repo.promote(problem.id, user_id, task_id).await.unwrap(); | |
| 193 | + | let reopened = repo | |
| 194 | + | .set_status(problem.id, user_id, ProblemStatus::Open) | |
| 195 | + | .await | |
| 196 | + | .unwrap() | |
| 197 | + | .unwrap(); | |
| 198 | + | ||
| 199 | + | assert_eq!(reopened.status, ProblemStatus::Open); | |
| 200 | + | assert!(reopened.promoted_task_id.is_none()); | |
| 201 | + | assert!(reopened.settled_at.is_none()); | |
| 202 | + | assert_eq!(reopened.painhours(), open_score); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | #[tokio::test] | |
| 206 | + | async fn list_ranks_by_painhours_not_insertion_order() { | |
| 207 | + | let pool = common::setup_test_db().await; | |
| 208 | + | let user_id = common::create_test_user(&pool).await; | |
| 209 | + | let repo = SqliteProblemRepository::new(pool); | |
| 210 | + | ||
| 211 | + | // Inserted mildest first, so insertion order is the reverse of the ranking. | |
| 212 | + | let mut mild = new_problem("mild"); | |
| 213 | + | mild.pain = 1; | |
| 214 | + | mild.scale = 1; | |
| 215 | + | repo.ingest(user_id, mild).await.unwrap(); | |
| 216 | + | ||
| 217 | + | let mut widespread = new_problem("widespread"); | |
| 218 | + | widespread.pain = 1; | |
| 219 | + | widespread.scale = 5; | |
| 220 | + | repo.ingest(user_id, widespread).await.unwrap(); | |
| 221 | + | ||
| 222 | + | let listed = repo.list(user_id, &ProblemFilter::default()).await.unwrap(); | |
| 223 | + | assert_eq!(listed.len(), 2); | |
| 224 | + | assert_eq!(listed[0].source_ref, "widespread"); | |
| 225 | + | assert!(listed[0].painhours() > listed[1].painhours()); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[tokio::test] | |
| 229 | + | async fn list_filters_compose() { | |
| 230 | + | let pool = common::setup_test_db().await; | |
| 231 | + | let user_id = common::create_test_user(&pool).await; | |
| 232 | + | let repo = SqliteProblemRepository::new(pool); | |
| 233 | + | ||
| 234 | + | repo.ingest(user_id, new_problem("wam-1")).await.unwrap(); | |
| 235 | + | let mut from_audit = new_problem("audit-1"); | |
| 236 | + | from_audit.source = "audit".to_string(); | |
| 237 | + | let audit = repo.ingest(user_id, from_audit).await.unwrap(); | |
| 238 | + | repo.set_status(audit.id, user_id, ProblemStatus::Dismissed) | |
| 239 | + | .await | |
| 240 | + | .unwrap(); | |
| 241 | + | ||
| 242 | + | let open = repo | |
| 243 | + | .list( | |
| 244 | + | user_id, | |
| 245 | + | &ProblemFilter { | |
| 246 | + | status: Some(ProblemStatus::Open), | |
| 247 | + | ..Default::default() | |
| 248 | + | }, | |
| 249 | + | ) | |
| 250 | + | .await | |
| 251 | + | .unwrap(); | |
| 252 | + | assert_eq!(open.len(), 1); | |
| 253 | + | assert_eq!(open[0].source, "wam"); | |
| 254 | + | ||
| 255 | + | let from_audit = repo | |
| 256 | + | .list( | |
| 257 | + | user_id, | |
| 258 | + | &ProblemFilter { | |
| 259 | + | source: Some("audit".to_string()), | |
| 260 | + | ..Default::default() | |
| 261 | + | }, | |
| 262 | + | ) | |
| 263 | + | .await | |
| 264 | + | .unwrap(); | |
| 265 | + | assert_eq!(from_audit.len(), 1); | |
| 266 | + | ||
| 267 | + | // Both filters at once, contradicting each other, must return nothing. | |
| 268 | + | let none = repo | |
| 269 | + | .list( | |
| 270 | + | user_id, | |
| 271 | + | &ProblemFilter { | |
| 272 | + | source: Some("audit".to_string()), | |
| 273 | + | status: Some(ProblemStatus::Open), | |
| 274 | + | ..Default::default() | |
| 275 | + | }, | |
| 276 | + | ) | |
| 277 | + | .await | |
| 278 | + | .unwrap(); | |
| 279 | + | assert!(none.is_empty()); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[tokio::test] | |
| 283 | + | async fn problems_are_scoped_to_their_user() { | |
| 284 | + | let pool = common::setup_test_db().await; | |
| 285 | + | let user_id = common::create_test_user(&pool).await; | |
| 286 | + | let repo = SqliteProblemRepository::new(pool); | |
| 287 | + | ||
| 288 | + | let mine = repo.ingest(user_id, new_problem("t-1")).await.unwrap(); | |
| 289 | + | let stranger = UserId::new(); | |
| 290 | + | ||
| 291 | + | assert!(repo.get_by_id(mine.id, stranger).await.unwrap().is_none()); | |
| 292 | + | assert!( | |
| 293 | + | repo.find_by_source_ref(stranger, "wam", "t-1") | |
| 294 | + | .await | |
| 295 | + | .unwrap() | |
| 296 | + | .is_none() | |
| 297 | + | ); | |
| 298 | + | assert!( | |
| 299 | + | repo.list(stranger, &ProblemFilter::default()) | |
| 300 | + | .await | |
| 301 | + | .unwrap() | |
| 302 | + | .is_empty() | |
| 303 | + | ); | |
| 304 | + | assert!(!repo.delete(mine.id, stranger).await.unwrap()); | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | #[tokio::test] | |
| 308 | + | async fn set_project_attributes_and_clears() { | |
| 309 | + | let pool = common::setup_test_db().await; | |
| 310 | + | let user_id = common::create_test_user(&pool).await; | |
| 311 | + | let repo = SqliteProblemRepository::new(pool); | |
| 312 | + | ||
| 313 | + | let problem = repo.ingest(user_id, new_problem("t-1")).await.unwrap(); | |
| 314 | + | assert!(problem.project_id.is_none()); | |
| 315 | + | ||
| 316 | + | // A project that does not exist would trip the FK, so this asserts the | |
| 317 | + | // clear path and leaves attribution to the adapter that knows real ids. | |
| 318 | + | let cleared = repo | |
| 319 | + | .set_project(problem.id, user_id, None) | |
| 320 | + | .await | |
| 321 | + | .unwrap() | |
| 322 | + | .unwrap(); | |
| 323 | + | assert!(cleared.project_id.is_none()); | |
| 324 | + | ||
| 325 | + | let missing = repo | |
| 326 | + | .set_project(ProblemId::new(), user_id, None) | |
| 327 | + | .await | |
| 328 | + | .unwrap(); | |
| 329 | + | assert!(missing.is_none(), "unknown id must return None, not error"); | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | #[tokio::test] | |
| 333 | + | async fn last_pulled_at_tracks_the_newest_sighting() { | |
| 334 | + | let pool = common::setup_test_db().await; | |
| 335 | + | let user_id = common::create_test_user(&pool).await; | |
| 336 | + | let repo = SqliteProblemRepository::new(pool); | |
| 337 | + | ||
| 338 | + | assert!( | |
| 339 | + | repo.last_pulled_at(user_id, "wam").await.unwrap().is_none(), | |
| 340 | + | "a never-pulled source has no timestamp" | |
| 341 | + | ); | |
| 342 | + | ||
| 343 | + | let problem = repo.ingest(user_id, new_problem("t-1")).await.unwrap(); | |
| 344 | + | let pulled = repo | |
| 345 | + | .last_pulled_at(user_id, "wam") | |
| 346 | + | .await | |
| 347 | + | .unwrap() | |
| 348 | + | .expect("expected a pull timestamp"); | |
| 349 | + | ||
| 350 | + | assert_eq!(pulled, problem.last_seen_at); | |
| 351 | + | assert!(!problem.is_stale(pulled)); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | #[tokio::test] | |
| 355 | + | async fn delete_removes_the_row() { | |
| 356 | + | let pool = common::setup_test_db().await; | |
| 357 | + | let user_id = common::create_test_user(&pool).await; | |
| 358 | + | let repo = SqliteProblemRepository::new(pool); | |
| 359 | + | ||
| 360 | + | let problem = repo.ingest(user_id, new_problem("t-1")).await.unwrap(); | |
| 361 | + | assert!(repo.delete(problem.id, user_id).await.unwrap()); | |
| 362 | + | assert!(repo.get_by_id(problem.id, user_id).await.unwrap().is_none()); | |
| 363 | + | assert!(!repo.delete(problem.id, user_id).await.unwrap()); | |
| 364 | + | } |
| @@ -79,6 +79,7 @@ async fn restore_preserves_completed_status_and_is_idempotent() { | |||
| 79 | 79 | weekly_reviews: vec![], | |
| 80 | 80 | monthly_goals: vec![], | |
| 81 | 81 | monthly_reflections: vec![], | |
| 82 | + | problems: vec![], | |
| 82 | 83 | }; | |
| 83 | 84 | ||
| 84 | 85 | // First restore recreates with status + completed_at + id preserved. | |
| @@ -192,6 +193,7 @@ async fn restore_preserves_subtask_completion_ordering_and_annotation_timestamp( | |||
| 192 | 193 | weekly_reviews: vec![], | |
| 193 | 194 | monthly_goals: vec![], | |
| 194 | 195 | monthly_reflections: vec![], | |
| 196 | + | problems: vec![], | |
| 195 | 197 | }; | |
| 196 | 198 | let r = restore_all(&pool, user_id, &input).await.unwrap(); | |
| 197 | 199 | assert_eq!(r.tasks_restored, 1); | |
| @@ -291,6 +293,7 @@ async fn restore_event_preserves_external_metadata() { | |||
| 291 | 293 | weekly_reviews: vec![], | |
| 292 | 294 | monthly_goals: vec![], | |
| 293 | 295 | monthly_reflections: vec![], | |
| 296 | + | problems: vec![], | |
| 294 | 297 | }; | |
| 295 | 298 | let r = restore_all(&pool, user_id, &input).await.unwrap(); | |
| 296 | 299 | assert_eq!(r.events_restored, 1); | |
| @@ -411,6 +414,7 @@ async fn restore_round_trips_supplemental_collections() { | |||
| 411 | 414 | weekly_reviews: vec![], | |
| 412 | 415 | monthly_goals: vec![], | |
| 413 | 416 | monthly_reflections: vec![], | |
| 417 | + | problems: vec![], | |
| 414 | 418 | }; | |
| 415 | 419 | // Deleting tasks cascades time_sessions + attachments; deleting projects | |
| 416 | 420 | // cascades milestones; daily_notes and sync_accounts are independent. |
| @@ -0,0 +1,72 @@ | |||
| 1 | + | -- The Problems inbox: a local mirror of problems pulled from external sources. | |
| 2 | + | -- | |
| 3 | + | -- GoingsOn is the list of SOLUTIONS (tasks). Other systems (wam tickets, /audit | |
| 4 | + | -- and /fuzz findings, later PoM alerts) are lists of PROBLEMS. A problem is a | |
| 5 | + | -- candidate a human promotes into a task; nothing here auto-creates one. | |
| 6 | + | -- | |
| 7 | + | -- This is a cached mirror, not the record of truth: adapters pull from each | |
| 8 | + | -- source and upsert on (source, source_ref). That is why the table is absent | |
| 9 | + | -- from the SyncKit manifest in src-tauri/src/syncstore/manifest.rs. Rows rebuild | |
| 10 | + | -- from their source, so syncing them would replicate regenerable data. The | |
| 11 | + | -- non-regenerable part is the triage decision, which lives in `status` and | |
| 12 | + | -- `promoted_task_id` and survives a re-pull because upserts do not touch it. | |
| 13 | + | -- | |
| 14 | + | -- Urgency is not stored. It is the painhours score, computed from pain, scale, | |
| 15 | + | -- and age by the shared `painhours` crate, so the ranking matches wam's. | |
| 16 | + | ||
| 17 | + | CREATE TABLE problems ( | |
| 18 | + | id TEXT PRIMARY KEY NOT NULL, | |
| 19 | + | user_id TEXT NOT NULL, | |
| 20 | + | ||
| 21 | + | -- Provenance. `source` names the adapter ('wam', 'audit', 'fuzz'), and | |
| 22 | + | -- `source_ref` is the id within it. Together they are the upsert key, the | |
| 23 | + | -- same idea as go-mcp's `source:` task tags. | |
| 24 | + | source TEXT NOT NULL, | |
| 25 | + | source_ref TEXT NOT NULL, | |
| 26 | + | ||
| 27 | + | title TEXT NOT NULL, | |
| 28 | + | body TEXT NOT NULL DEFAULT '', | |
| 29 | + | ||
| 30 | + | -- The painhours inputs, 1-5 guesstimates. Clamped on read, so a source that | |
| 31 | + | -- sends nonsense degrades to the nearest valid factor. | |
| 32 | + | pain INTEGER NOT NULL DEFAULT 3, | |
| 33 | + | scale INTEGER NOT NULL DEFAULT 3, | |
| 34 | + | ||
| 35 | + | -- Open | Promoted | Dismissed | Resolved. Anything but Open is settled and | |
| 36 | + | -- stops climbing the list (see settled_at). | |
| 37 | + | status TEXT NOT NULL DEFAULT 'Open', | |
| 38 | + | ||
| 39 | + | project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, | |
| 40 | + | tags TEXT NOT NULL DEFAULT '[]', | |
| 41 | + | ||
| 42 | + | -- Set when the problem is promoted; the backlink from problem to solution. | |
| 43 | + | -- ON DELETE SET NULL so deleting the task reopens triage rather than losing | |
| 44 | + | -- the problem. | |
| 45 | + | promoted_task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, | |
| 46 | + | ||
| 47 | + | -- The SOURCE's creation instant, not ours: age drives the painhours score, | |
| 48 | + | -- so a ticket filed weeks ago must not read as new because we pulled it | |
| 49 | + | -- today. | |
| 50 | + | created_at TEXT NOT NULL, | |
| 51 | + | updated_at TEXT NOT NULL, | |
| 52 | + | ||
| 53 | + | -- The instant the problem left the open set (promoted, dismissed, or | |
| 54 | + | -- resolved upstream). Freezes the age anchor so settled problems stop | |
| 55 | + | -- climbing. NULL while Open. | |
| 56 | + | settled_at TEXT, | |
| 57 | + | ||
| 58 | + | -- Last pull that saw this problem in its source. Lets a source-gone row be | |
| 59 | + | -- shown as stale rather than silently deleted. | |
| 60 | + | last_seen_at TEXT NOT NULL | |
| 61 | + | ); | |
| 62 | + | ||
| 63 | + | -- The upsert key. A re-pull updates the existing row instead of duplicating it. | |
| 64 | + | CREATE UNIQUE INDEX idx_problems_provenance ON problems(user_id, source, source_ref); | |
| 65 | + | ||
| 66 | + | -- The inbox query: open problems for a user, optionally narrowed by source or | |
| 67 | + | -- project. Final ranking is by painhours, which is time-dependent and therefore | |
| 68 | + | -- computed in Rust after the fetch (as wam does), not sorted here. | |
| 69 | + | CREATE INDEX idx_problems_status ON problems(user_id, status); | |
| 70 | + | CREATE INDEX idx_problems_source ON problems(user_id, source); | |
| 71 | + | CREATE INDEX idx_problems_project ON problems(project_id); | |
| 72 | + | CREATE INDEX idx_problems_promoted_task ON problems(promoted_task_id); |
| @@ -61,3 +61,4 @@ | |||
| 61 | 61 | 058 8c800b52c85af7f107d7706f4d5c3dfcc3a44f5877a606da23bb3e1df0fa4812f34f2fc78999b16bcab9a6491ff78512 | |
| 62 | 62 | 059 32140c4ee5bb75b9d6530a0b7c60cef872acc5fa50bb969eb9368036131d4f84931cddbcf6f8b41038fb0d5fc175af7b | |
| 63 | 63 | 060 e5b833cff6c768710b0daf88fc9cf087938278ec158c99c0789242878832e802ae00e82bef2cc45efe21644de8d9fa57 | |
| 64 | + | 061 8a37aa99045c49df8e54d36227702e68f570abb870114da14b31760cd0a1433cdd8f0d234808ec0d0da22672bc7fcb11 |
| @@ -55,6 +55,12 @@ pub(crate) async fn collect_full_export( | |||
| 55 | 55 | let weekly_reviews = state.weekly_reviews.list_all(user_id).await?; | |
| 56 | 56 | let monthly_goals = state.monthly_reviews.list_all_goals(user_id).await?; | |
| 57 | 57 | let monthly_reflections = state.monthly_reviews.list_all_reflections(user_id).await?; | |
| 58 | + | // Unfiltered: a backup captures settled problems too, since dismissals and | |
| 59 | + | // promotions are the part that cannot be re-pulled. | |
| 60 | + | let problems = state | |
| 61 | + | .problems | |
| 62 | + | .list(user_id, &goingson_core::ProblemFilter::default()) | |
| 63 | + | .await?; | |
| 58 | 64 | Ok(FullExport::new( | |
| 59 | 65 | projects, | |
| 60 | 66 | tasks, | |
| @@ -70,6 +76,7 @@ pub(crate) async fn collect_full_export( | |||
| 70 | 76 | weekly_reviews, | |
| 71 | 77 | monthly_goals, | |
| 72 | 78 | monthly_reflections, | |
| 79 | + | problems, | |
| 73 | 80 | )) | |
| 74 | 81 | } | |
| 75 | 82 |