app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit dad32c7686f4757cb01a2d1452c4d6ae3fb58095
parent 64903b2b7093a4f6510ea0096e9c6e8d389e7968
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 22:58:52 +0000

app: add reminder and recovery sqlite persistence

Diffstat:
Acrates/shared/sqlite/migrations/0011_reminders_and_recovery.sql | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/shared/sqlite/src/lib.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/shared/sqlite/src/migrations.rs | 4++++
Acrates/shared/sqlite/src/reminders.rs | 730+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 924 insertions(+), 4 deletions(-)

diff --git a/crates/shared/sqlite/migrations/0011_reminders_and_recovery.sql b/crates/shared/sqlite/migrations/0011_reminders_and_recovery.sql @@ -0,0 +1,103 @@ +CREATE TABLE reminder_schedules ( + reminder_id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL, + farm_id TEXT NOT NULL, + order_id TEXT, + fulfillment_window_id TEXT, + reminder_kind TEXT NOT NULL CHECK ( + reminder_kind IN ( + 'fulfillment_window', + 'order_action', + 'missed_pickup_recovery', + 'refund_recovery', + 'sync_impact' + ) + ), + reminder_surface TEXT NOT NULL CHECK ( + reminder_surface IN ('today', 'orders', 'pack_day') + ), + reminder_urgency TEXT NOT NULL CHECK ( + reminder_urgency IN ('upcoming', 'due_soon', 'overdue', 'blocking') + ), + title TEXT NOT NULL, + detail TEXT NOT NULL, + deadline_at TEXT NOT NULL, + action_label TEXT, + delivery_state TEXT NOT NULL CHECK ( + delivery_state IN ('scheduled', 'presented', 'acknowledged', 'resolved') + ) +); + +CREATE TABLE reminder_log_entries ( + log_entry_id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL, + farm_id TEXT NOT NULL, + reminder_id TEXT NOT NULL, + reminder_kind TEXT NOT NULL CHECK ( + reminder_kind IN ( + 'fulfillment_window', + 'order_action', + 'missed_pickup_recovery', + 'refund_recovery', + 'sync_impact' + ) + ), + title TEXT NOT NULL, + recorded_at TEXT NOT NULL, + delivery_state TEXT NOT NULL CHECK ( + delivery_state IN ('scheduled', 'presented', 'acknowledged', 'resolved') + ), + detail TEXT +); + +CREATE TABLE order_recovery_records ( + recovery_record_id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL, + farm_id TEXT NOT NULL, + order_id TEXT NOT NULL, + recovery_kind TEXT NOT NULL CHECK ( + recovery_kind IN ('missed_pickup', 'refund_follow_up') + ), + recovery_state TEXT NOT NULL CHECK ( + recovery_state IN ('open', 'in_review', 'resolved') + ), + summary TEXT NOT NULL, + note TEXT, + last_updated_at TEXT NOT NULL, + UNIQUE(account_id, order_id, recovery_kind) +); + +CREATE INDEX idx_reminder_schedules_account_farm_deadline ON reminder_schedules( + account_id, + farm_id, + deadline_at, + reminder_id +); +CREATE INDEX idx_reminder_schedules_account_farm_surface ON reminder_schedules( + account_id, + farm_id, + reminder_surface, + deadline_at +); +CREATE INDEX idx_reminder_log_entries_account_farm_recorded_at ON reminder_log_entries( + account_id, + farm_id, + recorded_at, + log_entry_id +); +CREATE INDEX idx_reminder_log_entries_account_farm_reminder ON reminder_log_entries( + account_id, + farm_id, + reminder_id +); +CREATE INDEX idx_order_recovery_records_account_farm_updated_at ON order_recovery_records( + account_id, + farm_id, + last_updated_at, + recovery_record_id +); +CREATE INDEX idx_order_recovery_records_account_order_kind ON order_recovery_records( + account_id, + order_id, + recovery_kind +); diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -9,6 +9,7 @@ mod farm_setup; mod migrations; mod orders; mod products; +mod reminders; mod sync; mod today; @@ -19,10 +20,11 @@ use radroots_app_models::{ BuyerCartProjection, BuyerCheckoutDraft, BuyerCheckoutProjection, BuyerContext, BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrdersProjection, BuyerProductDetailProjection, FarmId, FarmOrderMethod, FarmRulesProjection, - FarmSetupProjection, FarmSummary, OrderDetailProjection, OrderId, OrdersListProjection, - OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, ProductEditorDraft, - ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, - TodayAgendaProjection, + FarmSetupProjection, FarmSummary, OrderDetailProjection, OrderId, OrderRecoveryProjection, + OrdersListProjection, OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, + ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, + ProductsSort, RecoveryKind, RecoveryQueueProjection, ReminderFeedProjection, + ReminderLogEntryProjection, ReminderLogProjection, TodayAgendaProjection, }; use radroots_app_sync::{ PendingSyncOperation, SyncCheckpointStatus, SyncConflict, SyncConflictResolutionStatus, @@ -40,6 +42,7 @@ pub use farm_setup::AppFarmSetupRepository; pub use migrations::latest_schema_version; pub use orders::AppOrdersRepository; pub use products::AppProductsRepository; +pub use reminders::AppRemindersRepository; pub use sync::{AppSyncRepository, StoredPendingSyncOperation, StoredSyncConflict}; pub use today::{ AppTodayAgendaRepository, TODAY_AGENDA_LIST_LIMIT, TODAY_AGENDA_LOW_STOCK_THRESHOLD, @@ -113,6 +116,10 @@ impl AppSqliteStore { AppSyncRepository::new(&self.connection) } + pub fn reminders_repository(&self) -> AppRemindersRepository<'_> { + AppRemindersRepository::new(&self.connection) + } + pub fn load_today_agenda( &self, farm_id: Option<FarmId>, @@ -254,6 +261,74 @@ impl AppSqliteStore { .mark_order_completed(farm_id, order_id) } + pub fn load_reminder_schedule( + &self, + account_id: &str, + farm_id: FarmId, + ) -> Result<ReminderFeedProjection, AppSqliteError> { + self.reminders_repository() + .load_reminder_schedule(account_id, farm_id) + } + + pub fn replace_reminder_schedule( + &self, + account_id: &str, + farm_id: FarmId, + projection: &ReminderFeedProjection, + ) -> Result<(), AppSqliteError> { + self.reminders_repository() + .replace_reminder_schedule(account_id, farm_id, projection) + } + + pub fn record_reminder_log_entry( + &self, + account_id: &str, + farm_id: FarmId, + entry: &ReminderLogEntryProjection, + ) -> Result<String, AppSqliteError> { + self.reminders_repository() + .record_reminder_log_entry(account_id, farm_id, entry) + } + + pub fn load_reminder_log( + &self, + account_id: &str, + farm_id: FarmId, + limit: usize, + ) -> Result<ReminderLogProjection, AppSqliteError> { + self.reminders_repository() + .load_reminder_log(account_id, farm_id, limit) + } + + pub fn load_recovery_queue( + &self, + account_id: &str, + farm_id: FarmId, + ) -> Result<RecoveryQueueProjection, AppSqliteError> { + self.reminders_repository() + .load_recovery_queue(account_id, farm_id) + } + + pub fn load_recovery_record( + &self, + account_id: &str, + order_id: OrderId, + kind: RecoveryKind, + ) -> Result<Option<OrderRecoveryProjection>, AppSqliteError> { + self.reminders_repository() + .load_recovery_record(account_id, order_id, kind) + } + + pub fn save_recovery_record( + &self, + account_id: &str, + farm_id: FarmId, + record: &OrderRecoveryProjection, + ) -> Result<(), AppSqliteError> { + self.reminders_repository() + .save_recovery_record(account_id, farm_id, record) + } + pub fn save_product_editor_draft( &self, product_id: ProductId, @@ -579,6 +654,9 @@ mod tests { assert!(table_exists(connection, "order_lines")); assert!(table_exists(connection, "buyer_carts")); assert!(table_exists(connection, "buyer_cart_lines")); + assert!(table_exists(connection, "reminder_schedules")); + assert!(table_exists(connection, "reminder_log_entries")); + assert!(table_exists(connection, "order_recovery_records")); assert!(column_exists(connection, "farms", "timezone")); assert!(column_exists(connection, "farms", "currency_code")); assert!(column_exists(connection, "local_outbox", "account_id")); @@ -616,6 +694,11 @@ mod tests { assert!(column_exists(connection, "orders", "buyer_email")); assert!(column_exists(connection, "orders", "buyer_phone")); assert!(column_exists(connection, "orders", "buyer_order_note")); + assert!(column_exists(connection, "reminder_schedules", "account_id")); + assert!(column_exists(connection, "reminder_schedules", "delivery_state")); + assert!(column_exists(connection, "reminder_log_entries", "recorded_at")); + assert!(column_exists(connection, "order_recovery_records", "recovery_kind")); + assert!(column_exists(connection, "order_recovery_records", "recovery_state")); assert_eq!(row_count(connection, "sync_checkpoints"), 0); drop(store); diff --git a/crates/shared/sqlite/src/migrations.rs b/crates/shared/sqlite/src/migrations.rs @@ -44,6 +44,10 @@ const MIGRATIONS: &[Migration] = &[ version: 10, sql: include_str!("../migrations/0010_sync_contract_alignment.sql"), }, + Migration { + version: 11, + sql: include_str!("../migrations/0011_reminders_and_recovery.sql"), + }, ]; pub fn latest_schema_version() -> u32 { diff --git a/crates/shared/sqlite/src/reminders.rs b/crates/shared/sqlite/src/reminders.rs @@ -0,0 +1,730 @@ +use radroots_app_models::{ + FarmId, OrderId, OrderRecoveryProjection, RecoveryKind, RecoveryQueueProjection, + RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, + ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, + ReminderUrgency, +}; +use rusqlite::{Connection, OptionalExtension, params}; +use std::str::FromStr; +use uuid::Uuid; + +use crate::AppSqliteError; + +pub struct AppRemindersRepository<'a> { + connection: &'a Connection, +} + +impl<'a> AppRemindersRepository<'a> { + pub const fn new(connection: &'a Connection) -> Self { + Self { connection } + } + + pub fn load_reminder_schedule( + &self, + account_id: &str, + farm_id: FarmId, + ) -> Result<ReminderFeedProjection, AppSqliteError> { + let mut statement = self + .connection + .prepare( + "SELECT + reminder_id, + order_id, + fulfillment_window_id, + reminder_kind, + reminder_surface, + reminder_urgency, + title, + detail, + deadline_at, + action_label, + delivery_state + FROM reminder_schedules + WHERE account_id = ?1 AND farm_id = ?2 + ORDER BY deadline_at ASC, reminder_id ASC", + ) + .map_err(|source| AppSqliteError::Query { + operation: "prepare reminder schedule query", + source, + })?; + let rows = statement + .query_map(params![account_id, farm_id.to_string()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option<String>>(1)?, + row.get::<_, Option<String>>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, Option<String>>(9)?, + row.get::<_, String>(10)?, + )) + }) + .map_err(|source| AppSqliteError::Query { + operation: "query reminder schedule", + source, + })?; + + let items = rows + .map(|row| { + let ( + reminder_id, + order_id, + fulfillment_window_id, + reminder_kind, + reminder_surface, + reminder_urgency, + title, + detail, + deadline_at, + action_label, + delivery_state, + ) = row.map_err(|source| AppSqliteError::Query { + operation: "read reminder schedule row", + source, + })?; + + Ok(ReminderDeadlineProjection { + reminder_id: parse_typed_id("reminder_schedules.reminder_id", reminder_id)?, + farm_id, + order_id: parse_optional_typed_id("reminder_schedules.order_id", order_id)?, + fulfillment_window_id: parse_optional_typed_id( + "reminder_schedules.fulfillment_window_id", + fulfillment_window_id, + )?, + kind: parse_reminder_kind(reminder_kind)?, + surface: parse_reminder_surface(reminder_surface)?, + urgency: parse_reminder_urgency(reminder_urgency)?, + title, + detail, + deadline_at, + action_label, + delivery_state: parse_reminder_delivery_state(delivery_state)?, + }) + }) + .collect::<Result<Vec<_>, AppSqliteError>>()?; + + Ok(ReminderFeedProjection { items }) + } + + pub fn replace_reminder_schedule( + &self, + account_id: &str, + farm_id: FarmId, + projection: &ReminderFeedProjection, + ) -> Result<(), AppSqliteError> { + let transaction = self + .connection + .unchecked_transaction() + .map_err(|source| AppSqliteError::Query { + operation: "begin reminder schedule replacement", + source, + })?; + + transaction + .execute( + "DELETE FROM reminder_schedules WHERE account_id = ?1 AND farm_id = ?2", + params![account_id, farm_id.to_string()], + ) + .map_err(|source| AppSqliteError::Query { + operation: "clear reminder schedule", + source, + })?; + + { + let mut statement = transaction + .prepare( + "INSERT INTO reminder_schedules ( + reminder_id, + account_id, + farm_id, + order_id, + fulfillment_window_id, + reminder_kind, + reminder_surface, + reminder_urgency, + title, + detail, + deadline_at, + action_label, + delivery_state + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + ) + .map_err(|source| AppSqliteError::Query { + operation: "prepare reminder schedule insert", + source, + })?; + + for reminder in &projection.items { + statement + .execute(params![ + reminder.reminder_id.to_string(), + account_id, + reminder.farm_id.to_string(), + reminder.order_id.map(|value| value.to_string()), + reminder + .fulfillment_window_id + .map(|value| value.to_string()), + reminder.kind.storage_key(), + reminder.surface.storage_key(), + reminder.urgency.storage_key(), + reminder.title, + reminder.detail, + reminder.deadline_at, + reminder.action_label, + reminder.delivery_state.storage_key(), + ]) + .map_err(|source| AppSqliteError::Query { + operation: "insert reminder schedule row", + source, + })?; + } + } + + transaction.commit().map_err(|source| AppSqliteError::Query { + operation: "commit reminder schedule replacement", + source, + })?; + + Ok(()) + } + + pub fn record_reminder_log_entry( + &self, + account_id: &str, + farm_id: FarmId, + entry: &ReminderLogEntryProjection, + ) -> Result<String, AppSqliteError> { + let log_entry_id = Uuid::now_v7().to_string(); + + self.connection + .execute( + "INSERT INTO reminder_log_entries ( + log_entry_id, + account_id, + farm_id, + reminder_id, + reminder_kind, + title, + recorded_at, + delivery_state, + detail + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + log_entry_id, + account_id, + farm_id.to_string(), + entry.reminder_id.to_string(), + entry.kind.storage_key(), + entry.title, + entry.recorded_at, + entry.delivery_state.storage_key(), + entry.detail, + ], + ) + .map_err(|source| AppSqliteError::Query { + operation: "record reminder log entry", + source, + })?; + + Ok(log_entry_id) + } + + pub fn load_reminder_log( + &self, + account_id: &str, + farm_id: FarmId, + limit: usize, + ) -> Result<ReminderLogProjection, AppSqliteError> { + let mut statement = self + .connection + .prepare( + "SELECT + reminder_id, + reminder_kind, + title, + recorded_at, + delivery_state, + detail + FROM reminder_log_entries + WHERE account_id = ?1 AND farm_id = ?2 + ORDER BY recorded_at DESC, log_entry_id DESC + LIMIT ?3", + ) + .map_err(|source| AppSqliteError::Query { + operation: "prepare reminder log query", + source, + })?; + let rows = statement + .query_map(params![account_id, farm_id.to_string(), limit as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option<String>>(5)?, + )) + }) + .map_err(|source| AppSqliteError::Query { + operation: "query reminder log", + source, + })?; + + let entries = rows + .map(|row| { + let (reminder_id, reminder_kind, title, recorded_at, delivery_state, detail) = + row.map_err(|source| AppSqliteError::Query { + operation: "read reminder log row", + source, + })?; + + Ok(ReminderLogEntryProjection { + reminder_id: parse_typed_id("reminder_log_entries.reminder_id", reminder_id)?, + kind: parse_reminder_kind(reminder_kind)?, + title, + recorded_at, + delivery_state: parse_reminder_delivery_state(delivery_state)?, + detail, + }) + }) + .collect::<Result<Vec<_>, AppSqliteError>>()?; + + Ok(ReminderLogProjection { entries }) + } + + pub fn load_recovery_queue( + &self, + account_id: &str, + farm_id: FarmId, + ) -> Result<RecoveryQueueProjection, AppSqliteError> { + let mut statement = self + .connection + .prepare( + "SELECT + recovery_record_id, + order_id, + recovery_kind, + recovery_state, + summary, + note, + last_updated_at + FROM order_recovery_records + WHERE account_id = ?1 AND farm_id = ?2 + ORDER BY last_updated_at DESC, recovery_record_id DESC", + ) + .map_err(|source| AppSqliteError::Query { + operation: "prepare recovery queue query", + source, + })?; + let rows = statement + .query_map(params![account_id, farm_id.to_string()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option<String>>(5)?, + row.get::<_, String>(6)?, + )) + }) + .map_err(|source| AppSqliteError::Query { + operation: "query recovery queue", + source, + })?; + + let items = rows + .map(|row| { + let ( + recovery_record_id, + order_id, + recovery_kind, + recovery_state, + summary, + note, + last_updated_at, + ) = row.map_err(|source| AppSqliteError::Query { + operation: "read recovery queue row", + source, + })?; + + Ok(OrderRecoveryProjection { + recovery_record_id: parse_typed_id( + "order_recovery_records.recovery_record_id", + recovery_record_id, + )?, + order_id: parse_typed_id("order_recovery_records.order_id", order_id)?, + kind: parse_recovery_kind(recovery_kind)?, + state: parse_recovery_state(recovery_state)?, + summary, + note, + last_updated_at, + }) + }) + .collect::<Result<Vec<_>, AppSqliteError>>()?; + + Ok(RecoveryQueueProjection { items }) + } + + pub fn load_recovery_record( + &self, + account_id: &str, + order_id: OrderId, + kind: RecoveryKind, + ) -> Result<Option<OrderRecoveryProjection>, AppSqliteError> { + let row = self + .connection + .query_row( + "SELECT + recovery_record_id, + order_id, + recovery_kind, + recovery_state, + summary, + note, + last_updated_at + FROM order_recovery_records + WHERE account_id = ?1 AND order_id = ?2 AND recovery_kind = ?3 + LIMIT 1", + params![account_id, order_id.to_string(), kind.storage_key()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option<String>>(5)?, + row.get::<_, String>(6)?, + )) + }, + ) + .optional() + .map_err(|source| AppSqliteError::Query { + operation: "load recovery record", + source, + })?; + + row.map_or_else( + || Ok(None), + |( + recovery_record_id, + order_id, + recovery_kind, + recovery_state, + summary, + note, + last_updated_at, + )| { + Ok(Some(OrderRecoveryProjection { + recovery_record_id: parse_typed_id( + "order_recovery_records.recovery_record_id", + recovery_record_id, + )?, + order_id: parse_typed_id("order_recovery_records.order_id", order_id)?, + kind: parse_recovery_kind(recovery_kind)?, + state: parse_recovery_state(recovery_state)?, + summary, + note, + last_updated_at, + })) + }, + ) + } + + pub fn save_recovery_record( + &self, + account_id: &str, + farm_id: FarmId, + record: &OrderRecoveryProjection, + ) -> Result<(), AppSqliteError> { + self.connection + .execute( + "INSERT INTO order_recovery_records ( + recovery_record_id, + account_id, + farm_id, + order_id, + recovery_kind, + recovery_state, + summary, + note, + last_updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(account_id, order_id, recovery_kind) DO UPDATE SET + recovery_record_id = excluded.recovery_record_id, + farm_id = excluded.farm_id, + recovery_state = excluded.recovery_state, + summary = excluded.summary, + note = excluded.note, + last_updated_at = excluded.last_updated_at", + params![ + record.recovery_record_id.to_string(), + account_id, + farm_id.to_string(), + record.order_id.to_string(), + record.kind.storage_key(), + record.state.storage_key(), + record.summary, + record.note, + record.last_updated_at, + ], + ) + .map_err(|source| AppSqliteError::Query { + operation: "save recovery record", + source, + })?; + + Ok(()) + } +} + +fn parse_reminder_kind(value: String) -> Result<ReminderKind, AppSqliteError> { + match value.as_str() { + "fulfillment_window" => Ok(ReminderKind::FulfillmentWindow), + "order_action" => Ok(ReminderKind::OrderAction), + "missed_pickup_recovery" => Ok(ReminderKind::MissedPickupRecovery), + "refund_recovery" => Ok(ReminderKind::RefundRecovery), + "sync_impact" => Ok(ReminderKind::SyncImpact), + _ => Err(AppSqliteError::DecodeEnum { + field: "reminder_schedules.reminder_kind", + value, + }), + } +} + +fn parse_reminder_surface(value: String) -> Result<ReminderSurface, AppSqliteError> { + match value.as_str() { + "today" => Ok(ReminderSurface::Today), + "orders" => Ok(ReminderSurface::Orders), + "pack_day" => Ok(ReminderSurface::PackDay), + _ => Err(AppSqliteError::DecodeEnum { + field: "reminder_schedules.reminder_surface", + value, + }), + } +} + +fn parse_reminder_urgency(value: String) -> Result<ReminderUrgency, AppSqliteError> { + match value.as_str() { + "upcoming" => Ok(ReminderUrgency::Upcoming), + "due_soon" => Ok(ReminderUrgency::DueSoon), + "overdue" => Ok(ReminderUrgency::Overdue), + "blocking" => Ok(ReminderUrgency::Blocking), + _ => Err(AppSqliteError::DecodeEnum { + field: "reminder_schedules.reminder_urgency", + value, + }), + } +} + +fn parse_reminder_delivery_state(value: String) -> Result<ReminderDeliveryState, AppSqliteError> { + match value.as_str() { + "scheduled" => Ok(ReminderDeliveryState::Scheduled), + "presented" => Ok(ReminderDeliveryState::Presented), + "acknowledged" => Ok(ReminderDeliveryState::Acknowledged), + "resolved" => Ok(ReminderDeliveryState::Resolved), + _ => Err(AppSqliteError::DecodeEnum { + field: "reminder delivery_state", + value, + }), + } +} + +fn parse_recovery_kind(value: String) -> Result<RecoveryKind, AppSqliteError> { + match value.as_str() { + "missed_pickup" => Ok(RecoveryKind::MissedPickup), + "refund_follow_up" => Ok(RecoveryKind::RefundFollowUp), + _ => Err(AppSqliteError::DecodeEnum { + field: "order_recovery_records.recovery_kind", + value, + }), + } +} + +fn parse_recovery_state(value: String) -> Result<RecoveryState, AppSqliteError> { + match value.as_str() { + "open" => Ok(RecoveryState::Open), + "in_review" => Ok(RecoveryState::InReview), + "resolved" => Ok(RecoveryState::Resolved), + _ => Err(AppSqliteError::DecodeEnum { + field: "order_recovery_records.recovery_state", + value, + }), + } +} + +fn parse_typed_id<T>(field: &'static str, value: String) -> Result<T, AppSqliteError> +where + T: FromStr<Err = uuid::Error>, +{ + T::from_str(&value).map_err(|_| AppSqliteError::DecodeId { field, value }) +} + +fn parse_optional_typed_id<T>( + field: &'static str, + value: Option<String>, +) -> Result<Option<T>, AppSqliteError> +where + T: FromStr<Err = uuid::Error>, +{ + value + .map(|value| parse_typed_id(field, value)) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::AppRemindersRepository; + use crate::{AppSqliteStore, DatabaseTarget}; + use radroots_app_models::{ + FarmId, OrderId, OrderRecoveryProjection, RecoveryKind, RecoveryRecordId, RecoveryState, + ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderId, + ReminderKind, ReminderLogEntryProjection, ReminderSurface, ReminderUrgency, + }; + + #[test] + fn reminder_schedule_round_trips_and_is_account_scoped() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let repository = AppRemindersRepository::new(store.connection()); + let farm_id = FarmId::new(); + let other_farm_id = FarmId::new(); + let order_id = OrderId::new(); + let reminder = ReminderDeadlineProjection { + reminder_id: ReminderId::new(), + farm_id, + order_id: Some(order_id), + fulfillment_window_id: None, + kind: ReminderKind::OrderAction, + surface: ReminderSurface::Orders, + urgency: ReminderUrgency::DueSoon, + title: "Pack CSA order".to_owned(), + detail: "Order R-1001 still needs packing.".to_owned(), + deadline_at: "2026-04-25T14:00:00Z".to_owned(), + action_label: Some("Review order".to_owned()), + delivery_state: ReminderDeliveryState::Scheduled, + }; + + repository + .replace_reminder_schedule( + "acct_farmer", + farm_id, + &ReminderFeedProjection { + items: vec![reminder.clone()], + }, + ) + .expect("schedule should save"); + repository + .replace_reminder_schedule("acct_other", other_farm_id, &ReminderFeedProjection::default()) + .expect("other schedule should save"); + + let loaded = repository + .load_reminder_schedule("acct_farmer", farm_id) + .expect("schedule should load"); + let other = repository + .load_reminder_schedule("acct_other", other_farm_id) + .expect("other schedule should load"); + + assert_eq!(loaded.items, vec![reminder]); + assert!(other.is_empty()); + } + + #[test] + fn reminder_log_records_and_loads_recent_entries() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let repository = AppRemindersRepository::new(store.connection()); + let farm_id = FarmId::new(); + let first_reminder_id = ReminderId::new(); + let second_reminder_id = ReminderId::new(); + + repository + .record_reminder_log_entry( + "acct_farmer", + farm_id, + &ReminderLogEntryProjection { + reminder_id: first_reminder_id, + kind: ReminderKind::FulfillmentWindow, + title: "Window closes today".to_owned(), + recorded_at: "2026-04-25T12:00:00Z".to_owned(), + delivery_state: ReminderDeliveryState::Presented, + detail: None, + }, + ) + .expect("first log entry should save"); + repository + .record_reminder_log_entry( + "acct_farmer", + farm_id, + &ReminderLogEntryProjection { + reminder_id: second_reminder_id, + kind: ReminderKind::RefundRecovery, + title: "Refund follow-up pending".to_owned(), + recorded_at: "2026-04-25T13:00:00Z".to_owned(), + delivery_state: ReminderDeliveryState::Acknowledged, + detail: Some("Customer requested a callback.".to_owned()), + }, + ) + .expect("second log entry should save"); + + let loaded = repository + .load_reminder_log("acct_farmer", farm_id, 1) + .expect("log should load"); + + assert_eq!(loaded.entries.len(), 1); + assert_eq!(loaded.entries[0].reminder_id, second_reminder_id); + assert_eq!( + loaded.entries[0].delivery_state, + ReminderDeliveryState::Acknowledged + ); + } + + #[test] + fn recovery_records_round_trip_and_upsert_by_account_order_and_kind() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let repository = AppRemindersRepository::new(store.connection()); + let farm_id = FarmId::new(); + let order_id = OrderId::new(); + + let first = OrderRecoveryProjection { + recovery_record_id: RecoveryRecordId::new(), + order_id, + kind: RecoveryKind::MissedPickup, + state: RecoveryState::Open, + summary: "Customer missed pickup".to_owned(), + note: Some("Hold until Friday".to_owned()), + last_updated_at: "2026-04-25T17:00:00Z".to_owned(), + }; + let updated = OrderRecoveryProjection { + recovery_record_id: RecoveryRecordId::new(), + order_id, + kind: RecoveryKind::MissedPickup, + state: RecoveryState::InReview, + summary: "Pickup follow-up underway".to_owned(), + note: Some("Customer will confirm by tonight".to_owned()), + last_updated_at: "2026-04-25T18:00:00Z".to_owned(), + }; + + repository + .save_recovery_record("acct_farmer", farm_id, &first) + .expect("first recovery should save"); + repository + .save_recovery_record("acct_farmer", farm_id, &updated) + .expect("updated recovery should save"); + + let loaded = repository + .load_recovery_queue("acct_farmer", farm_id) + .expect("recovery queue should load"); + let one = repository + .load_recovery_record("acct_farmer", order_id, RecoveryKind::MissedPickup) + .expect("recovery record should load") + .expect("recovery record should exist"); + + assert_eq!(loaded.items.len(), 1); + assert_eq!(loaded.items[0], updated); + assert_eq!(one, updated); + } +}