init
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user