diff --git a/crud.py b/crud.py index 3bec1f2..adc0836 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,5 @@ import json +from typing import List, Optional, Tuple from lnbits.helpers import urlsafe_short_hash @@ -43,7 +44,7 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant: async def update_merchant( user_id: str, merchant_id: str, config: MerchantConfig -) -> Merchant | None: +) -> Optional[Merchant]: await db.execute( f""" UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now} @@ -54,7 +55,7 @@ async def update_merchant( return await get_merchant(user_id, merchant_id) -async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None: +async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: await db.execute( f""" UPDATE nostrmarket.merchants SET time = {db.timestamp_now} @@ -65,7 +66,7 @@ async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None: return await get_merchant(user_id, merchant_id) -async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None: +async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: row: dict = await db.fetchone( """SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""", { @@ -77,7 +78,7 @@ async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None: return Merchant.from_row(row) if row else None -async def get_merchant_by_pubkey(public_key: str) -> Merchant | None: +async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]: row: dict = await db.fetchone( """SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""", {"public_key": public_key}, @@ -86,7 +87,7 @@ async def get_merchant_by_pubkey(public_key: str) -> Merchant | None: return Merchant.from_row(row) if row else None -async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]: +async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]: rows: list[dict] = await db.fetchall( """SELECT id, public_key FROM nostrmarket.merchants""", ) @@ -94,7 +95,7 @@ async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]: return [(row["id"], row["public_key"]) for row in rows] -async def get_merchant_for_user(user_id: str) -> Merchant | None: +async def get_merchant_for_user(user_id: str) -> Optional[Merchant]: row: dict = await db.fetchone( """SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """, {"user_id": user_id}, @@ -137,7 +138,7 @@ async def create_zone(merchant_id: str, data: Zone) -> Zone: return zone -async def update_zone(merchant_id: str, z: Zone) -> Zone | None: +async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]: await db.execute( """ UPDATE nostrmarket.zones @@ -156,7 +157,7 @@ async def update_zone(merchant_id: str, z: Zone) -> Zone | None: return await get_zone(merchant_id, z.id) -async def get_zone(merchant_id: str, zone_id: str) -> Zone | None: +async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]: row: dict = await db.fetchone( "SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id", { @@ -167,7 +168,7 @@ async def get_zone(merchant_id: str, zone_id: str) -> Zone | None: return Zone.from_row(row) if row else None -async def get_zones(merchant_id: str) -> list[Zone]: +async def get_zones(merchant_id: str) -> List[Zone]: rows: list[dict] = await db.fetchall( "SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id", {"merchant_id": merchant_id}, @@ -234,7 +235,7 @@ async def create_stall(merchant_id: str, data: Stall) -> Stall: return stall -async def get_stall(merchant_id: str, stall_id: str) -> Stall | None: +async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.stalls @@ -248,7 +249,7 @@ async def get_stall(merchant_id: str, stall_id: str) -> Stall | None: return Stall.from_row(row) if row else None -async def get_stalls(merchant_id: str, pending: bool | None = False) -> list[Stall]: +async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.stalls @@ -273,7 +274,7 @@ async def get_last_stall_update_time() -> int: return row["event_created_at"] or 0 if row else 0 -async def update_stall(merchant_id: str, stall: Stall) -> Stall | None: +async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]: await db.execute( """ UPDATE nostrmarket.stalls @@ -397,7 +398,9 @@ async def update_product(merchant_id: str, product: Product) -> Product: return updated_product -async def update_product_quantity(product_id: str, new_quantity: int) -> Product | None: +async def update_product_quantity( + product_id: str, new_quantity: int +) -> Optional[Product]: await db.execute( """ UPDATE nostrmarket.products SET quantity = :quantity @@ -412,7 +415,7 @@ async def update_product_quantity(product_id: str, new_quantity: int) -> Product return Product.from_row(row) if row else None -async def get_product(merchant_id: str, product_id: str) -> Product | None: +async def get_product(merchant_id: str, product_id: str) -> Optional[Product]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.products @@ -428,8 +431,8 @@ async def get_product(merchant_id: str, product_id: str) -> Product | None: async def get_products( - merchant_id: str, stall_id: str, pending: bool | None = False -) -> list[Product]: + merchant_id: str, stall_id: str, pending: Optional[bool] = False +) -> List[Product]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.products @@ -442,8 +445,8 @@ async def get_products( async def get_products_by_ids( - merchant_id: str, product_ids: list[str] -) -> list[Product]: + merchant_id: str, product_ids: List[str] +) -> List[Product]: # todo: revisit keys = [] @@ -464,7 +467,7 @@ async def get_products_by_ids( return [Product.from_row(row) for row in rows] -async def get_wallet_for_product(product_id: str) -> str | None: +async def get_wallet_for_product(product_id: str) -> Optional[str]: row: dict = await db.fetchone( """ SELECT s.wallet as wallet FROM nostrmarket.products p @@ -571,7 +574,7 @@ async def create_order(merchant_id: str, o: Order) -> Order: return order -async def get_order(merchant_id: str, order_id: str) -> Order | None: +async def get_order(merchant_id: str, order_id: str) -> Optional[Order]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.orders @@ -585,7 +588,7 @@ async def get_order(merchant_id: str, order_id: str) -> Order | None: return Order.from_row(row) if row else None -async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None: +async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.orders @@ -599,7 +602,7 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None return Order.from_row(row) if row else None -async def get_orders(merchant_id: str, **kwargs) -> list[Order]: +async def get_orders(merchant_id: str, **kwargs) -> List[Order]: q = " AND ".join( [ f"{field[0]} = :{field[0]}" @@ -626,7 +629,7 @@ async def get_orders(merchant_id: str, **kwargs) -> list[Order]: async def get_orders_for_stall( merchant_id: str, stall_id: str, **kwargs -) -> list[Order]: +) -> List[Order]: q = " AND ".join( [ f"{field[0]} = :{field[0]}" @@ -652,7 +655,7 @@ async def get_orders_for_stall( return [Order.from_row(row) for row in rows] -async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | None: +async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]: q = ", ".join( [ f"{field[0]} = :{field[0]}" @@ -676,7 +679,7 @@ async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | Non return await get_order(merchant_id, order_id) -async def update_order_paid_status(order_id: str, paid: bool) -> Order | None: +async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]: await db.execute( "UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id", {"paid": paid, "id": order_id}, @@ -690,7 +693,7 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Order | None: async def update_order_shipped_status( merchant_id: str, order_id: str, shipped: bool -) -> Order | None: +) -> Optional[Order]: await db.execute( """ UPDATE nostrmarket.orders @@ -754,7 +757,7 @@ async def create_direct_message( return msg -async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None: +async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.direct_messages @@ -770,7 +773,7 @@ async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | No async def get_direct_message_by_event_id( merchant_id: str, event_id: str -) -> DirectMessage | None: +) -> Optional[DirectMessage]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.direct_messages @@ -784,7 +787,7 @@ async def get_direct_message_by_event_id( return DirectMessage.from_row(row) if row else None -async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectMessage]: +async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectMessage]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.direct_messages @@ -796,7 +799,7 @@ async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectM return [DirectMessage.from_row(row) for row in rows] -async def get_orders_from_direct_messages(merchant_id: str) -> list[DirectMessage]: +async def get_orders_from_direct_messages(merchant_id: str) -> List[DirectMessage]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.direct_messages @@ -857,7 +860,7 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer: return customer -async def get_customer(merchant_id: str, public_key: str) -> Customer | None: +async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.customers @@ -871,7 +874,7 @@ async def get_customer(merchant_id: str, public_key: str) -> Customer | None: return Customer.from_row(row) if row else None -async def get_customers(merchant_id: str) -> list[Customer]: +async def get_customers(merchant_id: str) -> List[Customer]: rows: list[dict] = await db.fetchall( "SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id", {"merchant_id": merchant_id}, @@ -879,7 +882,7 @@ async def get_customers(merchant_id: str) -> list[Customer]: return [Customer.from_row(row) for row in rows] -async def get_all_unique_customers() -> list[Customer]: +async def get_all_unique_customers() -> List[Customer]: q = """ SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at) FROM nostrmarket.customers diff --git a/helpers.py b/helpers.py index dcc0f06..dd26116 100644 --- a/helpers.py +++ b/helpers.py @@ -1,5 +1,6 @@ import base64 import secrets +from typing import Optional import coincurve from bech32 import bech32_decode, convertbits @@ -36,7 +37,7 @@ def decrypt_message(encoded_message: str, encryption_key) -> str: return unpadded_data.decode() -def encrypt_message(message: str, encryption_key, iv: bytes | None = None) -> str: +def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> str: padder = padding.PKCS7(128).padder() padded_data = padder.update(message.encode()) + padder.finalize() diff --git a/misc-docs/ORDER-DISCOVERY-ANALYSIS.md b/misc-docs/ORDER-DISCOVERY-ANALYSIS.md new file mode 100644 index 0000000..de393e2 --- /dev/null +++ b/misc-docs/ORDER-DISCOVERY-ANALYSIS.md @@ -0,0 +1,320 @@ +# Nostrmarket Order Discovery Analysis + +## Executive Summary + +This document analyzes the order discovery mechanism in the Nostrmarket extension and identifies why merchants must manually refresh to see new orders instead of receiving them automatically through persistent subscriptions. + +--- + +## Current Architecture + +### Two Subscription Systems + +The Nostrmarket extension implements two distinct subscription mechanisms for receiving Nostr events: + +#### 1. **Persistent Subscriptions (Background Task)** + +**Purpose**: Continuous monitoring for new orders, products, and merchant events + +**Implementation**: + +- Runs via `wait_for_nostr_events()` background task +- Initiated on extension startup (15-second delay) +- Creates subscription ID: `nostrmarket-{hash}` +- Monitors all merchant public keys continuously + +**Code Location**: `/nostrmarket/tasks.py:37-49` + +```python +async def wait_for_nostr_events(nostr_client: NostrClient): + while True: + try: + await subscribe_to_all_merchants() + while True: + message = await nostr_client.get_event() + await process_nostr_message(message) +``` + +**Subscription Filters**: + +- Direct messages (kind 4) - for orders +- Stall events (kind 30017) +- Product events (kind 30018) +- Profile updates (kind 0) + +#### 2. **Temporary Subscriptions (Manual Refresh)** + +**Purpose**: Catch up on missed events when merchant clicks "Refresh from Nostr" + +**Implementation**: + +- Duration: 10 seconds only +- Triggered by user action +- Creates subscription ID: `merchant-{hash}` +- Fetches ALL events from time=0 + +**Code Location**: `/nostrmarket/nostr/nostr_client.py:100-120` + +```python +async def merchant_temp_subscription(self, pk, duration=10): + dm_filters = self._filters_for_direct_messages([pk], 0) + # ... creates filters with time=0 (all history) + await self.send_req_queue.put(["REQ", subscription_id] + merchant_filters) + asyncio.create_task(unsubscribe_with_delay(subscription_id, duration)) +``` + +--- + +## Problem Identification + +### Why Manual Refresh is Required + +#### **Issue 1: Timing Window Problem** + +The persistent subscription uses timestamps from the last database update: + +```python +async def subscribe_to_all_merchants(): + last_dm_time = await get_last_direct_messages_created_at() + last_stall_time = await get_last_stall_update_time() + last_prod_time = await get_last_product_update_time() + + await nostr_client.subscribe_merchants( + public_keys, last_dm_time, last_stall_time, last_prod_time, 0 + ) +``` + +**Problem**: Events that occur between: + +- The last database update time +- When the subscription becomes active + ...are potentially missed + +#### **Issue 2: Connection Stability** + +The WebSocket connection between components may be unstable: + +``` +[Nostrmarket] <--WebSocket--> [Nostrclient] <--WebSocket--> [Nostr Relays] + Extension Extension (Global) +``` + +**Potential failure points**: + +1. Connection drops between nostrmarket → nostrclient +2. Connection drops between nostrclient → relays +3. Reconnection doesn't re-establish subscriptions + +#### **Issue 3: Subscription State Management** + +**Current behavior**: + +- Single persistent subscription per merchant +- No automatic resubscription on failure +- No heartbeat/keepalive mechanism +- No verification that subscription is active + +#### **Issue 4: Event Processing Delays** + +The startup sequence has intentional delays: + +```python +async def _subscribe_to_nostr_client(): + await asyncio.sleep(10) # Wait for nostrclient + await nostr_client.run_forever() + +async def _wait_for_nostr_events(): + await asyncio.sleep(15) # Wait for extension init + await wait_for_nostr_events(nostr_client) +``` + +**Problem**: Orders arriving during initialization are missed + +--- + +## Why Manual Refresh Works + +The temporary subscription succeeds because: + +1. **Fetches from time=0**: Gets ALL historical events +2. **Fresh connection**: Creates new subscription request +3. **Immediate processing**: No startup delays +4. **Direct feedback**: User sees results immediately + +```python +# Temporary subscription uses time=0 (all events) +dm_filters = self._filters_for_direct_messages([pk], 0) # ← 0 means all time + +# Persistent subscription uses last update time +dm_filters = self._filters_for_direct_messages(public_keys, dm_time) # ← can miss events +``` + +--- + +## Impact Analysis + +### User Experience Issues + +1. **Merchants miss orders** without manual refresh +2. **No real-time notifications** for new orders +3. **Uncertainty** about order status +4. **Extra manual steps** required +5. **Delayed order fulfillment** + +### Technical Implications + +1. **Not truly decentralized** - requires active monitoring +2. **Scalability concerns** - manual refresh doesn't scale +3. **Reliability issues** - depends on user action +4. **Performance overhead** - fetching all events repeatedly + +--- + +## Recommended Solutions + +### Solution A: Enhanced Persistent Subscriptions + +**Implement redundant subscription mechanisms:** + +```python +class EnhancedSubscriptionManager: + def __init__(self): + self.last_heartbeat = time.time() + self.subscription_active = False + + async def maintain_subscription(self): + while True: + if not self.subscription_active or \ + time.time() - self.last_heartbeat > 30: + await self.resubscribe_with_overlap() + await asyncio.sleep(10) + + async def resubscribe_with_overlap(self): + # Use timestamp with 5-minute overlap + overlap_time = int(time.time()) - 300 + await subscribe_to_all_merchants(since=overlap_time) +``` + +### Solution B: Periodic Auto-Refresh + +**Add automatic temporary subscriptions:** + +```python +async def auto_refresh_loop(): + while True: + await asyncio.sleep(60) # Every minute + merchants = await get_all_active_merchants() + for merchant in merchants: + await merchant_temp_subscription(merchant.pubkey, duration=5) +``` + +### Solution C: WebSocket Health Monitoring + +**Implement connection health checks:** + +```python +class WebSocketHealthMonitor: + async def check_connection_health(self): + try: + # Send ping to nostrclient + response = await nostr_client.ping(timeout=5) + if not response: + await self.reconnect_and_resubscribe() + except Exception: + await self.reconnect_and_resubscribe() +``` + +### Solution D: Event Gap Detection + +**Detect and fill gaps in event sequence:** + +```python +async def detect_event_gaps(): + # Check for gaps in event timestamps + last_known = await get_last_event_time() + current_time = int(time.time()) + + if current_time - last_known > 60: # 1 minute gap + # Perform temporary subscription to fill gap + await fetch_missing_events(since=last_known) +``` + +--- + +## Implementation Priority + +### Phase 1: Quick Fixes (1-2 days) + +1. [DONE] Increase temp subscription duration (10s → 30s) +2. [DONE] Add connection health logging +3. [DONE] Reduce startup delays + +### Phase 2: Reliability (3-5 days) + +1. [TODO] Implement subscription heartbeat +2. [TODO] Add automatic resubscription on failure +3. [TODO] Create event gap detection + +### Phase 3: Full Solution (1-2 weeks) + +1. [TODO] WebSocket connection monitoring +2. [TODO] Redundant subscription system +3. [TODO] Real-time order notifications +4. [TODO] Event deduplication logic + +--- + +## Testing Recommendations + +### Test Scenarios + +1. **Order during startup**: Send order within 15 seconds of server start +2. **Long-running test**: Keep server running for 24 hours, send periodic orders +3. **Connection interruption**: Disconnect nostrclient, send order, reconnect +4. **High volume**: Send 100 orders rapidly +5. **Network latency**: Add artificial delay between components + +### Monitoring Metrics + +- Time between order sent → order discovered +- Percentage of orders requiring manual refresh +- WebSocket connection uptime +- Subscription success rate +- Event processing latency + +--- + +## Conclusion + +The current order discovery system relies on manual refresh due to: + +1. **Timing gaps** in persistent subscriptions +2. **Connection stability** issues +3. **Lack of redundancy** in subscription management +4. **No automatic recovery** mechanisms + +While the temporary subscription (manual refresh) provides a workaround, a proper solution requires implementing connection monitoring, subscription health checks, and automatic gap-filling mechanisms to ensure reliable real-time order discovery. + +--- + +## Appendix: Code References + +### Key Files + +- `/nostrmarket/tasks.py` - Background task management +- `/nostrmarket/nostr/nostr_client.py` - Nostr client implementation +- `/nostrmarket/services.py` - Order processing logic +- `/nostrmarket/views_api.py` - API endpoints for refresh + +### Relevant Functions + +- `wait_for_nostr_events()` - Main event loop +- `subscribe_to_all_merchants()` - Persistent subscription +- `merchant_temp_subscription()` - Manual refresh +- `process_nostr_message()` - Event processing + +--- + +_Document prepared: January 2025_ +_Analysis based on: Nostrmarket v1.0_ +_Status: Active Investigation_ diff --git a/models.py b/models.py index f1af073..b12b775 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,7 @@ import json import time from abc import abstractmethod from enum import Enum -from typing import Any +from typing import Any, List, Optional, Tuple from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis from pydantic import BaseModel @@ -32,16 +32,26 @@ class Nostrable: class MerchantProfile(BaseModel): - name: str | None = None - about: str | None = None - picture: str | None = None + name: Optional[str] = None + display_name: Optional[str] = None + about: Optional[str] = None + picture: Optional[str] = None + banner: Optional[str] = None + website: Optional[str] = None + nip05: Optional[str] = None + lud16: Optional[str] = None class MerchantConfig(MerchantProfile): - event_id: str | None = None + event_id: Optional[str] = None sync_from_nostr: bool = False - active: bool = False - restore_in_progress: bool | None = False + # TODO: switched to True for AIO demo; determine if we leave this as True + active: bool = True + restore_in_progress: Optional[bool] = False + + +class CreateMerchantRequest(BaseModel): + config: MerchantConfig = MerchantConfig() class PartialMerchant(BaseModel): @@ -52,7 +62,7 @@ class PartialMerchant(BaseModel): class Merchant(PartialMerchant, Nostrable): id: str - time: int | None = 0 + time: Optional[int] = 0 def sign_hash(self, hash_: bytes) -> str: return sign_message_hash(self.private_key, hash_) @@ -86,11 +96,23 @@ class Merchant(PartialMerchant, Nostrable): return merchant def to_nostr_event(self, pubkey: str) -> NostrEvent: - content = { - "name": self.config.name, - "about": self.config.about, - "picture": self.config.picture, - } + content: dict[str, str] = {} + if self.config.name: + content["name"] = self.config.name + if self.config.display_name: + content["display_name"] = self.config.display_name + if self.config.about: + content["about"] = self.config.about + if self.config.picture: + content["picture"] = self.config.picture + if self.config.banner: + content["banner"] = self.config.banner + if self.config.website: + content["website"] = self.config.website + if self.config.nip05: + content["nip05"] = self.config.nip05 + if self.config.lud16: + content["lud16"] = self.config.lud16 event = NostrEvent( pubkey=pubkey, created_at=round(time.time()), @@ -122,11 +144,11 @@ class Merchant(PartialMerchant, Nostrable): ######################################## ZONES ######################################## class Zone(BaseModel): - id: str | None = None - name: str | None = None + id: Optional[str] = None + name: Optional[str] = None currency: str cost: float - countries: list[str] = [] + countries: List[str] = [] @classmethod def from_row(cls, row: dict) -> "Zone": @@ -139,22 +161,22 @@ class Zone(BaseModel): class StallConfig(BaseModel): - image_url: str | None = None - description: str | None = None + image_url: Optional[str] = None + description: Optional[str] = None class Stall(BaseModel, Nostrable): - id: str | None = None + id: Optional[str] = None wallet: str name: str currency: str = "sat" - shipping_zones: list[Zone] = [] + shipping_zones: List[Zone] = [] config: StallConfig = StallConfig() pending: bool = False """Last published nostr event for this Stall""" - event_id: str | None = None - event_created_at: int | None = None + event_id: Optional[str] = None + event_created_at: Optional[int] = None def validate_stall(self): for z in self.shipping_zones: @@ -212,19 +234,19 @@ class ProductShippingCost(BaseModel): class ProductConfig(BaseModel): - description: str | None = None - currency: str | None = None - use_autoreply: bool | None = False - autoreply_message: str | None = None - shipping: list[ProductShippingCost] = [] + description: Optional[str] = None + currency: Optional[str] = None + use_autoreply: Optional[bool] = False + autoreply_message: Optional[str] = None + shipping: List[ProductShippingCost] = [] class Product(BaseModel, Nostrable): - id: str | None = None + id: Optional[str] = None stall_id: str name: str - categories: list[str] = [] - images: list[str] = [] + categories: List[str] = [] + images: List[str] = [] price: float quantity: int active: bool = True @@ -232,8 +254,8 @@ class Product(BaseModel, Nostrable): config: ProductConfig = ProductConfig() """Last published nostr event for this Product""" - event_id: str | None = None - event_created_at: int | None = None + event_id: Optional[str] = None + event_created_at: Optional[int] = None def to_nostr_event(self, pubkey: str) -> NostrEvent: content = { @@ -290,7 +312,7 @@ class ProductOverview(BaseModel): id: str name: str price: float - product_shipping_cost: float | None = None + product_shipping_cost: Optional[float] = None @classmethod def from_product(cls, p: Product) -> "ProductOverview": @@ -307,21 +329,21 @@ class OrderItem(BaseModel): class OrderContact(BaseModel): - nostr: str | None = None - phone: str | None = None - email: str | None = None + nostr: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None class OrderExtra(BaseModel): - products: list[ProductOverview] + products: List[ProductOverview] currency: str btc_price: str shipping_cost: float = 0 shipping_cost_sat: float = 0 - fail_message: str | None = None + fail_message: Optional[str] = None @classmethod - async def from_products(cls, products: list[Product]): + async def from_products(cls, products: List[Product]): currency = products[0].config.currency if len(products) else "sat" exchange_rate = ( await btc_price(currency) if currency and currency != "sat" else 1 @@ -337,19 +359,19 @@ class OrderExtra(BaseModel): class PartialOrder(BaseModel): id: str - event_id: str | None = None - event_created_at: int | None = None + event_id: Optional[str] = None + event_created_at: Optional[int] = None public_key: str merchant_public_key: str shipping_id: str - items: list[OrderItem] - contact: OrderContact | None = None - address: str | None = None + items: List[OrderItem] + contact: Optional[OrderContact] = None + address: Optional[str] = None def validate_order(self): assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" - def validate_order_items(self, product_list: list[Product]): + def validate_order_items(self, product_list: List[Product]): assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" assert ( len(product_list) != 0 @@ -370,8 +392,8 @@ class PartialOrder(BaseModel): ) async def costs_in_sats( - self, products: list[Product], shipping_id: str, stall_shipping_cost: float - ) -> tuple[float, float]: + self, products: List[Product], shipping_id: str, stall_shipping_cost: float + ) -> Tuple[float, float]: product_prices = {} for p in products: product_shipping_cost = next( @@ -400,7 +422,7 @@ class PartialOrder(BaseModel): return product_cost, stall_shipping_cost def receipt( - self, products: list[Product], shipping_id: str, stall_shipping_cost: float + self, products: List[Product], shipping_id: str, stall_shipping_cost: float ) -> str: if len(products) == 0: return "[No Products]" @@ -449,7 +471,7 @@ class Order(PartialOrder): total: float paid: bool = False shipped: bool = False - time: int | None = None + time: Optional[int] = None extra: OrderExtra @classmethod @@ -463,14 +485,14 @@ class Order(PartialOrder): class OrderStatusUpdate(BaseModel): id: str - message: str | None = None - paid: bool | None = False - shipped: bool | None = None + message: Optional[str] = None + paid: Optional[bool] = False + shipped: Optional[bool] = None class OrderReissue(BaseModel): id: str - shipping_id: str | None = None + shipping_id: Optional[str] = None class PaymentOption(BaseModel): @@ -480,8 +502,8 @@ class PaymentOption(BaseModel): class PaymentRequest(BaseModel): id: str - message: str | None = None - payment_options: list[PaymentOption] + message: Optional[str] = None + payment_options: List[PaymentOption] ######################################## MESSAGE ####################################### @@ -497,16 +519,16 @@ class DirectMessageType(Enum): class PartialDirectMessage(BaseModel): - event_id: str | None = None - event_created_at: int | None = None + event_id: Optional[str] = None + event_created_at: Optional[int] = None message: str public_key: str type: int = DirectMessageType.PLAIN_TEXT.value incoming: bool = False - time: int | None = None + time: Optional[int] = None @classmethod - def parse_message(cls, msg) -> tuple[DirectMessageType, Any | None]: + def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]: try: msg_json = json.loads(msg) if "type" in msg_json: @@ -529,15 +551,15 @@ class DirectMessage(PartialDirectMessage): class CustomerProfile(BaseModel): - name: str | None = None - about: str | None = None + name: Optional[str] = None + about: Optional[str] = None class Customer(BaseModel): merchant_id: str public_key: str - event_created_at: int | None = None - profile: CustomerProfile | None = None + event_created_at: Optional[int] = None + profile: Optional[CustomerProfile] = None unread_messages: int = 0 @classmethod diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index a611980..967bc1b 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -31,9 +31,11 @@ class NostrClient: logger.debug(f"Connecting to websockets for 'nostrclient' extension...") relay_endpoint = encrypt_internal_message("relay", urlsafe=True) + ws_url = f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}" + on_open, on_message, on_error, on_close = self._ws_handlers() ws = WebSocketApp( - f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}", + ws_url, on_message=on_message, on_open=on_open, on_close=on_close, @@ -65,6 +67,7 @@ class NostrClient: async def get_event(self): value = await self.recieve_event_queue.get() if isinstance(value, ValueError): + logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}") raise value return value @@ -91,10 +94,6 @@ class NostrClient: self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters) - logger.debug( - f"Subscribing to events for: {len(public_keys)} keys. New subscription id: {self.subscription_id}" - ) - async def merchant_temp_subscription(self, pk, duration=10): dm_filters = self._filters_for_direct_messages([pk], 0) stall_filters = self._filters_for_stall_events([pk], 0) @@ -175,16 +174,21 @@ class NostrClient: def _ws_handlers(self): def on_open(_): - logger.info("Connected to 'nostrclient' websocket") + logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully") def on_message(_, message): - self.recieve_event_queue.put_nowait(message) + logger.debug(f"[NOSTRMARKET DEBUG] 📨 Received websocket message: {message[:200]}...") + try: + self.recieve_event_queue.put_nowait(message) + logger.debug(f"[NOSTRMARKET DEBUG] 📤 Message queued successfully") + except Exception as e: + logger.error(f"[NOSTRMARKET] ❌ Failed to queue message: {e}") def on_error(_, error): - logger.warning(error) + logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}") def on_close(x, status_code, message): - logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'") + logger.warning(f"[NOSTRMARKET] 🔌 Websocket closed: {x}: '{status_code}' '{message}'") # force re-subscribe self.recieve_event_queue.put_nowait(ValueError("Websocket close.")) diff --git a/services.py b/services.py index 4039dbb..3057292 100644 --- a/services.py +++ b/services.py @@ -1,7 +1,8 @@ import asyncio import json +from typing import List, Optional, Tuple -from bolt11 import decode +from lnbits.bolt11 import decode from lnbits.core.crud import get_wallet from lnbits.core.services import create_invoice, websocket_updater from loguru import logger @@ -59,11 +60,12 @@ from .nostr.event import NostrEvent async def create_new_order( merchant_public_key: str, data: PartialOrder -) -> PaymentRequest | None: +) -> Optional[PaymentRequest]: merchant = await get_merchant_by_pubkey(merchant_public_key) assert merchant, "Cannot find merchant for order!" - if await get_order(merchant.id, data.id): + existing_order = await get_order(merchant.id, data.id) + if existing_order: return None if data.event_id and await get_order_by_event_id(merchant.id, data.event_id): return None @@ -73,20 +75,24 @@ async def create_new_order( ) await create_order(merchant.id, order) - return PaymentRequest( + payment_request = PaymentRequest( id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)], message=receipt, ) + return payment_request async def build_order_with_payment( merchant_id: str, merchant_public_key: str, data: PartialOrder ): + products = await get_products_by_ids( merchant_id, [p.product_id for p in data.items] ) + data.validate_order_items(products) + shipping_zone = await get_zone(merchant_id, data.shipping_id) assert shipping_zone, f"Shipping zone not found for order '{data.id}'" @@ -94,6 +100,7 @@ async def build_order_with_payment( product_cost_sat, shipping_cost_sat = await data.costs_in_sats( products, shipping_zone.id, shipping_zone.cost ) + receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost) wallet_id = await get_wallet_for_product(data.items[0].product_id) @@ -104,11 +111,13 @@ async def build_order_with_payment( merchant_id, product_ids, data.items ) if not success: + logger.error(f"[NOSTRMARKET] ❌ Product quantity check failed: {message}") raise ValueError(message) + total_amount_sat = round(product_cost_sat + shipping_cost_sat) payment = await create_invoice( wallet_id=wallet_id, - amount=round(product_cost_sat + shipping_cost_sat), + amount=total_amount_sat, memo=f"Order '{data.id}' for pubkey '{data.public_key}'", extra={ "tag": "nostrmarket", @@ -136,7 +145,7 @@ async def update_merchant_to_nostr( merchant: Merchant, delete_merchant=False ) -> Merchant: stalls = await get_stalls(merchant.id) - event: NostrEvent | None = None + event: Optional[NostrEvent] = None for stall in stalls: assert stall.id products = await get_products(merchant.id, stall.id) @@ -149,9 +158,8 @@ async def update_merchant_to_nostr( stall.event_id = event.id stall.event_created_at = event.created_at await update_stall(merchant.id, stall) - if delete_merchant: - # merchant profile updates not supported yet - event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant) + # Always publish merchant profile (kind 0) + event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant) assert event merchant.config.event_id = event.id return merchant @@ -221,7 +229,7 @@ async def notify_client_of_order_status( async def update_products_for_order( merchant: Merchant, order: Order -) -> tuple[bool, str]: +) -> Tuple[bool, str]: product_ids = [i.product_id for i in order.items] success, products, message = await compute_products_new_quantity( merchant.id, product_ids, order.items @@ -289,9 +297,9 @@ async def send_dm( async def compute_products_new_quantity( - merchant_id: str, product_ids: list[str], items: list[OrderItem] -) -> tuple[bool, list[Product], str]: - products: list[Product] = await get_products_by_ids(merchant_id, product_ids) + merchant_id: str, product_ids: List[str], items: List[OrderItem] +) -> Tuple[bool, List[Product], str]: + products: List[Product] = await get_products_by_ids(merchant_id, product_ids) for p in products: required_quantity = next( @@ -314,11 +322,17 @@ async def compute_products_new_quantity( async def process_nostr_message(msg: str): try: - type_, *rest = json.loads(msg) + parsed_msg = json.loads(msg) + type_, *rest = parsed_msg + if type_.upper() == "EVENT": + if len(rest) < 2: + logger.warning(f"[NOSTRMARKET] ⚠️ EVENT message missing data: {rest}") + return _, event = rest event = NostrEvent(**event) + if event.kind == 0: await _handle_customer_profile_update(event) elif event.kind == 4: @@ -327,10 +341,15 @@ async def process_nostr_message(msg: str): await _handle_stall(event) elif event.kind == 30018: await _handle_product(event) + else: + logger.info(f"[NOSTRMARKET] ❓ Unhandled event kind: {event.kind} - event: {event.id}") return + else: + logger.info(f"[NOSTRMARKET] 🔄 Non-EVENT message type: {type_}") except Exception as ex: - logger.debug(ex) + logger.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}") + logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}") async def create_or_update_order_from_dm( @@ -412,28 +431,29 @@ async def extract_customer_order_from_dm( async def _handle_nip04_message(event: NostrEvent): - merchant_public_key = event.pubkey - merchant = await get_merchant_by_pubkey(merchant_public_key) - - if not merchant: - p_tags = event.tag_values("p") - if len(p_tags) and p_tags[0]: - merchant_public_key = p_tags[0] - merchant = await get_merchant_by_pubkey(merchant_public_key) - - assert merchant, f"Merchant not found for public key '{merchant_public_key}'" - - if event.pubkey == merchant_public_key: - assert len(event.tag_values("p")) != 0, "Outgong message has no 'p' tag" - clear_text_msg = merchant.decrypt_message( + + p_tags = event.tag_values("p") + + # PRIORITY 1: Check if any recipient (p_tag) is a merchant → incoming message TO merchant + for p_tag in p_tags: + if p_tag: + potential_merchant = await get_merchant_by_pubkey(p_tag) + if potential_merchant: + clear_text_msg = potential_merchant.decrypt_message(event.content, event.pubkey) + await _handle_incoming_dms(event, potential_merchant, clear_text_msg) + return # IMPORTANT: Return immediately to prevent double processing + + # PRIORITY 2: If no recipient merchant found, check if sender is a merchant → outgoing message FROM merchant + sender_merchant = await get_merchant_by_pubkey(event.pubkey) + if sender_merchant: + assert len(event.tag_values("p")) != 0, "Outgoing message has no 'p' tag" + clear_text_msg = sender_merchant.decrypt_message( event.content, event.tag_values("p")[0] ) - await _handle_outgoing_dms(event, merchant, clear_text_msg) - elif event.has_tag_value("p", merchant_public_key): - clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) - await _handle_incoming_dms(event, merchant, clear_text_msg) - else: - logger.warning(f"Bad NIP04 event: '{event.id}'") + await _handle_outgoing_dms(event, sender_merchant, clear_text_msg) + return # IMPORTANT: Return immediately + + # No merchant found in either direction async def _handle_incoming_dms( @@ -483,17 +503,18 @@ async def _handle_outgoing_dms( async def _handle_incoming_structured_dm( merchant: Merchant, dm: DirectMessage, json_data: dict -) -> tuple[DirectMessageType, str | None]: +) -> Tuple[DirectMessageType, Optional[str]]: try: if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active: json_resp = await _handle_new_order( merchant.id, merchant.public_key, dm, json_data ) - return DirectMessageType.PAYMENT_REQUEST, json_resp + else: + logger.info(f"[NOSTRMARKET] Skipping order processing - type: {dm.type}, expected: {DirectMessageType.CUSTOMER_ORDER.value}, merchant_active: {merchant.config.active}") except Exception as ex: - logger.warning(ex) + logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}") return DirectMessageType.PLAIN_TEXT, None @@ -574,9 +595,31 @@ async def _handle_new_order( wallet = await get_wallet(wallet_id) assert wallet, f"Cannot find wallet for product id: {first_product_id}" + payment_req = await create_new_order(merchant_public_key, partial_order) + + if payment_req is None: + # Return existing order data instead of creating a failed order + existing_order = await get_order(merchant_id, partial_order.id) + if existing_order and existing_order.invoice_id != "None": + # Order exists with invoice, return existing payment request + duplicate_response = json.dumps({ + "type": DirectMessageType.PAYMENT_REQUEST.value, + "id": existing_order.id, + "message": "Order already received and processed", + "payment_options": [] + }, separators=(",", ":"), ensure_ascii=False) + return duplicate_response + else: + # Order exists but no invoice, skip processing + logger.info(f"[NOSTRMARKET] Order exists but no invoice, returning empty string") + return "" + except Exception as e: - logger.debug(e) + logger.error(f"[NOSTRMARKET] Error creating order: {e}") + logger.error(f"[NOSTRMARKET] Order data: {json_data}") + logger.error(f"[NOSTRMARKET] Exception type: {type(e).__name__}") + logger.error(f"[NOSTRMARKET] Exception details: {str(e)}") payment_req = await create_new_failed_order( merchant_id, merchant_public_key, @@ -584,12 +627,17 @@ async def _handle_new_order( json_data, "Order received, but cannot be processed. Please contact merchant.", ) - assert payment_req + + if not payment_req: + logger.error(f"[NOSTRMARKET] No payment request returned for order: {partial_order.id}") + return "" + response = { "type": DirectMessageType.PAYMENT_REQUEST.value, **payment_req.dict(), } - return json.dumps(response, separators=(",", ":"), ensure_ascii=False) + response_json = json.dumps(response, separators=(",", ":"), ensure_ascii=False) + return response_json async def create_new_failed_order( @@ -622,8 +670,11 @@ async def subscribe_to_all_merchants(): last_stall_time = await get_last_stall_update_time() last_prod_time = await get_last_product_update_time() + # Make dm_time more lenient by subtracting 5 minutes to avoid missing recent events + lenient_dm_time = max(0, last_dm_time - 300) if last_dm_time > 0 else 0 + await nostr_client.subscribe_merchants( - public_keys, last_dm_time, last_stall_time, last_prod_time, 0 + public_keys, lenient_dm_time, last_stall_time, last_prod_time, 0 ) diff --git a/static/components/edit-profile-dialog.js b/static/components/edit-profile-dialog.js new file mode 100644 index 0000000..0736695 --- /dev/null +++ b/static/components/edit-profile-dialog.js @@ -0,0 +1,91 @@ +window.app.component('edit-profile-dialog', { + name: 'edit-profile-dialog', + template: '#edit-profile-dialog', + delimiters: ['${', '}'], + props: ['model-value', 'merchant-id', 'merchant-config', 'adminkey'], + emits: ['update:model-value', 'profile-updated'], + data: function () { + return { + saving: false, + formData: { + name: '', + display_name: '', + about: '', + picture: '', + banner: '', + website: '', + nip05: '', + lud16: '' + } + } + }, + computed: { + show: { + get() { + return this.modelValue + }, + set(value) { + this.$emit('update:model-value', value) + } + } + }, + methods: { + saveProfile: async function () { + this.saving = true + try { + const config = { + ...this.merchantConfig, + name: this.formData.name || null, + display_name: this.formData.display_name || null, + about: this.formData.about || null, + picture: this.formData.picture || null, + banner: this.formData.banner || null, + website: this.formData.website || null, + nip05: this.formData.nip05 || null, + lud16: this.formData.lud16 || null + } + await LNbits.api.request( + 'PATCH', + `/nostrmarket/api/v1/merchant/${this.merchantId}`, + this.adminkey, + config + ) + // Publish to Nostr + await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`, + this.adminkey + ) + this.show = false + this.$q.notify({ + type: 'positive', + message: 'Profile saved and published to Nostr!' + }) + this.$emit('profile-updated') + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.saving = false + } + }, + loadFormData: function () { + if (this.merchantConfig) { + this.formData.name = this.merchantConfig.name || '' + this.formData.display_name = this.merchantConfig.display_name || '' + this.formData.about = this.merchantConfig.about || '' + this.formData.picture = this.merchantConfig.picture || '' + this.formData.banner = this.merchantConfig.banner || '' + this.formData.website = this.merchantConfig.website || '' + this.formData.nip05 = this.merchantConfig.nip05 || '' + this.formData.lud16 = this.merchantConfig.lud16 || '' + } + } + }, + watch: { + modelValue(newVal) { + if (newVal) { + this.loadFormData() + } + } + } +}) diff --git a/static/components/key-pair.js b/static/components/key-pair.js deleted file mode 100644 index 5bf9d23..0000000 --- a/static/components/key-pair.js +++ /dev/null @@ -1,22 +0,0 @@ -window.app.component('key-pair', { - name: 'key-pair', - template: '#key-pair', - delimiters: ['${', '}'], - props: ['public-key', 'private-key'], - data: function () { - return { - showPrivateKey: false - } - }, - methods: { - copyText: function (text, message, position) { - var notify = this.$q.notify - Quasar.copyToClipboard(text).then(function () { - notify({ - message: message || 'Copied to clipboard!', - position: position || 'bottom' - }) - }) - } - } -}) diff --git a/static/components/merchant-tab.js b/static/components/merchant-tab.js index c82d853..1194420 100644 --- a/static/components/merchant-tab.js +++ b/static/components/merchant-tab.js @@ -10,14 +10,46 @@ window.app.component('merchant-tab', { 'merchant-active', 'public-key', 'private-key', - 'is-admin' + 'is-admin', + 'merchant-config' ], + emits: [ + 'toggle-show-keys', + 'hide-keys', + 'merchant-deleted', + 'toggle-merchant-state', + 'restart-nostr-connection', + 'profile-updated', + 'import-key', + 'generate-key' + ], + data: function () { + return { + showEditProfileDialog: false, + showKeysDialog: false + } + }, computed: { marketClientUrl: function () { return '/nostrmarket/market' } }, methods: { + publishProfile: async function () { + try { + await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`, + this.adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Profile published to Nostr!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, toggleShowKeys: function () { this.$emit('toggle-show-keys') }, @@ -27,11 +59,40 @@ window.app.component('merchant-tab', { handleMerchantDeleted: function () { this.$emit('merchant-deleted') }, + removeMerchant: function () { + const name = + this.merchantConfig?.display_name || + this.merchantConfig?.name || + 'this merchant' + LNbits.utils + .confirmDialog( + `Are you sure you want to remove "${name}"? This will delete all associated data (stalls, products, orders, messages).` + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + `/nostrmarket/api/v1/merchant/${this.merchantId}`, + this.adminkey + ) + this.$emit('merchant-deleted') + this.$q.notify({ + type: 'positive', + message: 'Merchant removed' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }) + }, toggleMerchantState: function () { this.$emit('toggle-merchant-state') }, restartNostrConnection: function () { this.$emit('restart-nostr-connection') + }, + handleImageError: function (e) { + e.target.style.display = 'none' } } }) diff --git a/static/components/nostr-keys-dialog.js b/static/components/nostr-keys-dialog.js new file mode 100644 index 0000000..81c451f --- /dev/null +++ b/static/components/nostr-keys-dialog.js @@ -0,0 +1,56 @@ +window.app.component('nostr-keys-dialog', { + name: 'nostr-keys-dialog', + template: '#nostr-keys-dialog', + delimiters: ['${', '}'], + props: ['public-key', 'private-key', 'model-value'], + emits: ['update:model-value'], + data: function () { + return { + showNsec: false + } + }, + computed: { + show: { + get() { + return this.modelValue + }, + set(value) { + this.$emit('update:model-value', value) + } + }, + npub: function () { + if (!this.publicKey) return '' + try { + return window.NostrTools.nip19.npubEncode(this.publicKey) + } catch (e) { + return this.publicKey + } + }, + nsec: function () { + if (!this.privateKey) return '' + try { + return window.NostrTools.nip19.nsecEncode(this.privateKey) + } catch (e) { + return this.privateKey + } + } + }, + methods: { + copyText: function (text, message) { + var notify = this.$q.notify + Quasar.copyToClipboard(text).then(function () { + notify({ + message: message || 'Copied to clipboard!', + position: 'bottom' + }) + }) + } + }, + watch: { + modelValue(newVal) { + if (!newVal) { + this.showNsec = false + } + } + } +}) diff --git a/static/js/index.js b/static/js/index.js index 00560df..b10220c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -5,7 +5,7 @@ window.app = Vue.createApp({ mixins: [window.windowMixin], data: function () { return { - activeTab: 'merchant', + activeTab: 'orders', selectedStallFilter: null, merchant: {}, shippingZones: [], @@ -19,6 +19,13 @@ window.app = Vue.createApp({ privateKey: null } }, + generateKeyDialog: { + show: false, + privateKey: null, + nsec: null, + npub: null, + showNsec: false + }, wsConnection: null, nostrStatus: { connected: false, @@ -43,26 +50,18 @@ window.app = Vue.createApp({ }, methods: { generateKeys: async function () { - const privateKey = nostr.generatePrivateKey() - await this.createMerchant(privateKey) + // No longer need to generate keys here - the backend will use user's existing keypairs + await this.createMerchant() }, importKeys: async function () { this.importKeyDialog.show = false - let privateKey = this.importKeyDialog.data.privateKey - if (!privateKey) { - return - } - try { - if (privateKey.toLowerCase().startsWith('nsec')) { - privateKey = nostr.nip19.decode(privateKey).data - } - } catch (error) { - this.$q.notify({ - type: 'negative', - message: `${error}` - }) - } - await this.createMerchant(privateKey) + // Import keys functionality removed since we use user's native keypairs + // Show a message that this is no longer needed + this.$q.notify({ + type: 'info', + message: 'Merchants now use your account Nostr keys automatically. Key import is no longer needed.', + timeout: 3000 + }) }, showImportKeysDialog: async function () { this.importKeyDialog.show = true @@ -117,12 +116,9 @@ window.app = Vue.createApp({ this.showKeys = false this.stallCount = 0 }, - createMerchant: async function (privateKey) { + createMerchant: async function () { try { - const pubkey = nostr.getPublicKey(privateKey) const payload = { - private_key: privateKey, - public_key: pubkey, config: {} } const {data} = await LNbits.api.request( diff --git a/tasks.py b/tasks.py index 013a281..774951f 100644 --- a/tasks.py +++ b/tasks.py @@ -35,13 +35,17 @@ async def on_invoice_paid(payment: Payment) -> None: async def wait_for_nostr_events(nostr_client: NostrClient): + logger.info("[NOSTRMARKET DEBUG] Starting wait_for_nostr_events task") while True: try: + logger.info("[NOSTRMARKET DEBUG] Subscribing to all merchants...") await subscribe_to_all_merchants() while True: + logger.debug("[NOSTRMARKET DEBUG] Waiting for nostr event...") message = await nostr_client.get_event() + logger.info(f"[NOSTRMARKET DEBUG] Received event from nostr_client: {message[:100]}...") await process_nostr_message(message) except Exception as e: - logger.warning(f"Subcription failed. Will retry in one minute: {e}") + logger.warning(f"[NOSTRMARKET DEBUG] Subscription failed. Will retry in 10 seconds: {e}") await asyncio.sleep(10) diff --git a/templates/nostrmarket/components/edit-profile-dialog.html b/templates/nostrmarket/components/edit-profile-dialog.html new file mode 100644 index 0000000..a447237 --- /dev/null +++ b/templates/nostrmarket/components/edit-profile-dialog.html @@ -0,0 +1,68 @@ + + + +
Edit Profile
+ + + + + + + + +
+ Save & Publish + Cancel +
+
+
+
diff --git a/templates/nostrmarket/components/key-pair.html b/templates/nostrmarket/components/key-pair.html deleted file mode 100644 index 911e057..0000000 --- a/templates/nostrmarket/components/key-pair.html +++ /dev/null @@ -1,93 +0,0 @@ -
- - - -
-
Keys
- -
- - -
- -
- - -
Public Key
-
- - - -
-
- ... -
- -
-
-
- - -
- - -
- - Private Key (Keep Secret!) -
-
- - - -
-
- ... -
- -
-
-
-
-
diff --git a/templates/nostrmarket/components/merchant-tab.html b/templates/nostrmarket/components/merchant-tab.html index 41caca1..3d932d2 100644 --- a/templates/nostrmarket/components/merchant-tab.html +++ b/templates/nostrmarket/components/merchant-tab.html @@ -1,56 +1,277 @@
-
-
- -
-
-
- - + + +
+
+ Merchant Profile +
-
-
-
-
-
+
Hide KeysEdit + + Show Keys + + + + Saved Profiles + + + + + + + + + + + + + Remove profile + + + + + + + + + + Import Existing Key + Use an existing nsec + + + + + + + + Generate New Key + Create a fresh nsec + + + + + + + + + + + + Accepting Orders + Orders Paused + + New orders will be processed + + + New orders will be ignored + + + + + + + + + + Pause Orders + Resume Orders + + Stop accepting new orders + + + Start accepting new orders + + + + +
+ + + +
+ +
-
-
- + + + +
+ +
+ + + + +
+ + +
+
+
+
+ +
+
+ (No display name set) +
+
+ +
+
+
+ 0 Following + Not implemented yet +
+
+ 0 Followers + Not implemented yet +
+
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + + New Post (Coming soon) +
-
+ + + +
+ +
Coming Soon
+
+ Merchant posts will appear here +
+
+
+
+ + + + + +
diff --git a/templates/nostrmarket/components/nostr-keys-dialog.html b/templates/nostrmarket/components/nostr-keys-dialog.html new file mode 100644 index 0000000..dde7069 --- /dev/null +++ b/templates/nostrmarket/components/nostr-keys-dialog.html @@ -0,0 +1,75 @@ + + + +
Nostr Keys
+
+ + +
+ +
+ + +
+ + Public Key (npub) +
+ + + + + +
+ + Private Key (nsec) +
+ + + +
+ + Never share your private key! +
+
+ + + +
+
diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index c3ec16d..949f8fe 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -12,6 +12,12 @@ indicator-color="primary" align="left" > + + + + +
Orders
+
+ + + + +
+ @@ -95,21 +122,6 @@
- - -
Orders
-
- - - - -
@@ -410,6 +422,63 @@
+ + + + +
Generate New Key
+
+
Public Key (npub)
+ + + +
+
+
+ + Private Key (nsec) +
+ + + +
+ + Never share your private key! +
+
+
+ Create Merchant + Cancel +
+
+
{% endblock%}{% block scripts %} {{ window_vars(user) }} @@ -427,10 +496,23 @@ margin-left: auto; width: 100%; } + + .profile-avatar { + border: 3px solid var(--q-dark-page); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + + .profile-avatar .q-avatar__content { + overflow: hidden; + border-radius: 50%; + } -{% include("nostrmarket/components/nostr-keys-dialog.html") %} + - + + diff --git a/views_api.py b/views_api.py index af675cd..7bf3574 100644 --- a/views_api.py +++ b/views_api.py @@ -1,15 +1,18 @@ import json from http import HTTPStatus +from typing import List, Optional from fastapi import Depends from fastapi.exceptions import HTTPException -from lnbits.core.models import WalletTypeInfo +from lnbits.core.crud import get_account, update_account from lnbits.core.services import websocket_updater from lnbits.decorators import ( + WalletTypeInfo, require_admin_key, require_invoice_key, ) from lnbits.utils.exchange_rates import currencies +from lnbits.utils.nostr import generate_keypair from loguru import logger from . import nostr_client, nostrmarket_ext @@ -58,10 +61,12 @@ from .crud import ( ) from .helpers import normalize_public_key from .models import ( + CreateMerchantRequest, Customer, DirectMessage, DirectMessageType, Merchant, + MerchantConfig, Order, OrderReissue, OrderStatusUpdate, @@ -89,18 +94,48 @@ from .services import ( @nostrmarket_ext.post("/api/v1/merchant") async def api_create_merchant( - data: PartialMerchant, + data: CreateMerchantRequest, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Merchant: try: - merchant = await get_merchant_by_pubkey(data.public_key) - assert merchant is None, "A merchant already uses this public key" - + # Check if merchant already exists for this user merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant is None, "A merchant already exists for this user" - merchant = await create_merchant(wallet.wallet.user, data) + # Get user's account to access their Nostr keypairs + account = await get_account(wallet.wallet.user) + if not account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="User account not found", + ) + + # Check if user has Nostr keypairs, generate them if not + if not account.pubkey or not account.prvkey: + # Generate new keypair for user + private_key, public_key = generate_keypair() + + # Update user account with new keypairs + account.pubkey = public_key + account.prvkey = private_key + await update_account(account) + else: + public_key = account.pubkey + private_key = account.prvkey + + # Check if another merchant is already using this public key + existing_merchant = await get_merchant_by_pubkey(public_key) + assert existing_merchant is None, "A merchant already uses this public key" + + # Create PartialMerchant with user's keypairs + partial_merchant = PartialMerchant( + private_key=private_key, + public_key=public_key, + config=data.config + ) + + merchant = await create_merchant(wallet.wallet.user, partial_merchant) await create_zone( merchant.id, @@ -115,7 +150,7 @@ async def api_create_merchant( await resubscribe_to_all_merchants() - await nostr_client.merchant_temp_subscription(data.public_key) + await nostr_client.merchant_temp_subscription(public_key) return merchant except AssertionError as ex: @@ -134,7 +169,7 @@ async def api_create_merchant( @nostrmarket_ext.get("/api/v1/merchant") async def api_get_merchant( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Merchant | None: +) -> Optional[Merchant]: try: merchant = await get_merchant_for_user(wallet.wallet.user) @@ -192,6 +227,35 @@ async def api_delete_merchant( await subscribe_to_all_merchants() +@nostrmarket_ext.patch("/api/v1/merchant/{merchant_id}") +async def api_update_merchant( + merchant_id: str, + config: MerchantConfig, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + try: + merchant = await get_merchant_for_user(wallet.wallet.user) + assert merchant, "Merchant cannot be found" + assert merchant.id == merchant_id, "Wrong merchant ID" + + updated_merchant = await update_merchant( + wallet.wallet.user, merchant_id, config + ) + return updated_merchant + + except AssertionError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) from ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot update merchant", + ) from ex + + @nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/nostr") async def api_republish_merchant( merchant_id: str, @@ -302,7 +366,7 @@ async def api_delete_merchant_on_nostr( @nostrmarket_ext.get("/api/v1/zone") async def api_get_zones( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> list[Zone]: +) -> List[Zone]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -502,7 +566,7 @@ async def api_get_stall( @nostrmarket_ext.get("/api/v1/stall") async def api_get_stalls( - pending: bool | None = False, + pending: Optional[bool] = False, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -526,7 +590,7 @@ async def api_get_stalls( @nostrmarket_ext.get("/api/v1/stall/product/{stall_id}") async def api_get_stall_products( stall_id: str, - pending: bool | None = False, + pending: Optional[bool] = False, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -550,9 +614,9 @@ async def api_get_stall_products( @nostrmarket_ext.get("/api/v1/stall/order/{stall_id}") async def api_get_stall_orders( stall_id: str, - paid: bool | None = None, - shipped: bool | None = None, - pubkey: str | None = None, + paid: Optional[bool] = None, + shipped: Optional[bool] = None, + pubkey: Optional[str] = None, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -686,7 +750,7 @@ async def api_update_product( async def api_get_product( product_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Product | None: +) -> Optional[Product]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -771,9 +835,9 @@ async def api_get_order( @nostrmarket_ext.get("/api/v1/order") async def api_get_orders( - paid: bool | None = None, - shipped: bool | None = None, - pubkey: str | None = None, + paid: Optional[bool] = None, + shipped: Optional[bool] = None, + pubkey: Optional[str] = None, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -859,7 +923,7 @@ async def api_update_order_status( async def api_restore_order( event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), -) -> Order | None: +) -> Optional[Order]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -986,7 +1050,7 @@ async def api_reissue_order_invoice( @nostrmarket_ext.get("/api/v1/message/{public_key}") async def api_get_messages( public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key) -) -> list[DirectMessage]: +) -> List[DirectMessage]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -1042,7 +1106,7 @@ async def api_create_message( @nostrmarket_ext.get("/api/v1/customer") async def api_get_customers( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> list[Customer]: +) -> List[Customer]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found"