diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 9e20f9a..36a435a 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -2761,6 +2761,7 @@ dependencies = [ "futures-util", "hex", "nostr 0.33.0", + "otrta-nostr", "rand 0.8.5", "regex", "reqwest", @@ -2779,6 +2780,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "otrta-nostr" +version = "1.0.0" +dependencies = [ + "anyhow", + "chrono", + "nostr 0.43.0", + "nostr-sdk", + "serde", + "serde_json", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "otrta-ui" version = "1.0.0" @@ -2793,6 +2810,7 @@ dependencies = [ "futures", "futures-util", "otrta", + "otrta-nostr", "pgvector", "reqwest", "secrecy", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 1641f9f..cf79041 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = [ - "otrta", "otrta-ui" + "otrta", "otrta-nostr", "otrta-ui" ] [workspace.package] @@ -15,6 +15,7 @@ repository = "https://github.com/ecash-402/ecash-402-routstr" description = "llm ecash 402 payment" [workspace.dependencies] +nostr = "0.43" ecash-402-wallet = "0.1.23" # ecash-402-wallet = {path = "../../ecash-402-wallet/crates/wallet/"} diff --git a/crates/otrta-nostr/Cargo.toml b/crates/otrta-nostr/Cargo.toml new file mode 100644 index 0000000..89fe5ad --- /dev/null +++ b/crates/otrta-nostr/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "otrta-nostr" +edition.workspace = true +version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description.workspace = true + +[dependencies] +nostr.workspace = true +nostr-sdk = "0.43" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.0", features = ["full"] } +tracing = "0.1" +anyhow = "1.0" +uuid = { version = "1.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +url = "2.5" diff --git a/crates/otrta-nostr/src/lib.rs b/crates/otrta-nostr/src/lib.rs new file mode 100644 index 0000000..4c9f21e --- /dev/null +++ b/crates/otrta-nostr/src/lib.rs @@ -0,0 +1,7 @@ +pub mod nip91_discovery; + +// Re-export main types for easier access +pub use nip91_discovery::{ + Discovery, NostrProvider, Provider, ProviderContent, discover_providers, + get_updated_providers_since, +}; diff --git a/crates/otrta-nostr/src/nip91_discovery.rs b/crates/otrta-nostr/src/nip91_discovery.rs new file mode 100644 index 0000000..87c7b2b --- /dev/null +++ b/crates/otrta-nostr/src/nip91_discovery.rs @@ -0,0 +1,234 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use nostr_sdk::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; +use tracing::{debug, info, warn}; + +const PROVIDER_ANNOUNCEMENT_KIND: u16 = 38421; + +const DEFAULT_RELAYS: &[&str] = &[ + "wss://relay.damus.io", + "wss://relay.snort.social", + "wss://nos.lol", + "wss://relay.nostr.band", + "wss://nostr.wine", + "wss://relay.primal.net", + "wss://relay.routstr.com", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderContent { + pub name: String, + pub about: String, + pub urls: Option>, + pub mints: Option>, + pub version: Option, + pub use_onion: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NostrProvider { + pub id: String, + pub pubkey: String, + pub name: String, + pub about: String, + pub urls: Vec, + pub mints: Vec, + pub version: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub followers: i32, + pub zaps: i32, + pub use_onion: bool, +} + +#[derive(Debug)] +pub struct NostrProviderDiscovery { + relays: Vec, + client: Client, +} + +fn parse_tags_to_map(tags: &Tags) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + + for tag in tags.iter() { + let tag_str = format!("{:?}", tag); + if let Some(start) = tag_str.find('[') { + if let Some(end) = tag_str.find(']') { + let inner = &tag_str[start + 1..end]; + let parts: Vec<&str> = inner + .split(',') + .map(|s| s.trim().trim_matches('"')) + .collect(); + + if parts.len() >= 2 { + let key = parts[0].to_string(); + let value = parts[1].to_string(); + map.entry(key).or_default().push(value); + } + } + } + } + + map +} + +impl NostrProviderDiscovery { + pub async fn new() -> Result { + let relays = DEFAULT_RELAYS.iter().map(|&s| s.to_string()).collect(); + let client = Client::default(); + + for relay in &relays { + if let Err(e) = client.add_relay(relay).await { + warn!("Failed to add relay {}: {}", relay, e); + } + } + + Ok(Self { relays, client }) + } + + pub async fn with_relays(relay_urls: Vec) -> Result { + let client = Client::default(); + + for relay in &relay_urls { + if let Err(e) = client.add_relay(relay).await { + warn!("Failed to add relay {}: {}", relay, e); + } + } + + Ok(Self { + relays: relay_urls, + client, + }) + } + + pub async fn discover_providers(&self) -> Result> { + info!("Starting provider discovery from Nostr relays..."); + + self.client.connect().await; + + tokio::time::sleep(Duration::from_secs(2)).await; + + let filter = Filter::new() + .kind(Kind::Custom(PROVIDER_ANNOUNCEMENT_KIND)) + .limit(100); + + info!( + "Fetching provider events from {} relays...", + self.relays.len() + ); + + let events = self + .client + .fetch_events(filter, Duration::from_secs(10)) + .await + .unwrap(); + + println!("Retrieved {} provider events", events.len()); + + let mut providers = Vec::new(); + + for event in events { + match self.parse_provider_from_event(&event) { + Ok(provider) => { + debug!("Successfully parsed provider: {}", provider.name); + providers.push(provider); + } + Err(e) => { + println!("Failed to parse provider event {}: {}", event.id, e); + continue; + } + } + } + + if providers.is_empty() {} + + info!("Discovered {} providers", providers.len()); + Ok(providers) + } + + fn parse_provider_from_event(&self, event: &Event) -> Result { + println!("Parsing event content: {}", event.content); + println!("Event tags: {:?}", event.tags); + + let content: ProviderContent = match serde_json::from_str(&event.content) { + Ok(content) => content, + Err(e) => { + return Err(anyhow::anyhow!("Failed to parse JSON content: {}", e)); + } + }; + + let mut urls = content.urls.unwrap_or_default(); + let mut mints = content.mints.unwrap_or_default(); + let mut version = content.version; + let mut use_onion = content.use_onion.unwrap_or(false); + + let tag_map = parse_tags_to_map(&event.tags); + println!("Parsed tag map: {:?}", tag_map); + + if let Some(tag_urls) = tag_map.get("u") { + for url in tag_urls { + urls.push(url.clone()); + if url.contains(".onion") { + use_onion = true; + } + } + } + + if let Some(tag_mints) = tag_map.get("mint") { + for mint in tag_mints { + mints.push(mint.clone()); + } + } + + if let Some(tag_version) = tag_map.get("version") { + if let Some(v) = tag_version.first() { + version = Some(v.clone()); + } + } + + if urls.is_empty() { + return Err(anyhow::anyhow!("Provider must have at least one URL")); + } + + if !use_onion { + use_onion = + urls.iter().any(|url| url.contains(".onion")) || content.use_onion.unwrap_or(false); + } + + Ok(NostrProvider { + id: event.id.to_hex(), + pubkey: event.pubkey.to_hex(), + name: content.name, + about: content.about, + urls, + mints, + version, + created_at: DateTime::from_timestamp(event.created_at.as_u64() as i64, 0) + .unwrap_or_else(|| Utc::now()), + updated_at: Utc::now(), + followers: 0, + zaps: 0, + use_onion, + }) + } + + pub async fn get_updated_providers(&self, _since: DateTime) -> Result> { + self.discover_providers().await + } +} + +pub async fn discover_providers() -> Result> { + let discovery = NostrProviderDiscovery::new().await?; + discovery.discover_providers().await +} + +pub async fn get_updated_providers_since(since: DateTime) -> Result> { + let discovery = NostrProviderDiscovery::new().await?; + discovery.get_updated_providers(since).await +} + +pub use NostrProvider as Provider; +pub use NostrProviderDiscovery as Discovery; diff --git a/crates/otrta-ui/Cargo.toml b/crates/otrta-ui/Cargo.toml index bfb6bec..6b1d8fc 100644 --- a/crates/otrta-ui/Cargo.toml +++ b/crates/otrta-ui/Cargo.toml @@ -38,3 +38,4 @@ secrecy = {version = "0.10", features = ["serde"]} ecash-402-wallet.workspace = true otrta = { path = "../otrta"} +otrta-nostr = { path = "../otrta-nostr" } diff --git a/crates/otrta-ui/migrations/20250722000006_add_provider_source.down.sql b/crates/otrta-ui/migrations/20250722000006_add_provider_source.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/crates/otrta-ui/migrations/20250722000006_add_provider_source.up.sql b/crates/otrta-ui/migrations/20250722000006_add_provider_source.up.sql new file mode 100644 index 0000000..a68b763 --- /dev/null +++ b/crates/otrta-ui/migrations/20250722000006_add_provider_source.up.sql @@ -0,0 +1,8 @@ +-- Add up migration script here + +ALTER TABLE providers +ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'manual' NOT NULL; + +UPDATE providers SET source = 'manual' WHERE source IS NULL; + +CREATE INDEX IF NOT EXISTS idx_providers_source ON providers(source); diff --git a/crates/otrta-ui/migrations/20250722000007_update_provider_url_constraint_with_source.down.sql b/crates/otrta-ui/migrations/20250722000007_update_provider_url_constraint_with_source.down.sql new file mode 100644 index 0000000..bfd35ea --- /dev/null +++ b/crates/otrta-ui/migrations/20250722000007_update_provider_url_constraint_with_source.down.sql @@ -0,0 +1,21 @@ +-- Revert provider URL constraint back to simple URL uniqueness +-- This removes the source-aware constraint and restores the original behavior + +DO $$ +BEGIN + -- Drop the composite unique constraint if it exists + IF EXISTS ( + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_name = 'providers' + AND constraint_name = 'providers_url_source_key' + AND constraint_type = 'UNIQUE' + ) THEN + ALTER TABLE providers DROP CONSTRAINT providers_url_source_key; + END IF; + + -- Add back the simple unique constraint on url + -- Note: This may fail if there are duplicate URLs with different sources + ALTER TABLE providers ADD CONSTRAINT providers_url_key UNIQUE (url); + +END $$; diff --git a/crates/otrta-ui/migrations/20250722000007_update_provider_url_constraint_with_source.up.sql b/crates/otrta-ui/migrations/20250722000007_update_provider_url_constraint_with_source.up.sql new file mode 100644 index 0000000..f7a441d --- /dev/null +++ b/crates/otrta-ui/migrations/20250722000007_update_provider_url_constraint_with_source.up.sql @@ -0,0 +1,24 @@ +-- Update provider URL constraint to allow same URL for different sources +-- This enables having the same URL for both 'nostr' and 'manual' providers + +DO $$ +BEGIN + -- Drop the existing unique constraint on url if it exists + IF EXISTS ( + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_name = 'providers' + AND constraint_name = 'providers_url_key' + AND constraint_type = 'UNIQUE' + ) THEN + ALTER TABLE providers DROP CONSTRAINT providers_url_key; + END IF; + + -- Drop any existing composite index + DROP INDEX IF EXISTS providers_url_org_unique; + + -- Add new unique constraint on url + source combination + -- This allows same URL for different sources (nostr vs manual) + ALTER TABLE providers ADD CONSTRAINT providers_url_source_key UNIQUE (url, source); + +END $$; diff --git a/crates/otrta-ui/src/background.rs b/crates/otrta-ui/src/background.rs index 622a319..9da1b6a 100644 --- a/crates/otrta-ui/src/background.rs +++ b/crates/otrta-ui/src/background.rs @@ -1,7 +1,7 @@ use super::*; use otrta::handlers::refresh_models_background; use tokio::time::{Duration, interval}; -use tracing::{error, info}; +use tracing::{debug, error, info}; pub struct BackgroundJobRunner { app_state: Arc, @@ -15,11 +15,16 @@ impl BackgroundJobRunner { pub async fn start_all_jobs(&self) { info!("Starting background jobs..."); - // Model refresh job - every 5 minutes let state_clone = Arc::clone(&self.app_state); tokio::spawn(async move { Self::model_refresh_job(state_clone, 300).await; }); + + let state_clone = Arc::clone(&self.app_state); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(60)).await; + Self::nostr_provider_discovery_job(state_clone, 300).await; + }); } async fn model_refresh_job(app_state: Arc, interval_secs: u64) { @@ -43,4 +48,77 @@ impl BackgroundJobRunner { } } } + + async fn nostr_provider_discovery_job(app_state: Arc, interval_secs: u64) { + let mut interval = interval(Duration::from_secs(interval_secs)); + info!( + "Background Nostr provider discovery job started with {}s interval", + interval_secs + ); + + loop { + interval.tick().await; + info!("Running background Nostr provider discovery..."); + + match Self::discover_and_update_nostr_providers(&app_state).await { + Ok((added, updated)) => { + info!( + "Nostr provider discovery completed successfully: {} added, {} updated", + added, updated + ); + } + Err(e) => { + error!("Nostr provider discovery failed: {:?}", e); + } + } + } + } + + async fn discover_and_update_nostr_providers( + app_state: &AppState, + ) -> Result<(usize, usize), Box> { + use otrta::db::provider::{CreateNostrProviderRequest, upsert_nostr_provider}; + use otrta_nostr::discover_providers; + + let nostr_providers = discover_providers().await?; + info!("Discovered {} providers from Nostr", nostr_providers.len()); + + let providers_added = 0; + let mut providers_updated = 0; + + for nostr_provider in nostr_providers { + let request = CreateNostrProviderRequest { + name: nostr_provider.name.clone(), + about: nostr_provider.about.clone(), + url: nostr_provider + .urls + .first() + .unwrap_or(&"".to_string()) + .clone(), + mints: nostr_provider.mints.clone(), + use_onion: nostr_provider.use_onion, + followers: nostr_provider.followers, + zaps: nostr_provider.zaps, + version: nostr_provider.version.clone(), + }; + + match upsert_nostr_provider(&app_state.db, request).await { + Ok(_provider) => { + providers_updated += 1; + debug!( + "Successfully upserted Nostr provider: {}", + nostr_provider.name + ); + } + Err(e) => { + error!( + "Failed to upsert Nostr provider '{}': {}", + nostr_provider.name, e + ); + } + } + } + + Ok((providers_added, providers_updated)) + } } diff --git a/crates/otrta/Cargo.toml b/crates/otrta/Cargo.toml index 6d4a217..7a99090 100644 --- a/crates/otrta/Cargo.toml +++ b/crates/otrta/Cargo.toml @@ -34,5 +34,6 @@ url = "2.5" dashmap = "6.1" # otrta-wallet = { path = "../otrta-wallet" } +otrta-nostr = { path = "../otrta-nostr" } ecash-402-wallet.workspace = true diff --git a/crates/otrta/src/db/provider.rs b/crates/otrta/src/db/provider.rs index c762a20..756e52b 100644 --- a/crates/otrta/src/db/provider.rs +++ b/crates/otrta/src/db/provider.rs @@ -1,4 +1,5 @@ use crate::db::Pool; +use otrta_nostr::discover_providers; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; @@ -26,6 +27,30 @@ impl std::fmt::Display for ProviderError { impl std::error::Error for ProviderError {} +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)] +pub enum ProviderSource { + Manual, + Nostr, +} + +impl std::fmt::Display for ProviderSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProviderSource::Manual => write!(f, "manual"), + ProviderSource::Nostr => write!(f, "nostr"), + } + } +} + +impl From for ProviderSource { + fn from(s: String) -> Self { + match s.to_lowercase().as_str() { + "nostr" => ProviderSource::Nostr, + _ => ProviderSource::Manual, + } + } +} + #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct Provider { pub id: i32, @@ -37,6 +62,7 @@ pub struct Provider { pub zaps: i32, pub is_default: bool, pub is_custom: bool, + pub source: String, pub organization_id: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, @@ -76,6 +102,20 @@ pub struct RefreshProvidersResponse { pub message: Option, } +impl Provider { + pub fn get_source(&self) -> ProviderSource { + ProviderSource::from(self.source.clone()) + } + + pub fn is_from_nostr(&self) -> bool { + self.get_source() == ProviderSource::Nostr + } + + pub fn is_manual(&self) -> bool { + self.get_source() == ProviderSource::Manual + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct CreateCustomProviderRequest { pub name: String, @@ -84,6 +124,18 @@ pub struct CreateCustomProviderRequest { pub use_onion: bool, } +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateNostrProviderRequest { + pub name: String, + pub about: String, + pub url: String, + pub mints: Vec, + pub use_onion: bool, + pub followers: i32, + pub zaps: i32, + pub version: Option, +} + #[derive(Debug, Serialize, Deserialize)] pub struct UpdateCustomProviderRequest { pub name: String, @@ -94,16 +146,18 @@ pub struct UpdateCustomProviderRequest { pub async fn get_all_providers(db: &Pool) -> Result, sqlx::Error> { let providers = sqlx::query_as::<_, Provider>( - "SELECT - p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, - p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, + "SELECT + p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, + p.is_default, p.is_custom, + COALESCE(p.source, 'manual') as source, + p.organization_id, p.created_at, p.updated_at, EXISTS( - SELECT 1 FROM mints m - WHERE m.mint_url = ANY(p.mints) + SELECT 1 FROM mints m + WHERE m.mint_url = ANY(p.mints) AND m.currency_unit = 'msat' ) as has_msat_support FROM providers p - ORDER BY p.is_default DESC, p.is_custom ASC, p.followers DESC, p.zaps DESC", + ORDER BY p.is_default DESC, COALESCE(p.source, 'manual') DESC, p.is_custom ASC, p.followers DESC, p.zaps DESC", ) .fetch_all(db) .await?; @@ -113,12 +167,13 @@ pub async fn get_all_providers(db: &Pool) -> Result, sqlx::Error> pub async fn get_default_provider(db: &Pool) -> Result, sqlx::Error> { let provider = sqlx::query_as::<_, Provider>( - "SELECT - p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, - p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, + "SELECT + p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, + p.is_default, p.is_custom, COALESCE(p.source, 'manual') as source, + p.organization_id, p.created_at, p.updated_at, EXISTS( - SELECT 1 FROM mints m - WHERE m.mint_url = ANY(p.mints) + SELECT 1 FROM mints m + WHERE m.mint_url = ANY(p.mints) AND m.currency_unit = 'msat' ) as has_msat_support FROM providers p @@ -132,12 +187,13 @@ pub async fn get_default_provider(db: &Pool) -> Result, sqlx::E pub async fn get_provider_by_id(db: &Pool, id: i32) -> Result, sqlx::Error> { let provider = sqlx::query_as::<_, Provider>( - "SELECT - p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, - p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, + "SELECT + p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, + p.is_default, p.is_custom, COALESCE(p.source, 'manual') as source, + p.organization_id, p.created_at, p.updated_at, EXISTS( - SELECT 1 FROM mints m - WHERE m.mint_url = ANY(p.mints) + SELECT 1 FROM mints m + WHERE m.mint_url = ANY(p.mints) AND m.currency_unit = 'msat' ) as has_msat_support FROM providers p @@ -182,9 +238,9 @@ pub async fn create_custom_provider( } let provider = sqlx::query_as::<_, Provider>( - "INSERT INTO providers (name, url, mints, use_onion, followers, zaps, is_custom, organization_id, updated_at) - VALUES ($1, $2, $3, $4, 0, 0, TRUE, NULL, NOW()) - RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, organization_id, created_at, updated_at, false as has_msat_support" + "INSERT INTO providers (name, url, mints, use_onion, followers, zaps, is_custom, source, organization_id, updated_at) + VALUES ($1, $2, $3, $4, 0, 0, TRUE, 'manual', NULL, NOW()) + RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, COALESCE(source, 'manual') as source, organization_id, created_at, updated_at, false as has_msat_support" ) .bind(&request.name) .bind(&request.url) @@ -211,6 +267,7 @@ pub async fn delete_custom_provider(db: &Pool, id: i32) -> Result Result { let provider = sqlx::query_as::<_, Provider>( - "INSERT INTO providers (name, url, mints, use_onion, followers, zaps, is_custom, organization_id, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, FALSE, NULL, NOW()) + "INSERT INTO providers (name, url, mints, use_onion, followers, zaps, is_custom, source, organization_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, FALSE, 'manual', NULL, NOW()) ON CONFLICT (url, COALESCE(organization_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO UPDATE SET name = EXCLUDED.name, @@ -254,7 +311,7 @@ pub async fn upsert_provider( followers = EXCLUDED.followers, zaps = EXCLUDED.zaps, updated_at = NOW() - RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, organization_id, created_at, updated_at" + RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, COALESCE(source, 'manual') as source, organization_id, created_at, updated_at, false as has_msat_support" ) .bind(name) .bind(url) @@ -270,53 +327,109 @@ pub async fn upsert_provider( pub async fn refresh_providers_from_nostr( db: &Pool, + organization_id: &Uuid, ) -> Result> { - // This is a placeholder for now - in a real implementation, you would: - // 1. Connect to Nostr relays - // 2. Query for provider announcements using specific event kinds - // 3. Parse the provider data from the events - // 4. Update the database with fresh data - - // For now, we'll simulate updating the existing providers with new follower/zap counts let mut providers_updated = 0; + let mut providers_added = 0; + let mut newly_created_provider_ids: Vec = Vec::new(); + + let nostr_providers = match discover_providers().await { + Ok(providers) => providers, + Err(e) => { + eprintln!("Failed to discover providers from Nostr: {}", e); + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to discover providers: {}", e), + ))); + } + }; + + for nostr_provider in nostr_providers { + for url in &nostr_provider.urls { + if url.is_empty() { + continue; + } + + let existing_provider = sqlx::query_as::<_, Provider>( + "SELECT id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, + COALESCE(source, 'manual') as source, organization_id, created_at, updated_at, + EXISTS( + SELECT 1 FROM mints m + WHERE m.mint_url = ANY(mints) + AND m.currency_unit = 'msat' + ) as has_msat_support + FROM providers WHERE url = $1 AND source = 'nostr'", + ) + .bind(url) + .fetch_optional(db) + .await?; + + if let Some(provider) = existing_provider { + let result = sqlx::query( + "UPDATE providers SET + name = $1, + mints = $2, + use_onion = $3, + followers = $4, + zaps = $5, + updated_at = NOW() + WHERE id = $6", + ) + .bind(&nostr_provider.name) + .bind(&nostr_provider.mints) + .bind(nostr_provider.use_onion) + .bind(nostr_provider.followers) + .bind(nostr_provider.zaps) + .bind(provider.id) + .execute(db) + .await?; - // Simulate fetching updated data from Nostr (only update non-custom providers) - let mock_updates = vec![ - (1, 1300, 92000), // Lightning Labs Provider - (2, 920, 47500), // Casa Node Provider - (3, 2200, 160000), // Strike Provider - (4, 720, 34800), // Breez Provider - (5, 1500, 82000), // Alby Provider - ]; - - for (id, new_followers, new_zaps) in mock_updates { - match sqlx::query( - "UPDATE providers SET followers = $1, zaps = $2, updated_at = NOW() WHERE id = $3 AND is_custom = FALSE" - ) - .bind(new_followers) - .bind(new_zaps) - .bind(id) - .execute(db) - .await - { - Ok(result) => { if result.rows_affected() > 0 { providers_updated += 1; } - } - Err(e) => { - eprintln!("Failed to update provider {}: {}", id, e); + } else { + let provider_name = if nostr_provider.urls.len() > 1 { + let url_suffix = if url.contains(".onion") { " (Tor)" } else { "" }; + format!("{}{}", nostr_provider.name, url_suffix) + } else { + nostr_provider.name.clone() + }; + + let created_provider = sqlx::query!( + "INSERT INTO providers (name, url, mints, use_onion, followers, zaps, is_default, is_custom, source, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE, 'nostr', NOW(), NOW()) + RETURNING id", + &provider_name, + url, + &nostr_provider.mints, + nostr_provider.use_onion, + nostr_provider.followers, + nostr_provider.zaps + ) + .fetch_one(db) + .await?; + + providers_added += 1; + newly_created_provider_ids.push(created_provider.id); } } } + // Auto-activate newly created providers for the organization + for provider_id in newly_created_provider_ids { + if let Err(e) = activate_provider_for_organization(db, organization_id, provider_id).await { + eprintln!("Failed to auto-activate provider {}: {}", provider_id, e); + // Continue with other providers even if one fails + } + } + Ok(RefreshProvidersResponse { success: true, providers_updated, - providers_added: 0, + providers_added, message: Some(format!( - "Updated {} providers from Nostr marketplace", - providers_updated + "Updated {} providers and added {} new providers from Nostr marketplace", + providers_updated, providers_added )), }) } @@ -326,10 +439,16 @@ pub async fn get_providers_for_organization( organization_id: &Uuid, ) -> Result, sqlx::Error> { let providers = sqlx::query_as::<_, Provider>( - "SELECT id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, organization_id, created_at, updated_at + "SELECT id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, + COALESCE(source, 'manual') as source, organization_id, created_at, updated_at, + EXISTS( + SELECT 1 FROM mints m + WHERE m.mint_url = ANY(mints) + AND m.currency_unit = 'msat' + ) as has_msat_support FROM providers WHERE organization_id IS NULL OR organization_id = $1 - ORDER BY is_default DESC, is_custom ASC, followers DESC, zaps DESC" + ORDER BY is_default DESC, is_custom ASC, followers DESC, zaps DESC", ) .bind(organization_id) .fetch_all(db) @@ -343,9 +462,15 @@ pub async fn get_default_provider_for_organization( organization_id: &Uuid, ) -> Result, sqlx::Error> { let provider = sqlx::query_as::<_, Provider>( - "SELECT id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, organization_id, created_at, updated_at + "SELECT id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, + COALESCE(source, 'manual') as source, organization_id, created_at, updated_at, + EXISTS( + SELECT 1 FROM mints m + WHERE m.mint_url = ANY(mints) + AND m.currency_unit = 'msat' + ) as has_msat_support FROM providers - WHERE is_default = TRUE AND (organization_id IS NULL OR organization_id = $1)" + WHERE is_default = TRUE AND (organization_id IS NULL OR organization_id = $1)", ) .bind(organization_id) .fetch_optional(db) @@ -360,12 +485,12 @@ pub async fn get_provider_by_id_for_organization( organization_id: &Uuid, ) -> Result, sqlx::Error> { let provider = sqlx::query_as::<_, Provider>( - "SELECT - p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, - p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, + "SELECT + p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, + p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, p.source, EXISTS( - SELECT 1 FROM mints m - WHERE m.mint_url = ANY(p.mints) + SELECT 1 FROM mints m + WHERE m.mint_url = ANY(p.mints) AND m.organization_id = $2 AND m.currency_unit = 'msat' ) as has_msat_support @@ -401,9 +526,9 @@ pub async fn create_custom_provider_for_organization( } let provider = sqlx::query_as::<_, Provider>( - "INSERT INTO providers (name, url, mints, use_onion, followers, zaps, is_custom, organization_id, updated_at) - VALUES ($1, $2, $3, $4, 0, 0, TRUE, $5, NOW()) - RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, organization_id, created_at, updated_at, false as has_msat_support" + "INSERT INTO providers (name, url, mints, use_onion, followers, zaps, is_custom, source, organization_id, updated_at) + VALUES ($1, $2, $3, $4, 0, 0, TRUE, 'manual', $5, NOW()) + RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, COALESCE(source, 'manual') as source, organization_id, created_at, updated_at, false as has_msat_support" ) .bind(&request.name) .bind(&request.url) @@ -413,6 +538,9 @@ pub async fn create_custom_provider_for_organization( .fetch_one(db) .await?; + // Automatically activate the newly created provider for this organization + let _ = activate_provider_for_organization(db, organization_id, provider.id).await?; + Ok(provider) } @@ -437,10 +565,10 @@ pub async fn update_custom_provider( } let provider = sqlx::query_as::<_, Provider>( - "UPDATE providers + "UPDATE providers SET name = $1, url = $2, mints = $3, use_onion = $4, updated_at = NOW() WHERE id = $5 AND is_custom = TRUE - RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, organization_id, created_at, updated_at, false as has_msat_support" + RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, COALESCE(source, 'manual') as source, organization_id, created_at, updated_at, false as has_msat_support" ) .bind(&request.name) .bind(&request.url) @@ -476,10 +604,10 @@ pub async fn update_custom_provider_for_organization( } let provider = sqlx::query_as::<_, Provider>( - "UPDATE providers + "UPDATE providers SET name = $1, url = $2, mints = $3, use_onion = $4, updated_at = NOW() WHERE id = $5 AND is_custom = TRUE AND organization_id = $6 - RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, organization_id, created_at, updated_at, false as has_msat_support" + RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, COALESCE(source, 'manual') as source, organization_id, created_at, updated_at, false as has_msat_support" ) .bind(&request.name) .bind(&request.url) @@ -541,12 +669,12 @@ pub async fn get_available_providers_for_organization( r#" SELECT p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, - p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, + p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, p.source, COALESCE(op.is_active, false) as is_active_for_org, COALESCE(op.is_default, false) as is_default_for_org, EXISTS( - SELECT 1 FROM mints m - WHERE m.mint_url = ANY(p.mints) + SELECT 1 FROM mints m + WHERE m.mint_url = ANY(p.mints) AND m.organization_id = $1 AND m.currency_unit = 'msat' ) as has_msat_support @@ -583,6 +711,7 @@ pub async fn get_available_providers_for_organization( zaps: row.zaps.unwrap_or(0), is_default: row.is_default.unwrap_or(false), is_custom, + source: row.source, organization_id: provider_org_id, created_at: row.created_at.unwrap_or_else(chrono::Utc::now), updated_at: row.updated_at.unwrap_or_else(chrono::Utc::now), @@ -606,10 +735,10 @@ pub async fn get_active_providers_for_organization( r#" SELECT p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, - p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, + p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, p.source, EXISTS( - SELECT 1 FROM mints m - WHERE m.mint_url = ANY(p.mints) + SELECT 1 FROM mints m + WHERE m.mint_url = ANY(p.mints) AND m.organization_id = $1 AND m.currency_unit = 'msat' ) as has_msat_support @@ -635,10 +764,10 @@ pub async fn get_default_provider_for_organization_new( r#" SELECT p.id, p.name, p.url, p.mints, p.use_onion, p.followers, p.zaps, - p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, + p.is_default, p.is_custom, p.organization_id, p.created_at, p.updated_at, p.source, EXISTS( - SELECT 1 FROM mints m - WHERE m.mint_url = ANY(p.mints) + SELECT 1 FROM mints m + WHERE m.mint_url = ANY(p.mints) AND m.organization_id = $1 AND m.currency_unit = 'msat' ) as has_msat_support @@ -778,8 +907,8 @@ pub async fn upsert_provider_for_organization( organization_id: Option<&Uuid>, ) -> Result { let provider = sqlx::query_as::<_, Provider>( - "INSERT INTO providers (name, url, mints, use_onion, followers, zaps, is_custom, organization_id, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, FALSE, $7, NOW()) + "INSERT INTO providers (name, url, mints, use_onion, followers, zaps, is_custom, source, organization_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, FALSE, 'manual', $7, NOW()) ON CONFLICT (url, COALESCE(organization_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO UPDATE SET name = EXCLUDED.name, @@ -788,7 +917,7 @@ pub async fn upsert_provider_for_organization( followers = EXCLUDED.followers, zaps = EXCLUDED.zaps, updated_at = NOW() - RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, organization_id, created_at, updated_at" + RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, 'manual' as source, organization_id, created_at, updated_at, false as has_msat_support" ) .bind(name) .bind(url) @@ -802,3 +931,32 @@ pub async fn upsert_provider_for_organization( Ok(provider) } + +pub async fn upsert_nostr_provider( + db: &Pool, + request: CreateNostrProviderRequest, +) -> Result { + let provider = sqlx::query_as::<_, Provider>( + "INSERT INTO providers (name, url, mints, use_onion, followers, zaps, is_custom, source, organization_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, FALSE, 'nostr', NULL, NOW()) + ON CONFLICT (url, COALESCE(organization_id, '00000000-0000-0000-0000-000000000000'::uuid)) + DO UPDATE SET + name = EXCLUDED.name, + mints = EXCLUDED.mints, + use_onion = EXCLUDED.use_onion, + followers = EXCLUDED.followers, + zaps = EXCLUDED.zaps, + updated_at = NOW() + RETURNING id, name, url, mints, use_onion, followers, zaps, is_default, is_custom, COALESCE(source, 'nostr') as source, organization_id, created_at, updated_at, false as has_msat_support" + ) + .bind(&request.name) + .bind(&request.url) + .bind(&request.mints) + .bind(request.use_onion) + .bind(request.followers) + .bind(request.zaps) + .fetch_one(db) + .await?; + + Ok(provider) +} diff --git a/crates/otrta/src/handlers/providers.rs b/crates/otrta/src/handlers/providers.rs index bc00111..655c3e3 100644 --- a/crates/otrta/src/handlers/providers.rs +++ b/crates/otrta/src/handlers/providers.rs @@ -307,8 +307,9 @@ pub async fn get_provider( pub async fn refresh_providers( State(state): State>, + Extension(user_ctx): Extension, ) -> Result, (StatusCode, Json)> { - match refresh_providers_from_nostr(&state.db).await { + match refresh_providers_from_nostr(&state.db, &user_ctx.organization_id).await { Ok(response) => Ok(Json(response)), Err(e) => { eprintln!("Failed to refresh providers: {}", e); diff --git a/ui/app/dashboard/page.tsx b/ui/app/dashboard/page.tsx index 2b40418..46e3776 100644 --- a/ui/app/dashboard/page.tsx +++ b/ui/app/dashboard/page.tsx @@ -56,7 +56,22 @@ export default function Page() { }); const mints = mintsData?.mints || []; - const activeMints = mints.filter((mint) => mint.is_active); + + // Deduplicate mints by mint_url, keeping the most recent (highest id) and preferring active mints + const deduplicatedMints = mints.reduce((acc, mint) => { + const existing = acc.get(mint.mint_url); + if ( + !existing || + (mint.is_active && !existing.is_active) || + (mint.is_active === existing.is_active && mint.id > existing.id) + ) { + acc.set(mint.mint_url, mint); + } + return acc; + }, new Map()); + + const uniqueMints = Array.from(deduplicatedMints.values()); + const activeMints = uniqueMints.filter((mint) => mint.is_active); // Create a map from mint_url to MintWithBalances for easy lookup const mintBalancesMap = new Map( @@ -232,7 +247,7 @@ export default function Page() { ))} - ) : mints.length === 0 ? ( + ) : uniqueMints.length === 0 ? ( @@ -377,13 +392,13 @@ export default function Page() { ); })} - {mints.length > 6 && ( + {uniqueMints.length > 6 && (

- {mints.length - 6} more mint - {mints.length - 6 !== 1 ? 's' : ''} + {uniqueMints.length - 6} more mint + {uniqueMints.length - 6 !== 1 ? 's' : ''}

+ + + + + + + + Delete Custom Provider + + + Are you sure you want to delete " + {provider.name}"? This action cannot be undone. + + + + + Cancel + + + handleDeleteCustomProvider(provider.id) + } + className='w-full bg-red-600 hover:bg-red-700 md:w-auto' + > + Delete + + + + + + )} + + {/* Badges row */} +
+ {provider.is_default_for_org && ( + + + Default + + )} + {provider.is_custom && ( + + + Manual + + )} + {provider.source === 'nostr' && ( + + ⚡ Nostr + + )} + {!provider.has_msat_support && ( + + + + + + + + + Msat Precision Warning + + + This provider's mints only support satoshi + precision. Payments in millisatoshis (msat) will be + rounded down to the nearest satoshi, which may + result in small amounts of ecash being lost. + + + + + )} +
+ + {/* Third row: URL */} + + + + + + + +
+
+
+ + {provider.use_onion && ( + + + Tor + + )} +
+
+ + {expandedMints.has(provider.id) && + provider.mints.length > 0 && ( +
+
+ Supported Mints: +
+
+ {provider.mints.map((mint, index) => ( +
+
+ + {mint} + + +
+ ))} +
+
+ )} +
+ +
+ Updated{' '} + {formatDistanceToNow(new Date(provider.updated_at), { + addSuffix: true, + })} +
+ + {provider.is_active_for_org && !provider.is_default_for_org && ( + + )} + {!provider.is_active_for_org && ( + + )} + + + ))} +
+ ); + }; + if (error) { return (
@@ -145,34 +406,37 @@ export default function ProvidersPage() {

- Nostr Providers + Providers

- Select a provider from the Nostr marketplace to forward your - requests + Select a provider from the Nostr marketplace or add your own + manual provider

- Add Custom Provider + Add Manual Provider - Create a custom Nostr marketplace provider + Create a manually configured provider ))}
- ) : providers.length === 0 ? ( - - -

- No providers available. -

-
-
) : ( -
- {sortedProviders.map((provider) => ( - - -
- {/* Top row: Title and action buttons */} -
- - {provider.name} - - {provider.is_editable && ( -
- - - - - - - - - Delete Custom Provider - - - Are you sure you want to delete " - {provider.name}"? This action cannot be - undone. - - - - - Cancel - - - handleDeleteCustomProvider(provider.id) - } - className='w-full bg-red-600 hover:bg-red-700 md:w-auto' - > - Delete - - - - -
- )} -
- - {/* Second row: Badges */} -
- {provider.is_default_for_org && ( - - - Default - - )} - {provider.is_custom && ( - - - Custom - - )} - {!provider.has_msat_support && ( - - - - - - - - - Msat Precision Warning - - - This provider's mints only support - satoshi precision. Payments in millisatoshis - (msat) will be rounded down to the nearest - satoshi, which may result in small amounts of - ecash being lost. - - - - - )} -
- - {/* Third row: URL */} - - - -
-
- - -
-
-
- - {provider.use_onion && ( - - - Tor - - )} -
-
- - {expandedMints.has(provider.id) && - provider.mints.length > 0 && ( -
-
- Supported Mints: -
-
- {provider.mints.map((mint, index) => ( -
-
- - {mint} - - -
- ))} -
-
- )} -
- -
- Updated{' '} - {formatDistanceToNow(new Date(provider.updated_at), { - addSuffix: true, - })} -
- - {provider.is_active_for_org && - !provider.is_default_for_org && ( - - )} - {!provider.is_active_for_org && ( - - )} - - - ))} -
+ + + + ⚡ Nostr Providers + + {nostrProviders.length} + + + + + Manual Providers + + {customProviders.length} + + + + + {renderProviderCards(sortedNostrProviders)} + + + {renderProviderCards(sortedCustomProviders)} + + )}
diff --git a/ui/components/mint-list.tsx b/ui/components/mint-list.tsx index acbd4aa..6202ae1 100644 --- a/ui/components/mint-list.tsx +++ b/ui/components/mint-list.tsx @@ -59,7 +59,22 @@ export function MintList({ refetchBalance(); }; - const mints = mintsData?.mints || []; + const rawMints = mintsData?.mints || []; + + // Deduplicate mints by mint_url, keeping the most recent (highest id) and preferring active mints + const deduplicatedMints = rawMints.reduce((acc, mint) => { + const existing = acc.get(mint.mint_url); + if ( + !existing || + (mint.is_active && !existing.is_active) || + (mint.is_active === existing.is_active && mint.id > existing.id) + ) { + acc.set(mint.mint_url, mint); + } + return acc; + }, new Map()); + + const mints = Array.from(deduplicatedMints.values()); const activeMints = mints.filter((mint) => mint.is_active); const inactiveMints = mints.filter((mint) => !mint.is_active); diff --git a/ui/components/mint-management-page.tsx b/ui/components/mint-management-page.tsx index 470246a..6751080 100644 --- a/ui/components/mint-management-page.tsx +++ b/ui/components/mint-management-page.tsx @@ -69,9 +69,10 @@ export function MintManagementPage() { }); // Legacy support - convert mints with units to simple mints for backward compatibility + // Also deduplicate by mint_url to avoid duplicates in dropdown const activeMints = activeMintsWithUnits - ? { - mints: activeMintsWithUnits.mints.map((m) => ({ + ? (() => { + const rawMints = activeMintsWithUnits.mints.map((m) => ({ id: m.id, mint_url: m.mint_url, currency_unit: m.currency_unit, @@ -80,9 +81,23 @@ export function MintManagementPage() { organization_id: m.organization_id, created_at: m.created_at, updated_at: m.updated_at, - })), - total: activeMintsWithUnits.total, - } + })); + + // Deduplicate by mint_url, keeping the most recent (highest id) + const deduplicatedMints = rawMints.reduce((acc, mint) => { + const existing = acc.get(mint.mint_url); + if (!existing || mint.id > existing.id) { + acc.set(mint.mint_url, mint); + } + return acc; + }, new Map()); + + const uniqueMints = Array.from(deduplicatedMints.values()); + return { + mints: uniqueMints, + total: uniqueMints.length, + }; + })() : undefined; // Find the selected mint to get available units diff --git a/ui/lib/api/services/providers.ts b/ui/lib/api/services/providers.ts index 2f66733..7f99436 100644 --- a/ui/lib/api/services/providers.ts +++ b/ui/lib/api/services/providers.ts @@ -10,6 +10,7 @@ export interface Provider { zaps: number; is_default: boolean; is_custom: boolean; + source: string; organization_id: string | null; created_at: string; updated_at: string; diff --git a/ui/lib/hooks/useProviders.ts b/ui/lib/hooks/useProviders.ts index 515228c..8e8e8a9 100644 --- a/ui/lib/hooks/useProviders.ts +++ b/ui/lib/hooks/useProviders.ts @@ -45,6 +45,29 @@ export function useDefaultProvider() { }; } +export function useRefreshProviders() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => ProviderService.refreshProviders(), + onSuccess: (response) => { + queryClient.invalidateQueries({ queryKey: ['providers'] }); + queryClient.invalidateQueries({ queryKey: ['defaultProvider'] }); + if (response.message) { + toast.success(response.message); + } else { + toast.success( + `Updated ${response.providers_updated} providers, added ${response.providers_added} new providers` + ); + } + }, + onError: (error) => { + console.error('Error refreshing providers:', error); + toast.error('Failed to refresh providers from Nostr marketplace'); + }, + }); +} + export function useSetDefaultProvider() { const queryClient = useQueryClient();