updated wording
This commit is contained in:
+62
-15
@@ -1,12 +1,15 @@
|
||||
//! 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.
|
||||
//! 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;
|
||||
@@ -55,17 +58,33 @@ async fn run(state: &AppState, item_id: Uuid) -> anyhow::Result<()> {
|
||||
|
||||
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 {
|
||||
// Need a current price to judge anything.
|
||||
let Some(price) = row.current_price else {
|
||||
return Ok(());
|
||||
};
|
||||
let on_sale = price <= target;
|
||||
|
||||
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, target).await?;
|
||||
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.
|
||||
@@ -86,16 +105,44 @@ async fn run(state: &AppState, item_id: Uuid) -> anyhow::Result<()> {
|
||||
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: 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}");
|
||||
let goal = format!("{cur} {target:.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)",
|
||||
_ => "",
|
||||
@@ -110,7 +157,7 @@ async fn send(
|
||||
|
||||
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\
|
||||
{name} now asks {now}{against}{stock}.\n\n\
|
||||
The moment is upon you. Consume, and ascend.\n"
|
||||
);
|
||||
if let Some(l) = link {
|
||||
@@ -127,7 +174,7 @@ async fn send(
|
||||
"<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>\
|
||||
<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>\
|
||||
|
||||
Reference in New Issue
Block a user