feat(reminders): persist reminder_set; save_action_items returns saved rows (T8.6, FR-CAL-5)

reminder_set was accepted on the model but hard-coded to 0 on insert
and never included in the UPDATE. save_action_items's return type
changes from () to Vec<ActionItem> so the caller (which needs the row
id to key reminder scheduling) doesn't need a second round-trip for
newly-inserted items. Also adds list_pending_reminders for startup
reconciliation.
This commit is contained in:
iamdoubz committed 2026-07-02 11:59:09 -05:00
1 parent 994ace4b3f
commit 7f97328a2d
1 file changed
+48 -9
+48 -9
View File
@@ -167,12 +167,14 @@ pub trait Store: Send + Sync {
/// Persist reviewed/edited action items as confirmed tasks (T5.6,
/// FR-LLM-3): items with an `id` update that row, items without one
/// insert a new row. Drafted (unconfirmed) items from `generate_summary`
/// only ever live in `summary.json` until this is called.
/// only ever live in `summary.json` until this is called. Returns the
/// saved items with `id` populated (Phase 8, T8.6) — new rows don't have
/// one yet at call time, but the caller needs it to key reminder scheduling.
async fn save_action_items(
&self,
id: &MeetingId,
items: &[ActionItem],
) -> Result<(), StoreError>;
) -> Result<Vec<ActionItem>, StoreError>;
/// Persist imported calendar events + their attendees (T6.1/T6.2,
/// FR-CAL-1). Re-importing the same event (matched by `source` +
/// `raw_uid`) updates it in place rather than duplicating it. Returns
@@ -213,6 +215,12 @@ pub trait Store: Send + Sync {
async fn set_tags(&self, meeting_id: &MeetingId, tags: &[String]) -> Result<(), StoreError>;
/// All known tag names, sorted, for filter/autocomplete UI.
async fn list_tags(&self) -> Result<Vec<String>, StoreError>;
/// Action items with an unfired reminder due in the future (Phase 8,
/// T8.6, FR-CAL-5) — a startup reconcile against Windows' scheduled-toast
/// list, since that's the actual source of truth for "already scheduled"
/// (see `reminders` module) and it can be cleared out from under the app
/// (uninstall/reinstall, a different AppUserModelID, etc).
async fn list_pending_reminders(&self) -> Result<Vec<ActionItem>, StoreError>;
/// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1).
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>;
/// Enforce retention; returns count removed. Skips in-progress meetings
@@ -913,42 +921,52 @@ impl Store for SqliteStore {
&self,
id: &MeetingId,
items: &[ActionItem],
) -> Result<(), StoreError> {
) -> Result<Vec<ActionItem>, StoreError> {
let now = now_unix();
let mut saved = Vec::with_capacity(items.len());
for item in items {
match &item.id {
let item_id = match &item.id {
Some(existing_id) => {
sqlx::query(
"UPDATE action_items SET text = ?, owner = ?, due_at = ?, confirmed = ?
"UPDATE action_items SET text = ?, owner = ?, due_at = ?, confirmed = ?, reminder_set = ?
WHERE id = ? AND meeting_id = ?",
)
.bind(&item.text)
.bind(&item.owner)
.bind(item.due_at)
.bind(item.confirmed as i64)
.bind(item.reminder_set as i64)
.bind(existing_id)
.bind(id)
.execute(&self.pool)
.await?;
existing_id.clone()
}
None => {
let new_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO action_items (id, meeting_id, text, owner, due_at, confirmed, reminder_set, created_at)
VALUES (?, ?, ?, ?, ?, ?, 0, ?)",
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&new_id)
.bind(id)
.bind(&item.text)
.bind(&item.owner)
.bind(item.due_at)
.bind(item.confirmed as i64)
.bind(item.reminder_set as i64)
.bind(now)
.execute(&self.pool)
.await?;
new_id
}
}
};
saved.push(ActionItem {
id: Some(item_id),
..item.clone()
});
}
Ok(())
Ok(saved)
}
async fn import_calendar_events(&self, events: Vec<ImportedEvent>) -> Result<u32, StoreError> {
@@ -1070,6 +1088,27 @@ impl Store for SqliteStore {
.await?)
}
async fn list_pending_reminders(&self) -> Result<Vec<ActionItem>, StoreError> {
let rows = sqlx::query(
"SELECT id, text, owner, due_at, confirmed, reminder_set FROM action_items
WHERE reminder_set = 1 AND due_at IS NOT NULL AND due_at > ?",
)
.bind(now_unix())
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|r| ActionItem {
id: Some(r.get("id")),
text: r.get("text"),
owner: r.get("owner"),
due_at: r.get("due_at"),
confirmed: r.get::<i64, _>("confirmed") != 0,
reminder_set: r.get::<i64, _>("reminder_set") != 0,
})
.collect())
}
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError> {
let rows = sqlx::query(
"SELECT id, audio_path FROM meetings WHERE status IN ('recording', 'transcribing')",