diff --git a/__init__.py b/__init__.py index 7e80766..bac1155 100644 --- a/__init__.py +++ b/__init__.py @@ -46,7 +46,7 @@ def nostrmarket_start(): async def _wait_for_nostr_events(): # wait for this extension to initialize - await asyncio.sleep(5) + await asyncio.sleep(15) await wait_for_nostr_events(nostr_client) loop = asyncio.get_event_loop() diff --git a/crud.py b/crud.py index 2029a2e..3be6bf6 100644 --- a/crud.py +++ b/crud.py @@ -5,6 +5,8 @@ from lnbits.helpers import urlsafe_short_hash from . import db from .models import ( + Customer, + CustomerProfile, DirectMessage, Merchant, MerchantConfig, @@ -391,12 +393,13 @@ async def create_order(merchant_id: str, o: Order) -> Order: address, contact_data, extra_data, - order_items, + order_items, + shipping_id, stall_id, invoice_id, total ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(event_id) DO NOTHING """, ( @@ -410,6 +413,7 @@ async def create_order(merchant_id: str, o: Order) -> Order: json.dumps(o.contact.dict() if o.contact else {}), json.dumps(o.extra.dict()), json.dumps([i.dict() for i in o.items]), + o.shipping_id, o.stall_id, o.invoice_id, o.total, @@ -443,33 +447,38 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Ord return Order.from_row(row) if row else None -async def get_orders(merchant_id: str) -> List[Order]: +async def get_orders(merchant_id: str, **kwargs) -> List[Order]: + q = " AND ".join( + [f"{field[0]} = ?" for field in kwargs.items() if field[1] != None] + ) + values = () + if q: + q = f"AND {q}" + values = (v for v in kwargs.values() if v != None) rows = await db.fetchall( - "SELECT * FROM nostrmarket.orders WHERE merchant_id = ? ORDER BY time DESC", - (merchant_id,), + f"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? {q} ORDER BY time DESC", + (merchant_id, *values), ) return [Order.from_row(row) for row in rows] -async def get_orders_for_stall(merchant_id: str, stall_id: str) -> List[Order]: +async def get_orders_for_stall( + merchant_id: str, stall_id: str, **kwargs +) -> List[Order]: + q = " AND ".join( + [f"{field[0]} = ?" for field in kwargs.items() if field[1] != None] + ) + values = () + if q: + q = f"AND {q}" + values = (v for v in kwargs.values() if v != None) rows = await db.fetchall( - "SELECT * FROM nostrmarket.orders WHERE merchant_id = ? AND stall_id = ? ORDER BY time DESC", - ( - merchant_id, - stall_id, - ), + f"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? AND stall_id = ? {q} ORDER BY time DESC", + (merchant_id, stall_id, *values), ) return [Order.from_row(row) for row in rows] -async def get_public_keys_for_orders(merchant_id: str) -> List[str]: - rows = await db.fetchall( - "SELECT DISTINCT public_key FROM nostrmarket.orders WHERE merchant_id = ?", - (merchant_id,), - ) - return [row[0] for row in rows] - - async def get_last_order_time(public_key: str) -> int: row = await db.fetchone( """ @@ -596,9 +605,68 @@ async def delete_merchant_direct_messages(merchant_id: str) -> None: ) -async def get_public_keys_for_direct_messages(merchant_id: str) -> List[str]: - rows = await db.fetchall( - "SELECT DISTINCT public_key FROM nostrmarket.direct_messages WHERE merchant_id = ?", - (merchant_id), +######################################## CUSTOMERS ######################################## + + +async def create_customer(merchant_id: str, data: Customer) -> Customer: + await db.execute( + f""" + INSERT INTO nostrmarket.customers (merchant_id, public_key, meta) + VALUES (?, ?, ?) + """, + ( + merchant_id, + data.public_key, + json.dumps(data.profile) if data.profile else "{}", + ), + ) + + customer = await get_customer(merchant_id, data.public_key) + assert customer, "Newly created customer couldn't be retrieved" + return customer + + +async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]: + row = await db.fetchone( + "SELECT * FROM nostrmarket.customers WHERE merchant_id = ? AND public_key = ?", + ( + merchant_id, + public_key, + ), + ) + return Customer.from_row(row) if row else None + + +async def get_customers(merchant_id: str) -> List[Customer]: + rows = await db.fetchall( + "SELECT * FROM nostrmarket.customers WHERE merchant_id = ?", (merchant_id,) + ) + return [Customer.from_row(row) for row in rows] + + +async def get_all_customers() -> List[Customer]: + rows = await db.fetchall("SELECT * FROM nostrmarket.customers") + return [Customer.from_row(row) for row in rows] + + +async def update_customer_profile( + public_key: str, event_created_at: int, profile: CustomerProfile +): + await db.execute( + f"UPDATE nostrmarket.customers SET event_created_at = ?, meta = ? WHERE public_key = ?", + (event_created_at, json.dumps(profile.dict()), public_key), + ) + + +async def increment_customer_unread_messages(public_key: str): + await db.execute( + f"UPDATE nostrmarket.customers SET unread_messages = unread_messages + 1 WHERE public_key = ?", + (public_key,), + ) + + +async def update_customer_no_unread_messages(public_key: str): + await db.execute( + f"UPDATE nostrmarket.customers SET unread_messages = 0 WHERE public_key = ?", + (public_key,), ) - return [row[0] for row in rows] diff --git a/helpers.py b/helpers.py index a81f5a5..d3c9f93 100644 --- a/helpers.py +++ b/helpers.py @@ -77,8 +77,6 @@ def copy_x(output, x32, y32, data): def order_from_json(s: str) -> Tuple[Optional[Any], Optional[str]]: try: order = json.loads(s) - return ( - (order, s) if (type(order) is dict) and "items" in order else (None, s) - ) + return (order, s) if (type(order) is dict) and "items" in order else (None, s) except ValueError: return None, s diff --git a/migrations.py b/migrations.py index 530f12e..f928098 100644 --- a/migrations.py +++ b/migrations.py @@ -86,6 +86,7 @@ async def m001_initial(db): order_items TEXT NOT NULL, address TEXT, total REAL NOT NULL, + shipping_id TEXT NOT NULL, stall_id TEXT NOT NULL, invoice_id TEXT NOT NULL, paid BOOLEAN NOT NULL DEFAULT false, @@ -125,3 +126,18 @@ async def m001_initial(db): await db.execute( "CREATE INDEX idx_event_id ON nostrmarket.direct_messages (event_id)" ) + + """ + Initial customers table. + """ + await db.execute( + """ + CREATE TABLE nostrmarket.customers ( + merchant_id TEXT NOT NULL, + public_key TEXT NOT NULL, + event_created_at INTEGER, + unread_messages INTEGER NOT NULL DEFAULT 1, + meta TEXT NOT NULL DEFAULT '{}' + ); + """ + ) diff --git a/models.py b/models.py index 29d91e9..47bc91b 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,7 @@ import json import time from abc import abstractmethod from sqlite3 import Row -from typing import List, Optional +from typing import List, Optional, Tuple from pydantic import BaseModel @@ -313,6 +313,8 @@ class OrderExtra(BaseModel): products: List[ProductOverview] currency: str btc_price: str + shipping_cost: float = 0 + shipping_cost_sat: float = 0 @classmethod async def from_products(cls, products: List[Product]): @@ -329,6 +331,7 @@ class PartialOrder(BaseModel): event_created_at: Optional[int] public_key: str merchant_public_key: str + shipping_id: str items: List[OrderItem] contact: Optional[OrderContact] address: Optional[str] @@ -356,20 +359,25 @@ class PartialOrder(BaseModel): f"Order ({self.id}) has products from different stalls" ) - async def total_sats(self, products: List[Product]) -> float: + async def costs_in_sats( + self, products: List[Product], shipping_cost: float + ) -> Tuple[float, float]: product_prices = {} for p in products: product_prices[p.id] = p - amount: float = 0 # todo + product_cost: float = 0 # todo for item in self.items: price = product_prices[item.product_id].price currency = product_prices[item.product_id].config.currency or "sat" if currency != "sat": price = await fiat_amount_as_satoshis(price, currency) - amount += item.quantity * price + product_cost += item.quantity * price - return amount + if currency != "sat": + shipping_cost = await fiat_amount_as_satoshis(shipping_cost, currency) + + return product_cost, shipping_cost class Order(PartialOrder): @@ -427,3 +435,25 @@ class DirectMessage(PartialDirectMessage): def from_row(cls, row: Row) -> "DirectMessage": dm = cls(**dict(row)) return dm + + +######################################## CUSTOMERS ######################################## + + +class CustomerProfile(BaseModel): + name: Optional[str] + about: Optional[str] + + +class Customer(BaseModel): + merchant_id: str + public_key: str + event_created_at: Optional[int] + profile: Optional[CustomerProfile] + unread_messages: int = 0 + + @classmethod + def from_row(cls, row: Row) -> "Customer": + customer = cls(**dict(row)) + customer.profile = CustomerProfile(**json.loads(row["meta"])) + return customer diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index a2a936c..965c827 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -70,7 +70,7 @@ class NostrClient: async def subscribe_to_direct_messages(self, public_key: str, since: int): in_messages_filter = {"kind": 4, "#p": [public_key]} out_messages_filter = {"kind": 4, "authors": [public_key]} - if since != 0: + if since and since != 0: in_messages_filter["since"] = since out_messages_filter["since"] = since @@ -92,6 +92,15 @@ class NostrClient: ["REQ", f"product-events:{public_key}", product_filter] ) + async def subscribe_to_user_profile(self, public_key: str, since: int): + profile_filter = {"kind": 0, "authors": [public_key]} + if since and since != 0: + profile_filter["since"] = since + 1 + + await self.send_req_queue.put( + ["REQ", f"user-profile-events:{public_key}", profile_filter] + ) + async def unsubscribe_from_direct_messages(self, public_key: str): await self.send_req_queue.put(["CLOSE", f"direct-messages-in:{public_key}"]) await self.send_req_queue.put(["CLOSE", f"direct-messages-out:{public_key}"]) diff --git a/services.py b/services.py index 288ed43..827960b 100644 --- a/services.py +++ b/services.py @@ -4,11 +4,15 @@ from typing import List, Optional, Tuple from loguru import logger from lnbits.core import create_invoice, get_wallet +from lnbits.core.services import websocketUpdater from . import nostr_client from .crud import ( + CustomerProfile, + create_customer, create_direct_message, create_order, + get_customer, get_merchant_by_pubkey, get_order, get_order_by_event_id, @@ -16,6 +20,9 @@ from .crud import ( get_products_by_ids, get_stalls, get_wallet_for_product, + get_zone, + increment_customer_unread_messages, + update_customer_profile, update_order_paid_status, update_product, update_product_quantity, @@ -23,6 +30,7 @@ from .crud import ( ) from .helpers import order_from_json from .models import ( + Customer, Merchant, Nostrable, Order, @@ -53,8 +61,12 @@ async def create_new_order( 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}'" - total_amount = await data.total_sats(products) + product_cost_sat, shipping_cost_sat = await data.costs_in_sats( + products, shipping_zone.cost + ) wallet_id = await get_wallet_for_product(data.items[0].product_id) assert wallet_id, "Missing wallet for order `{data.id}`" @@ -68,7 +80,7 @@ async def create_new_order( payment_hash, invoice = await create_invoice( wallet_id=wallet_id, - amount=round(total_amount), + amount=round(product_cost_sat + shipping_cost_sat), memo=f"Order '{data.id}' for pubkey '{data.public_key}'", extra={ "tag": "nostrmarket", @@ -77,14 +89,29 @@ async def create_new_order( }, ) + extra = await OrderExtra.from_products(products) + extra.shipping_cost_sat = shipping_cost_sat + extra.shipping_cost = shipping_zone.cost + order = Order( **data.dict(), stall_id=products[0].stall_id, invoice_id=payment_hash, - total=total_amount, - extra=await OrderExtra.from_products(products), + total=product_cost_sat + shipping_cost_sat, + extra=extra, ) await create_order(merchant.id, order) + await websocketUpdater( + merchant.id, + json.dumps( + { + "type": "new-order", + "stallId": products[0].stall_id, + "customerPubkey": data.public_key, + "orderId": order.id, + } + ), + ) return PaymentRequest( id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)] @@ -206,9 +233,11 @@ async def process_nostr_message(msg: str): type, *rest = json.loads(msg) if type.upper() == "EVENT": subscription_id, event = rest - _, merchant_public_key = subscription_id.split(":") event = NostrEvent(**event) + if event.kind == 0: + await _handle_customer_profile_update(event) if event.kind == 4: + _, merchant_public_key = subscription_id.split(":") await _handle_nip04_message(merchant_public_key, event) return except Exception as ex: @@ -235,7 +264,13 @@ async def _handle_nip04_message(merchant_public_key: str, event: NostrEvent): async def _handle_incoming_dms( event: NostrEvent, merchant: Merchant, clear_text_msg: str ): - dm_content = await _handle_dirrect_message( + customer = await get_customer(merchant.id, event.pubkey) + if not customer: + await _handle_new_customer(event, merchant) + else: + await increment_customer_unread_messages(event.pubkey) + + dm_reply = await _handle_dirrect_message( merchant.id, merchant.public_key, event.pubkey, @@ -243,8 +278,8 @@ async def _handle_incoming_dms( event.created_at, clear_text_msg, ) - if dm_content: - dm_event = merchant.build_dm_event(dm_content, event.pubkey) + if dm_reply: + dm_event = merchant.build_dm_event(dm_reply, event.pubkey) await nostr_client.publish_nostr_event(dm_event) @@ -287,6 +322,11 @@ async def _handle_dirrect_message( order["event_created_at"] = event_created_at return await _handle_new_order(PartialOrder(**order)) + await websocketUpdater( + merchant_id, + json.dumps({"type": "new-direct-message", "customerPubkey": from_pubkey}), + ) + return None except Exception as ex: logger.warning(ex) @@ -308,3 +348,29 @@ async def _handle_new_order(order: PartialOrder) -> Optional[str]: return json.dumps(new_order.dict(), separators=(",", ":"), ensure_ascii=False) return None + + +async def _handle_new_customer(event, merchant): + await create_customer( + merchant.id, Customer(merchant_id=merchant.id, public_key=event.pubkey) + ) + await nostr_client.subscribe_to_user_profile(event.pubkey, 0) + await websocketUpdater( + merchant.id, + json.dumps({"type": "new-customer"}), + ) + + +async def _handle_customer_profile_update(event: NostrEvent): + try: + profile = json.loads(event.content) + await update_customer_profile( + event.pubkey, + event.created_at, + CustomerProfile( + name=profile["name"] if "name" in profile else "", + about=profile["about"] if "about" in profile else "", + ), + ) + except Exception as ex: + logger.warning(ex) diff --git a/static/components/customer-stall/customer-stall.js b/static/components/customer-stall/customer-stall.js index 6c054a2..b185d83 100644 --- a/static/components/customer-stall/customer-stall.js +++ b/static/components/customer-stall/customer-stall.js @@ -185,7 +185,7 @@ async function customerStall(path) { items: Array.from(this.cart.products, p => { return {product_id: p[0], quantity: p[1].quantity} }), - shipping: orderData.shippingzone + shipping_id: orderData.shippingzone } orderObj.id = await hash( [orderData.pubkey, created_at, JSON.stringify(orderObj)].join(':') @@ -269,7 +269,7 @@ async function customerStall(path) { items: Array.from(this.cart.products, p => { return {product_id: p[0], quantity: p[1].quantity} }), - shipping: orderData.shippingzone + shipping_id: orderData.shippingzone } let created_at = Math.floor(Date.now() / 1000) orderObj.id = await hash( @@ -375,8 +375,9 @@ async function customerStall(path) { this.qrCodeDialog.data.message = json.message return cb() } - let payment_request = json.payment_options.find(o => o.type == 'ln') - .link + let payment_request = json.payment_options.find( + o => o.type == 'ln' + ).link if (!payment_request) return this.loading = false this.qrCodeDialog.data.payment_request = payment_request diff --git a/static/components/direct-messages/direct-messages.html b/static/components/direct-messages/direct-messages.html index 43a81ac..ab7eee1 100644 --- a/static/components/direct-messages/direct-messages.html +++ b/static/components/direct-messages/direct-messages.html @@ -1,7 +1,26 @@