This commit is contained in:
2026-06-17 10:59:45 +02:00
parent 408e48c568
commit a2ccec4bb1
35 changed files with 2514 additions and 257 deletions
+143
View File
@@ -0,0 +1,143 @@
//! Phase 4: price-drop notifications.
//!
//! After a successful refetch we check whether an item's watched price has
//! fallen to or below the owner's target. If so — and we haven't already told
//! them about this drop — we email them in the house gospel voice. 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.
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 both a watched price and a target to judge a drop.
let (Some(price), Some(target)) = (row.current_price, row.target_price) else {
return Ok(());
};
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, 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(())
}
async fn send(
state: &AppState,
row: &NotifyRow,
price: Decimal,
target: 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}");
let goal = format!("{cur} {target:.2}");
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}, at or beneath your target of {goal}{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>, at or beneath your target of {goal}{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(())
}