v0.1.0:25–40 — tier model, edit forms, force-delete, license counts, migration 0009 (and hotfix); KEYSAT_INTEGRATION.md merged with downstream-LLM revisions
This commit is contained in:
@@ -100,6 +100,55 @@ pub async fn set_product_active(pool: &SqlitePool, id: &str, active: bool) -> Ap
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Patch mutable fields on a product. `slug` and `id` are intentionally
|
||||
/// not editable — slug is part of the public buy URL, and changing it
|
||||
/// would break links operators have shared. Each Option is "Some →
|
||||
/// update, None → leave alone."
|
||||
pub async fn update_product(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
price_sats: Option<i64>,
|
||||
) -> AppResult<Product> {
|
||||
let mut sets: Vec<&str> = Vec::new();
|
||||
if name.is_some() {
|
||||
sets.push("name = ?");
|
||||
}
|
||||
if description.is_some() {
|
||||
sets.push("description = ?");
|
||||
}
|
||||
if price_sats.is_some() {
|
||||
sets.push("price_sats = ?");
|
||||
}
|
||||
if sets.is_empty() {
|
||||
return get_product_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product {id}")));
|
||||
}
|
||||
sets.push("updated_at = ?");
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let sql = format!("UPDATE products SET {} WHERE id = ?", sets.join(", "));
|
||||
let mut q = sqlx::query(&sql);
|
||||
if let Some(v) = name {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = description {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = price_sats {
|
||||
q = q.bind(v);
|
||||
}
|
||||
q = q.bind(&now).bind(id);
|
||||
let rows = q.execute(pool).await?.rows_affected();
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound(format!("product {id}")));
|
||||
}
|
||||
get_product_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product {id}")))
|
||||
}
|
||||
|
||||
fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
||||
let metadata_json: String = row.try_get("metadata_json")?;
|
||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
|
||||
@@ -119,6 +168,7 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
||||
|
||||
// ---------- Invoices ----------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_invoice(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
@@ -128,12 +178,14 @@ pub async fn create_invoice(
|
||||
checkout_url: &str,
|
||||
buyer_email: Option<&str>,
|
||||
buyer_note: Option<&str>,
|
||||
policy_id: Option<&str>,
|
||||
) -> AppResult<Invoice> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO invoices
|
||||
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note, amount_sats, checkout_url, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)",
|
||||
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, policy_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(btcpay_invoice_id)
|
||||
@@ -142,6 +194,7 @@ pub async fn create_invoice(
|
||||
.bind(buyer_note)
|
||||
.bind(amount_sats)
|
||||
.bind(checkout_url)
|
||||
.bind(policy_id)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
@@ -162,6 +215,7 @@ pub async fn create_free_invoice(
|
||||
product_id: &str,
|
||||
buyer_email: Option<&str>,
|
||||
buyer_note: Option<&str>,
|
||||
policy_id: Option<&str>,
|
||||
) -> AppResult<Invoice> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
@@ -169,14 +223,15 @@ pub async fn create_free_invoice(
|
||||
sqlx::query(
|
||||
"INSERT INTO invoices
|
||||
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'settled', ?, ?, 0, '', ?, ?)",
|
||||
amount_sats, checkout_url, policy_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'settled', ?, ?, 0, '', ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&synthetic_btcpay_id)
|
||||
.bind(product_id)
|
||||
.bind(buyer_email)
|
||||
.bind(buyer_note)
|
||||
.bind(policy_id)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
@@ -189,7 +244,7 @@ pub async fn create_free_invoice(
|
||||
pub async fn get_invoice_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Invoice>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, created_at, updated_at
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id
|
||||
FROM invoices WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -204,7 +259,7 @@ pub async fn get_invoice_by_btcpay_id(
|
||||
) -> AppResult<Option<Invoice>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, created_at, updated_at
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id
|
||||
FROM invoices WHERE btcpay_invoice_id = ?",
|
||||
)
|
||||
.bind(btcpay_invoice_id)
|
||||
@@ -240,7 +295,7 @@ pub async fn list_pending_invoices(
|
||||
let cutoff = (Utc::now() - chrono::Duration::hours(max_age_hours)).to_rfc3339();
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, created_at, updated_at
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id
|
||||
FROM invoices
|
||||
WHERE status = 'pending' AND created_at >= ?
|
||||
ORDER BY created_at ASC",
|
||||
@@ -263,6 +318,7 @@ fn row_to_invoice(row: sqlx::sqlite::SqliteRow) -> Invoice {
|
||||
checkout_url: row.get("checkout_url"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
policy_id: row.try_get("policy_id").ok().flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,7 +609,8 @@ pub async fn log_validation(
|
||||
const POLICY_COLS: &str = "id, product_id, name, slug, duration_seconds, grace_seconds,
|
||||
tip_recipient, tip_pct_bps, tip_label,
|
||||
max_machines, is_trial, price_sats_override,
|
||||
entitlements_json, metadata_json, active, created_at, updated_at";
|
||||
entitlements_json, metadata_json, active, public,
|
||||
created_at, updated_at";
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_policy(
|
||||
@@ -577,12 +634,13 @@ pub async fn create_policy(
|
||||
let entitlements_json = serde_json::to_string(entitlements).unwrap_or_else(|_| "[]".into());
|
||||
let metadata_json = serde_json::to_string(metadata).unwrap_or_else(|_| "{}".into());
|
||||
let tip_pct = tip_pct_bps.clamp(0, 10_000);
|
||||
// public defaults to 1 here; admin can flip via PATCH /v1/admin/policies/:id/public.
|
||||
sqlx::query(
|
||||
"INSERT INTO policies
|
||||
(id, product_id, name, slug, duration_seconds, grace_seconds, max_machines,
|
||||
is_trial, price_sats_override, entitlements_json, metadata_json, active,
|
||||
is_trial, price_sats_override, entitlements_json, metadata_json, active, public,
|
||||
tip_recipient, tip_pct_bps, tip_label, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)",
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(product_id)
|
||||
@@ -650,6 +708,127 @@ pub async fn list_policies_by_product(
|
||||
Ok(rows.into_iter().map(row_to_policy).collect())
|
||||
}
|
||||
|
||||
/// Public-buyer view: only active+public policies. Sorted by ascending
|
||||
/// effective price so the cheapest tier renders leftmost. The buy page
|
||||
/// is the only caller; admin should use `list_policies_by_product`.
|
||||
pub async fn list_public_policies_by_product(
|
||||
pool: &SqlitePool,
|
||||
product_id: &str,
|
||||
) -> AppResult<Vec<Policy>> {
|
||||
let sql = format!(
|
||||
"SELECT {POLICY_COLS} FROM policies
|
||||
WHERE product_id = ? AND active = 1 AND public = 1
|
||||
ORDER BY COALESCE(price_sats_override, 0) ASC, name ASC"
|
||||
);
|
||||
let rows = sqlx::query(&sql).bind(product_id).fetch_all(pool).await?;
|
||||
Ok(rows.into_iter().map(row_to_policy).collect())
|
||||
}
|
||||
|
||||
/// Patch mutable fields on a policy. Slug, product_id, and id are
|
||||
/// intentionally not editable — they're identifiers that operators may
|
||||
/// have hard-coded into integration docs or buy URLs. Tip-related fields
|
||||
/// have their own admin endpoint (`set_policy_tip_config`) since they
|
||||
/// have their own validation rules (basis points, paired recipient/pct).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update_policy(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
duration_seconds: Option<i64>,
|
||||
grace_seconds: Option<i64>,
|
||||
max_machines: Option<i64>,
|
||||
is_trial: Option<bool>,
|
||||
price_sats_override: Option<Option<i64>>,
|
||||
entitlements: Option<&[String]>,
|
||||
metadata: Option<&serde_json::Value>,
|
||||
) -> AppResult<Policy> {
|
||||
let mut sets: Vec<&str> = Vec::new();
|
||||
if name.is_some() {
|
||||
sets.push("name = ?");
|
||||
}
|
||||
if duration_seconds.is_some() {
|
||||
sets.push("duration_seconds = ?");
|
||||
}
|
||||
if grace_seconds.is_some() {
|
||||
sets.push("grace_seconds = ?");
|
||||
}
|
||||
if max_machines.is_some() {
|
||||
sets.push("max_machines = ?");
|
||||
}
|
||||
if is_trial.is_some() {
|
||||
sets.push("is_trial = ?");
|
||||
}
|
||||
if price_sats_override.is_some() {
|
||||
sets.push("price_sats_override = ?");
|
||||
}
|
||||
if entitlements.is_some() {
|
||||
sets.push("entitlements_json = ?");
|
||||
}
|
||||
if metadata.is_some() {
|
||||
sets.push("metadata_json = ?");
|
||||
}
|
||||
if sets.is_empty() {
|
||||
return get_policy_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("policy {id}")));
|
||||
}
|
||||
sets.push("updated_at = ?");
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let sql = format!("UPDATE policies SET {} WHERE id = ?", sets.join(", "));
|
||||
let mut q = sqlx::query(&sql);
|
||||
if let Some(v) = name {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = duration_seconds {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = grace_seconds {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = max_machines {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = is_trial {
|
||||
q = q.bind(v as i64);
|
||||
}
|
||||
if let Some(opt_p) = price_sats_override {
|
||||
q = q.bind(opt_p);
|
||||
}
|
||||
let ent_json;
|
||||
if let Some(ents) = entitlements {
|
||||
ent_json = serde_json::to_string(ents).unwrap_or_else(|_| "[]".into());
|
||||
q = q.bind(&ent_json);
|
||||
}
|
||||
let meta_json;
|
||||
if let Some(m) = metadata {
|
||||
meta_json = serde_json::to_string(m).unwrap_or_else(|_| "{}".into());
|
||||
q = q.bind(&meta_json);
|
||||
}
|
||||
q = q.bind(&now).bind(id);
|
||||
let rows = q.execute(pool).await?.rows_affected();
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound(format!("policy {id}")));
|
||||
}
|
||||
get_policy_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("policy {id}")))
|
||||
}
|
||||
|
||||
pub async fn set_policy_public(pool: &SqlitePool, id: &str, public: bool) -> AppResult<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let rows = sqlx::query("UPDATE policies SET public = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(public as i64)
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound(format!("policy {id}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_policy_active(pool: &SqlitePool, id: &str, active: bool) -> AppResult<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let rows = sqlx::query("UPDATE policies SET active = ?, updated_at = ? WHERE id = ?")
|
||||
@@ -673,6 +852,7 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
|
||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
|
||||
let active_int: i64 = row.get("active");
|
||||
let is_trial_int: i64 = row.get("is_trial");
|
||||
let public_int: i64 = row.try_get("public").unwrap_or(1);
|
||||
Policy {
|
||||
id: row.get("id"),
|
||||
product_id: row.get("product_id"),
|
||||
@@ -686,6 +866,7 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
|
||||
entitlements,
|
||||
metadata,
|
||||
active: active_int != 0,
|
||||
public: public_int != 0,
|
||||
tip_recipient: row.get("tip_recipient"),
|
||||
tip_pct_bps: row.get("tip_pct_bps"),
|
||||
tip_label: row.get("tip_label"),
|
||||
@@ -1348,9 +1529,12 @@ pub async fn create_discount_code(
|
||||
referrer_label: Option<&str>,
|
||||
description: &str,
|
||||
) -> AppResult<DiscountCode> {
|
||||
if !matches!(kind, "percent" | "fixed_sats" | "free_license") {
|
||||
if !matches!(
|
||||
kind,
|
||||
"percent" | "fixed_sats" | "set_price" | "free_license"
|
||||
) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"discount kind must be 'percent', 'fixed_sats', or 'free_license', got '{kind}'"
|
||||
"discount kind must be 'percent', 'fixed_sats', 'set_price', or 'free_license', got '{kind}'"
|
||||
)));
|
||||
}
|
||||
if amount < 0 {
|
||||
@@ -1366,6 +1550,11 @@ pub async fn create_discount_code(
|
||||
"fixed_sats amount must be > 0".into(),
|
||||
));
|
||||
}
|
||||
if kind == "set_price" && amount <= 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"set_price amount (the buyer's flat-price target, in sats) must be > 0".into(),
|
||||
));
|
||||
}
|
||||
// free_license codes ignore `amount`; we force it to 0 on insert below.
|
||||
if let Some(m) = max_uses {
|
||||
if m <= 0 {
|
||||
@@ -1489,6 +1678,117 @@ pub async fn set_discount_code_active(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Patch mutable fields on a discount code. Mutable fields are the ones
|
||||
/// that don't change behavior in confusing ways for codes already in
|
||||
/// circulation: `amount`, `max_uses`, `expires_at`, `description`,
|
||||
/// `referrer_label`. The code string itself, kind, and product/policy
|
||||
/// scope are intentionally NOT editable — changing those would silently
|
||||
/// invalidate links that are already out in the wild. Operators should
|
||||
/// disable + create a new code instead. Each `Option<T>` parameter is
|
||||
/// `Some(value_or_clear)` to update, `None` to leave alone; for fields
|
||||
/// that can be NULL'd, callers pass `Some(None)` to clear.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update_discount_code(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
amount: Option<i64>,
|
||||
max_uses: Option<Option<i64>>,
|
||||
expires_at: Option<Option<&str>>,
|
||||
description: Option<&str>,
|
||||
referrer_label: Option<Option<&str>>,
|
||||
) -> AppResult<DiscountCode> {
|
||||
// Re-fetch to validate amount against the existing kind.
|
||||
let existing = get_discount_code_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))?;
|
||||
if let Some(a) = amount {
|
||||
if a < 0 {
|
||||
return Err(AppError::BadRequest("amount must be >= 0".into()));
|
||||
}
|
||||
if existing.kind == "percent" && a > 10_000 {
|
||||
return Err(AppError::BadRequest(
|
||||
"percent amount must be in basis points (0..=10000); 10000 = 100%".into(),
|
||||
));
|
||||
}
|
||||
if existing.kind == "fixed_sats" && a == 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"fixed_sats amount must be > 0".into(),
|
||||
));
|
||||
}
|
||||
if existing.kind == "set_price" && a <= 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"set_price amount (the buyer's flat-price target, in sats) must be > 0".into(),
|
||||
));
|
||||
}
|
||||
if existing.kind == "free_license" && a != 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"free_license codes have no amount; pass 0 or leave unchanged".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(Some(m)) = max_uses {
|
||||
if m <= 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"max_uses must be > 0 (or pass null to clear it for unlimited)".into(),
|
||||
));
|
||||
}
|
||||
if m < existing.used_count {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"max_uses ({m}) cannot be lower than the current used_count ({})",
|
||||
existing.used_count
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut sets: Vec<&str> = Vec::new();
|
||||
if amount.is_some() {
|
||||
sets.push("amount = ?");
|
||||
}
|
||||
if max_uses.is_some() {
|
||||
sets.push("max_uses = ?");
|
||||
}
|
||||
if expires_at.is_some() {
|
||||
sets.push("expires_at = ?");
|
||||
}
|
||||
if description.is_some() {
|
||||
sets.push("description = ?");
|
||||
}
|
||||
if referrer_label.is_some() {
|
||||
sets.push("referrer_label = ?");
|
||||
}
|
||||
if sets.is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
sets.push("updated_at = ?");
|
||||
let sql = format!(
|
||||
"UPDATE discount_codes SET {} WHERE id = ?",
|
||||
sets.join(", ")
|
||||
);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let mut q = sqlx::query(&sql);
|
||||
if let Some(a) = amount {
|
||||
q = q.bind(a);
|
||||
}
|
||||
if let Some(opt_m) = max_uses {
|
||||
q = q.bind(opt_m);
|
||||
}
|
||||
if let Some(opt_e) = expires_at {
|
||||
q = q.bind(opt_e);
|
||||
}
|
||||
if let Some(d) = description {
|
||||
q = q.bind(d);
|
||||
}
|
||||
if let Some(opt_r) = referrer_label {
|
||||
q = q.bind(opt_r);
|
||||
}
|
||||
q = q.bind(&now).bind(id);
|
||||
q.execute(pool).await?;
|
||||
|
||||
get_discount_code_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))
|
||||
}
|
||||
|
||||
/// Phase 1 of redemption: atomically increment `used_count` on the
|
||||
/// discount code, gated on active/not-expired/has-uses-remaining. Returns
|
||||
/// `BadRequest` if any of those checks fails. The caller MUST follow up
|
||||
@@ -1697,6 +1997,85 @@ pub async fn settings_get(pool: &SqlitePool, key: &str) -> AppResult<Option<Stri
|
||||
Ok(row.and_then(|r| r.get::<Option<String>, _>("value")))
|
||||
}
|
||||
|
||||
// ---------- Web UI sessions ----------
|
||||
|
||||
/// Create a new session row. Token is the random URL-safe base64 string
|
||||
/// (callers generate it with `crate::api::auth::new_session_token`).
|
||||
pub async fn create_session(
|
||||
pool: &SqlitePool,
|
||||
token: &str,
|
||||
created_at: &str,
|
||||
expires_at: &str,
|
||||
ip: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO sessions (token, created_at, expires_at, last_seen_at, ip, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(token)
|
||||
.bind(created_at)
|
||||
.bind(expires_at)
|
||||
.bind(created_at) // last_seen_at = created_at on insert
|
||||
.bind(ip)
|
||||
.bind(user_agent)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if the session exists and hasn't expired. Side-effect:
|
||||
/// bumps `last_seen_at` so an active session stays alive (sliding window).
|
||||
pub async fn is_session_valid(pool: &SqlitePool, token: &str) -> AppResult<bool> {
|
||||
let row = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT token, expires_at FROM sessions WHERE token = ?",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let Some((_, expires_at)) = row else { return Ok(false) };
|
||||
let exp = match chrono::DateTime::parse_from_rfc3339(&expires_at) {
|
||||
Ok(t) => t.with_timezone(&Utc),
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
if exp < Utc::now() {
|
||||
return Ok(false);
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let _ = sqlx::query("UPDATE sessions SET last_seen_at = ? WHERE token = ?")
|
||||
.bind(&now)
|
||||
.bind(token)
|
||||
.execute(pool)
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Hard-delete a single session row. Idempotent.
|
||||
pub async fn delete_session(pool: &SqlitePool, token: &str) -> AppResult<()> {
|
||||
sqlx::query("DELETE FROM sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wipe every session row — used on password rotation.
|
||||
pub async fn delete_all_sessions(pool: &SqlitePool) -> AppResult<()> {
|
||||
sqlx::query("DELETE FROM sessions").execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Background cleanup: drop sessions whose `expires_at` is in the past.
|
||||
/// Returns the number of rows removed (for logging).
|
||||
pub async fn reap_expired_sessions(pool: &SqlitePool) -> AppResult<u64> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let res = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
/// Upsert a key into the runtime settings table. Pass `None` to clear it.
|
||||
pub async fn settings_set(pool: &SqlitePool, key: &str, value: Option<&str>) -> AppResult<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
Reference in New Issue
Block a user