//! Phase 4: price-drop notifications. //! //! After a successful refetch we email the owner about a price drop. Two modes: //! - **Target set:** notify when the price falls to/under the target. The //! `items.notified_at` column is the de-dupe latch: //! - `NULL` = armed; a drop to/under target fires one email and stamps //! `now()`. //! - non-NULL = already announced; stays quiet until the price rises back //! above target, which clears the latch (re-arms) for the next drop. //! - **No target:** notify on *any* drop — whenever this fetch's price is //! lower than the immediately preceding observation in `price_history`. //! No latch needed: a stable price has no preceding-higher observation. use rust_decimal::Decimal; use uuid::Uuid; use crate::state::AppState; /// Row gathered for one item's notification decision. #[derive(sqlx::FromRow)] struct NotifyRow { title: String, title_fetched: Option, url: Option, current_price: Option, target_price: Option, currency: Option, in_stock: Option, notified_at: Option, email: String, display_name: Option, notify_email: bool, } /// Inspect one item after refetch and email its owner if the price just reached /// the target. Best-effort: never returns an error to the caller (a failed /// send must not fail the refetch); failures are logged. pub async fn maybe_notify_drop(state: &AppState, item_id: Uuid) { if let Err(e) = run(state, item_id).await { tracing::warn!(item = %item_id, error = %e, "price-drop notification failed"); } } async fn run(state: &AppState, item_id: Uuid) -> anyhow::Result<()> { let row: Option = sqlx::query_as( "SELECT i.title, i.title_fetched, i.url, i.current_price, i.target_price, i.currency, i.in_stock, i.notified_at, u.email, u.display_name, s.notify_email FROM items i JOIN lists l ON l.id = i.list_id JOIN users u ON u.id = l.user_id JOIN user_settings s ON s.user_id = u.id WHERE i.id = $1", ) .bind(item_id) .fetch_optional(&state.pool) .await?; let Some(row) = row else { return Ok(()) }; // Need a current price to judge anything. let Some(price) = row.current_price else { return Ok(()); }; match row.target_price { // Target set → notify at/under target, latched against repeats. Some(target) => target_drop(state, item_id, &row, price, target).await, // No target → notify on any drop versus the previous observation. None => any_drop(state, item_id, &row, price).await, } } /// Target mode: email once when the price reaches the target; re-arm above it. async fn target_drop( state: &AppState, item_id: Uuid, row: &NotifyRow, price: Decimal, target: Decimal, ) -> anyhow::Result<()> { let on_sale = price <= target; match (on_sale, row.notified_at.is_some()) { // Reached target and not yet announced → email + latch. (true, false) => { if row.notify_email { send(state, row, price, Some(target)).await?; } // Latch even if the user has email off, so flipping it on later // doesn't replay an old drop. Re-arms when price climbs back up. sqlx::query("UPDATE items SET notified_at = now() WHERE id = $1") .bind(item_id) .execute(&state.pool) .await?; } // Price rose back above target → clear the latch (re-arm). (false, true) => { sqlx::query("UPDATE items SET notified_at = NULL WHERE id = $1") .bind(item_id) .execute(&state.pool) .await?; } _ => {} } Ok(()) } /// No-target mode: email whenever this fetch is cheaper than the one before it. /// The newest `price_history` row is this fetch (inserted just before notify), /// so the previous observation is the second-newest. async fn any_drop( state: &AppState, item_id: Uuid, row: &NotifyRow, price: Decimal, ) -> anyhow::Result<()> { let prev: Option = sqlx::query_scalar( "SELECT price FROM price_history WHERE item_id = $1 ORDER BY fetched_at DESC OFFSET 1 LIMIT 1", ) .bind(item_id) .fetch_optional(&state.pool) .await?; // A drop is a strictly lower price than the previous observation. if matches!(prev, Some(p) if price < p) && row.notify_email { send(state, row, price, None).await?; } Ok(()) } async fn send( state: &AppState, row: &NotifyRow, price: Decimal, target: Option, ) -> anyhow::Result<()> { let name = row.title_fetched.as_deref().unwrap_or(&row.title); let cur = row.currency.as_deref().unwrap_or("EUR"); let now = format!("{cur} {price:.2}"); // With a target, frame the drop against it; otherwise just announce the fall. let against = match target { Some(t) => format!(", at or beneath your target of {cur} {t:.2}"), None => String::new(), }; let stock = match row.in_stock { Some(false) => " (sold out for now — but the sign is given)", _ => "", }; let greeting = match row.display_name.as_deref() { Some(n) if !n.is_empty() => format!("{n}, "), _ => String::new(), }; let link = row.url.as_deref(); let subject = format!("✦ The price has fallen — {name}"); let mut text = format!( "{greeting}your vigil is rewarded.\n\n\ {name} now asks {now}{against}{stock}.\n\n\ The moment is upon you. Consume, and ascend.\n" ); if let Some(l) = link { text.push_str(&format!("\nApproach the shrine: {l}\n")); } text.push_str("\n— consume·rs\n"); let link_html = link .map(|l| { format!("

Approach the shrine ↗

") }) .unwrap_or_default(); let html = format!( "
\

{greeting}your vigil is rewarded.

\

{name} now asks \ {now}{against}{stock}.

\

The moment is upon you. Consume, and ascend.

\ {link_html}\

— consume·rs

\
" ); state .mailer .send(&row.email, &subject, &text, &html) .await?; tracing::info!(to = %row.email, item = %name, "sent price-drop notification"); Ok(()) }