Skip to main content

max / goingson

Add the problems table and repository The storage half of the Problems inbox. Problems are a mirror of external sources (wam tickets, /audit and /fuzz findings) that a human promotes into tasks; GoingsOn stays the list of solutions. Ingest upserts on (source, source_ref) and refreshes only what the source owns, so re-pulling never undoes a triage decision. The one exception is a source reporting a fix, which settles an untriaged row but leaves an already promoted or dismissed one alone. Ranking is painhours from the shared crate, computed rather than stored because it moves with the clock, so list() sorts in Rust as wam does. Settling freezes the age anchor at settled_at, which stops promoted and dismissed problems from climbing. The table is backed up rather than excluded: the mirrored fields re-pull on their own, but dismissals, promotions, and the task backlink do not. That bumps the export format to 1.4. It stays out of the SyncKit manifest, where replicating a regenerable mirror would buy nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 01:15 UTC
Commit: 86cc60dee711468e3f89be898bdb88f901f74309
Parent: b8e66fb
25 files changed, +1299 insertions, -26 deletions
M Cargo.lock +9
@@ -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"
M Cargo.toml +3
@@ -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