diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql index 3bf6c47..5e87acf 100644 --- a/backend/migrations/0001_init.sql +++ b/backend/migrations/0001_init.sql @@ -39,3 +39,39 @@ CREATE TABLE IF NOT EXISTS relay ( push_enabled INTEGER NOT NULL DEFAULT 1, FOREIGN KEY (tenant) REFERENCES tenant(pubkey) ); + +CREATE TABLE IF NOT EXISTS invoice_nwc_payment ( + invoice_id TEXT PRIMARY KEY, + tenant_pubkey TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'paid')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey) +); + +CREATE TABLE IF NOT EXISTS invoice_manual_lightning_payment ( + invoice_id TEXT PRIMARY KEY, + tenant_pubkey TEXT NOT NULL, + bolt11 TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey) +); + +CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id + ON tenant (stripe_customer_id); + +CREATE INDEX IF NOT EXISTS idx_relay_tenant_id + ON relay (tenant, id); + +CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan + ON relay (tenant, status, plan); + +CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id + ON activity (resource_type, resource_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_invoice_nwc_payment_tenant_pubkey + ON invoice_nwc_payment (tenant_pubkey); + +CREATE INDEX IF NOT EXISTS idx_invoice_manual_lightning_payment_tenant_pubkey + ON invoice_manual_lightning_payment (tenant_pubkey); diff --git a/backend/migrations/0002_hot_path_indexes.sql b/backend/migrations/0002_hot_path_indexes.sql deleted file mode 100644 index 6872757..0000000 --- a/backend/migrations/0002_hot_path_indexes.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id -ON tenant (stripe_customer_id); - -CREATE INDEX IF NOT EXISTS idx_relay_tenant_id -ON relay (tenant, id); - -CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan -ON relay (tenant, status, plan); - -CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id -ON activity (resource_type, resource_id, created_at DESC, id DESC); diff --git a/backend/migrations/0003_invoice_nwc_payment_guard.sql b/backend/migrations/0003_invoice_nwc_payment_guard.sql deleted file mode 100644 index 8f5bf01..0000000 --- a/backend/migrations/0003_invoice_nwc_payment_guard.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS invoice_nwc_payment ( - invoice_id TEXT PRIMARY KEY, - tenant_pubkey TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'paid')), - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey) -); - -CREATE INDEX IF NOT EXISTS idx_invoice_nwc_payment_tenant_pubkey -ON invoice_nwc_payment (tenant_pubkey); \ No newline at end of file diff --git a/backend/migrations/0004_invoice_manual_lightning_payment.sql b/backend/migrations/0004_invoice_manual_lightning_payment.sql deleted file mode 100644 index b590955..0000000 --- a/backend/migrations/0004_invoice_manual_lightning_payment.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS invoice_manual_lightning_payment ( - invoice_id TEXT PRIMARY KEY, - tenant_pubkey TEXT NOT NULL, - bolt11 TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey) -); - -CREATE INDEX IF NOT EXISTS idx_invoice_manual_lightning_payment_tenant_pubkey -ON invoice_manual_lightning_payment (tenant_pubkey); diff --git a/backend/spec/api.md b/backend/spec/api.md index 4e0b233..1a6999a 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -136,7 +136,7 @@ Notes: - Serves `GET /relays/:id/activity` - Authorizes admin or relay owner -- Get activity from `query.list_activity_for_relay` +- Get activity from `query.list_activity_for_resource` - Return `data` is `{activity}` ## `async fn deactivate_relay(...) -> Response` diff --git a/backend/spec/query.md b/backend/spec/query.md index 9517501..633a690 100644 --- a/backend/spec/query.md +++ b/backend/spec/query.md @@ -39,7 +39,7 @@ Members: - Returns the tenant matching the given `stripe_customer_id` -## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result>` +## `pub fn list_activity_for_resource(&self, relay_id: &str) -> Result>` - Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id` - Ordered newest-first diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 2f677f6..4b027e2 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -454,7 +454,7 @@ impl Billing { let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; for relay in relays { - if Self::should_reactivate_after_payment(&relay) { + if relay.status == RELAY_STATUS_DELINQUENT && self.query.is_paid_plan(&relay.plan) { self.command.activate_relay(&relay).await?; } } @@ -508,7 +508,7 @@ impl Billing { let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; for relay in relays { - if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) { + if relay.status == RELAY_STATUS_ACTIVE && self.query.is_paid_plan(&relay.plan) { self.command.mark_relay_delinquent(&relay).await?; } } @@ -543,7 +543,7 @@ impl Billing { let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; for relay in relays { - if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) { + if relay.status == RELAY_STATUS_ACTIVE && self.query.is_paid_plan(&relay.plan) { self.command.mark_relay_delinquent(&relay).await?; } } @@ -962,10 +962,6 @@ impl Billing { ))), } } - - fn should_reactivate_after_payment(relay: &Relay) -> bool { - relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan) - } } fn summarize_nwc_error_for_dm(error: &str) -> Option { @@ -992,58 +988,3 @@ fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String { _ => MANUAL_LIGHTNING_PAYMENT_DM.to_string(), } } - -#[cfg(test)] -mod tests { - use super::Billing; - use crate::models::{ - RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, - }; - - fn relay_fixture(status: &str, plan: &str) -> Relay { - Relay { - id: "relay-1".to_string(), - tenant: "tenant-1".to_string(), - schema: "tenant_1".to_string(), - subdomain: "relay-1".to_string(), - plan: plan.to_string(), - stripe_subscription_item_id: None, - status: status.to_string(), - sync_error: String::new(), - info_name: String::new(), - info_icon: String::new(), - info_description: String::new(), - policy_public_join: 0, - policy_strip_signatures: 0, - groups_enabled: 1, - management_enabled: 1, - blossom_enabled: 1, - livekit_enabled: 1, - push_enabled: 1, - synced: 1, - } - } - - #[test] - fn reactivates_only_delinquent_paid_relays_after_payment() { - let delinquent_paid = relay_fixture(RELAY_STATUS_DELINQUENT, "basic"); - assert!(Billing::should_reactivate_after_payment(&delinquent_paid)); - - let manually_inactive_paid = relay_fixture(RELAY_STATUS_INACTIVE, "basic"); - assert!(!Billing::should_reactivate_after_payment( - &manually_inactive_paid - )); - - let free_delinquent = relay_fixture(RELAY_STATUS_DELINQUENT, "free"); - assert!(!Billing::should_reactivate_after_payment(&free_delinquent)); - - let active_paid = relay_fixture(RELAY_STATUS_ACTIVE, "basic"); - assert!(!Billing::should_reactivate_after_payment(&active_paid)); - - let unknown_status_paid = relay_fixture("suspended", "basic"); - assert!(!Billing::should_reactivate_after_payment( - &unknown_status_paid - )); - } - -} diff --git a/backend/src/infra.rs b/backend/src/infra.rs index 5e8e8cd..3d3effb 100644 --- a/backend/src/infra.rs +++ b/backend/src/infra.rs @@ -112,7 +112,7 @@ impl Infra { Some(Duration::from_secs(delay_secs)) } - let activities = self.query.list_activity_for_relay(relay_id).await?; + let activities = self.query.list_activity_for_resource(relay_id).await?; let consecutive_failures = activities .iter() .take_while(|activity| activity.activity_type == "fail_relay_sync") @@ -177,7 +177,11 @@ impl Infra { // otherwise check the activity history so that a re-sync after an update // (which resets `synced` to 0) PATCHes instead of clobbering the secret. let is_new = relay.synced != 1 - && !self.query.relay_has_completed_sync(&relay.id).await?; + && self + .query + .get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync") + .await? + .is_none(); let mut body = serde_json::json!({ "host": format!("{}.{}", relay.subdomain, self.env.relay_domain), diff --git a/backend/src/query.rs b/backend/src/query.rs index dea4351..d84d391 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -4,6 +4,18 @@ use sqlx::SqlitePool; use crate::env::Env; use crate::models::{Activity, Plan, Relay, Tenant}; +fn select_tenant(tail: &str) -> String { + format!("SELECT * FROM tenant {tail}") +} + +fn select_relay(tail: &str) -> String { + format!("SELECT * FROM relay {tail}") +} + +fn select_activity(tail: &str) -> String { + format!("SELECT * FROM activity {tail}") +} + #[derive(Clone)] pub struct Query { pool: SqlitePool, @@ -18,28 +30,7 @@ impl Query { } } - pub async fn list_tenants(&self) -> Result> { - let rows = sqlx::query_as::<_, Tenant>( - "SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at - FROM tenant - ORDER BY pubkey", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn get_tenant(&self, pubkey: &str) -> Result> { - let row = sqlx::query_as::<_, Tenant>( - "SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at - FROM tenant - WHERE pubkey = ?", - ) - .bind(pubkey) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } + // Plans pub fn list_plans(&self) -> Vec { vec![ @@ -77,77 +68,24 @@ impl Query { self.list_plans().into_iter().find(|p| p.id == plan_id) } - /// True for any plan that costs money. Doesn't require an instance because - /// the answer doesn't depend on Stripe price ids — only the canonical plan id. - pub fn is_paid_plan(plan_id: &str) -> bool { - matches!(plan_id, "basic" | "growth") + pub fn is_paid_plan(&self, plan_id: &str) -> bool { + self.get_plan(plan_id).is_some_and(|p| p.amount > 0) } - pub async fn list_relays(&self) -> Result> { - let rows = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, - status, sync_error, - info_name, info_icon, info_description, - policy_public_join, policy_strip_signatures, - groups_enabled, management_enabled, blossom_enabled, - livekit_enabled, push_enabled, synced - FROM relay - ORDER BY id", - ) - .fetch_all(&self.pool) - .await?; + // Tenants + + pub async fn list_tenants(&self) -> Result> { + let rows = sqlx::query_as::<_, Tenant>(&select_tenant("")) + .fetch_all(&self.pool) + .await?; Ok(rows) } - pub async fn list_relays_pending_sync(&self) -> Result> { - let rows = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, - status, sync_error, - info_name, info_icon, info_description, - policy_public_join, policy_strip_signatures, - groups_enabled, management_enabled, blossom_enabled, - livekit_enabled, push_enabled, synced - FROM relay - WHERE synced = 0 OR TRIM(sync_error) != '' - ORDER BY id", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result> { - let rows = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, - status, sync_error, - info_name, info_icon, info_description, - policy_public_join, policy_strip_signatures, - groups_enabled, management_enabled, blossom_enabled, - livekit_enabled, push_enabled, synced - FROM relay - WHERE tenant = ? - ORDER BY id", - ) - .bind(tenant_id) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn get_relay(&self, id: &str) -> Result> { - let row = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, - status, sync_error, - info_name, info_icon, info_description, - policy_public_join, policy_strip_signatures, - groups_enabled, management_enabled, blossom_enabled, - livekit_enabled, push_enabled, synced - FROM relay - WHERE id = ?", - ) - .bind(id) - .fetch_optional(&self.pool) - .await?; + pub async fn get_tenant(&self, pubkey: &str) -> Result> { + let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?")) + .bind(pubkey) + .fetch_optional(&self.pool) + .await?; Ok(row) } @@ -155,17 +93,49 @@ impl Query { &self, stripe_customer_id: &str, ) -> Result> { - let row = sqlx::query_as::<_, Tenant>( - "SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at - FROM tenant - WHERE stripe_customer_id = ?", - ) - .bind(stripe_customer_id) - .fetch_optional(&self.pool) - .await?; + let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE stripe_customer_id = ?")) + .bind(stripe_customer_id) + .fetch_optional(&self.pool) + .await?; Ok(row) } + // Relays + + pub async fn list_relays(&self) -> Result> { + let rows = sqlx::query_as::<_, Relay>(&select_relay("")) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + pub async fn list_relays_pending_sync(&self) -> Result> { + let rows = sqlx::query_as::<_, Relay>(&select_relay( + "WHERE synced = 0 OR TRIM(sync_error) != ''", + )) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result> { + let rows = sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?")) + .bind(tenant_id) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + pub async fn get_relay(&self, id: &str) -> Result> { + let row = sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?")) + .bind(id) + .fetch_optional(&self.pool) + .await?; + Ok(row) + } + + // Invoice state + pub async fn get_invoice_nwc_payment_state(&self, invoice_id: &str) -> Result> { let state = sqlx::query_scalar::<_, String>( "SELECT state FROM invoice_nwc_payment WHERE invoice_id = ?", @@ -189,32 +159,28 @@ impl Query { Ok(bolt11) } - pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result> { - let rows = sqlx::query_as::<_, Activity>( - "SELECT id, tenant, created_at, activity_type, resource_type, resource_id - FROM activity - WHERE resource_type = 'relay' AND resource_id = ? - ORDER BY created_at DESC, id DESC", - ) - .bind(relay_id) + // Activity + + pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result> { + let rows = sqlx::query_as::<_, Activity>(&select_activity("WHERE resource_id = ? ORDER BY created_at DESC")) + .bind(resource_id) .fetch_all(&self.pool) .await?; Ok(rows) } - pub async fn relay_has_completed_sync(&self, relay_id: &str) -> Result { - let found = sqlx::query_scalar::<_, i64>( - "SELECT 1 - FROM activity - WHERE resource_type = 'relay' - AND resource_id = ? - AND activity_type = 'complete_relay_sync' - LIMIT 1", - ) - .bind(relay_id) + pub async fn get_latest_activity_for_resource_and_type( + &self, + resource_id: &str, + activity_type: &str, + ) -> Result> { + let row = sqlx::query_as::<_, Activity>(&select_activity( + "WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1", + )) + .bind(resource_id) + .bind(activity_type) .fetch_optional(&self.pool) .await?; - - Ok(found.is_some()) + Ok(row) } } diff --git a/backend/src/routes/relays.rs b/backend/src/routes/relays.rs index c8f18c7..50d2215 100644 --- a/backend/src/routes/relays.rs +++ b/backend/src/routes/relays.rs @@ -80,7 +80,7 @@ pub async fn list_relay_activity( let activity = api .query - .list_activity_for_relay(&id) + .list_activity_for_resource(&id) .await .map_err(internal)?; ok(serde_json::json!({ "activity": activity }))