updated wording

This commit is contained in:
2026-06-17 16:39:43 +02:00
parent 8b1b9cedc2
commit 7a90ced98e
14 changed files with 257 additions and 79 deletions
+62 -15
View File
@@ -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>\
+41 -9
View File
@@ -226,8 +226,15 @@ struct UpdateItemReq {
#[validate(length(max = 1000))]
note: Option<String>,
status: Option<String>,
target_price: Option<Decimal>,
/// Absent = leave as-is; `null` = clear the target (→ notify on any drop);
/// a value = set the target.
#[serde(default, deserialize_with = "double_option")]
target_price: Option<Option<Decimal>>,
position: Option<i32>,
/// Manual ISO 4217 currency override (e.g. "EUR"). Wins over the fetched
/// currency for items the conversion heuristic mislabels.
#[validate(length(equal = 3, message = "currency must be a 3-letter code"))]
currency: Option<String>,
}
async fn update_item(
@@ -244,15 +251,27 @@ async fn update_item(
}
}
// Ownership enforced via the join to lists.user_id.
let currency = req
.currency
.map(|c| c.trim().to_uppercase())
.filter(|c| !c.is_empty());
// target_price: None = leave; Some(v) = set (v may be NULL to clear).
let set_target = req.target_price.is_some();
let target_val = req.target_price.flatten();
// Ownership enforced via the join to lists.user_id. A currency override
// both latches (currency_override) and takes effect immediately (currency).
let item = sqlx::query_as::<_, Item>(&format!(
"UPDATE items i SET
title = COALESCE($3, i.title),
url = COALESCE($4, i.url),
note = COALESCE($5, i.note),
status = COALESCE($6::item_status, i.status),
target_price = COALESCE($7, i.target_price),
position = COALESCE($8, i.position)
title = COALESCE($3, i.title),
url = COALESCE($4, i.url),
note = COALESCE($5, i.note),
status = COALESCE($6::item_status, i.status),
target_price = CASE WHEN $7 THEN $8 ELSE i.target_price END,
position = COALESCE($9, i.position),
currency_override = COALESCE($10, i.currency_override),
currency = COALESCE($10, i.currency)
FROM lists l
WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2
RETURNING {ITEM_COLS_I}"
@@ -263,8 +282,10 @@ async fn update_item(
.bind(opt_trim(req.url))
.bind(opt_trim(req.note))
.bind(req.status)
.bind(req.target_price)
.bind(set_target)
.bind(target_val)
.bind(req.position)
.bind(currency)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -354,3 +375,14 @@ async fn item_history(
fn opt_trim(s: Option<String>) -> Option<String> {
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
/// Deserialize a present-but-nullable field into `Option<Option<T>>`, so an
/// explicit JSON `null` (`Some(None)`) is distinguishable from an absent field
/// (`None`, handled by `#[serde(default)]`). Lets a PATCH clear a value.
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Ok(Some(Option::<T>::deserialize(de)?))
}
+4 -2
View File
@@ -82,11 +82,12 @@ pub async fn refetch(state: &AppState, item_id: Uuid, url: &str) -> anyhow::Resu
async fn apply_success(pool: &PgPool, item_id: Uuid, p: &FetchedProduct) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
// A manual currency_override (set by the user) wins over the fetched value.
sqlx::query(
"UPDATE items SET
title_fetched = $2,
current_price = $3,
currency = $4,
currency = COALESCE(currency_override, $4),
image_url = COALESCE($5, image_url),
in_stock = $6,
source = $7,
@@ -107,7 +108,8 @@ async fn apply_success(pool: &PgPool, item_id: Uuid, p: &FetchedProduct) -> anyh
sqlx::query(
"INSERT INTO price_history (item_id, price, currency, in_stock)
VALUES ($1, $2, $3, $4)",
VALUES ($1, $2,
COALESCE((SELECT currency_override FROM items WHERE id = $1), $3), $4)",
)
.bind(item_id)
.bind(p.price)