191 lines
6.6 KiB
Rust
191 lines
6.6 KiB
Rust
//! 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<String>,
|
|
url: Option<String>,
|
|
current_price: Option<Decimal>,
|
|
target_price: Option<Decimal>,
|
|
currency: Option<String>,
|
|
in_stock: Option<bool>,
|
|
notified_at: Option<time::OffsetDateTime>,
|
|
email: String,
|
|
display_name: Option<String>,
|
|
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<NotifyRow> = 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<Decimal> = 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<Decimal>,
|
|
) -> 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!("<p><a href=\"{l}\" style=\"color:#7c6cf0\">Approach the shrine ↗</a></p>")
|
|
})
|
|
.unwrap_or_default();
|
|
let html = format!(
|
|
"<div style=\"font-family:Georgia,serif;color:#1a1726\">\
|
|
<p><em>{greeting}your vigil is rewarded.</em></p>\
|
|
<p style=\"font-size:1.1em\"><strong>{name}</strong> now asks \
|
|
<strong>{now}</strong>{against}{stock}.</p>\
|
|
<p>The moment is upon you. Consume, and ascend.</p>\
|
|
{link_html}\
|
|
<p style=\"color:#8a849c\">— consume·rs</p>\
|
|
</div>"
|
|
);
|
|
|
|
state
|
|
.mailer
|
|
.send(&row.email, &subject, &text, &html)
|
|
.await?;
|
|
tracing::info!(to = %row.email, item = %name, "sent price-drop notification");
|
|
Ok(())
|
|
}
|