diff --git a/__init__.py b/__init__.py index 9e188f2..b4f7af0 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,5 @@ import asyncio +from asyncio import Queue, Task from typing import List from fastapi import APIRouter @@ -25,15 +26,29 @@ def nostrmarket_renderer(): return template_renderer(["lnbits/extensions/nostrmarket/templates"]) -scheduled_tasks: List[asyncio.Task] = [] +recieve_event_queue: Queue = Queue() +send_req_queue: Queue = Queue() +scheduled_tasks: List[Task] = [] -from .tasks import subscribe_nostrclient_ws, wait_for_paid_invoices + +from .tasks import ( + subscribe_to_nostr_client, + wait_for_nostr_events, + wait_for_paid_invoices, +) from .views import * # noqa from .views_api import * # noqa def nostrmarket_start(): + async def _subscribe_to_nostr_client(): + await subscribe_to_nostr_client(recieve_event_queue, send_req_queue) + + async def _wait_for_nostr_events(): + await wait_for_nostr_events(recieve_event_queue, send_req_queue) + loop = asyncio.get_event_loop() task1 = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) - task2 = loop.create_task(catch_everything_and_restart(subscribe_nostrclient_ws)) - scheduled_tasks.append([task1, task2]) + task2 = loop.create_task(catch_everything_and_restart(_subscribe_to_nostr_client)) + task3 = loop.create_task(catch_everything_and_restart(_wait_for_nostr_events)) + scheduled_tasks.append([task1, task2, task3]) diff --git a/crud.py b/crud.py index 990b9ad..2e6013b 100644 --- a/crud.py +++ b/crud.py @@ -1,5 +1,4 @@ import json -import time from typing import List, Optional from lnbits.helpers import urlsafe_short_hash @@ -7,6 +6,7 @@ from lnbits.helpers import urlsafe_short_hash from . import db from .models import ( Merchant, + Order, PartialMerchant, PartialProduct, PartialStall, @@ -45,6 +45,23 @@ async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: return Merchant.from_row(row) if row else None +async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]: + row = await db.fetchone( + """SELECT * FROM nostrmarket.merchants WHERE public_key = ? """, + (public_key,), + ) + + return Merchant.from_row(row) if row else None + + +async def get_public_keys_for_merchants() -> List[str]: + rows = await db.fetchall( + """SELECT public_key FROM nostrmarket.merchants""", + ) + + return [row[0] for row in rows] + + async def get_merchant_for_user(user_id: str) -> Optional[Merchant]: row = await db.fetchone( """SELECT * FROM nostrmarket.merchants WHERE user_id = ? """, @@ -189,7 +206,7 @@ async def delete_stall(user_id: str, stall_id: str) -> None: ) -######################################## STALL ######################################## +######################################## PRODUCTS ######################################## async def create_product(user_id: str, data: PartialProduct) -> Product: @@ -197,7 +214,7 @@ async def create_product(user_id: str, data: PartialProduct) -> Product: await db.execute( f""" - INSERT INTO nostrmarket.products (user_id, id, stall_id, name, images, price, quantity, category_list, meta) + INSERT INTO nostrmarket.products (user_id, id, stall_id, name, image, price, quantity, category_list, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( @@ -261,6 +278,28 @@ async def get_products(user_id: str, stall_id: str) -> List[Product]: return [Product.from_row(row) for row in rows] +async def get_products_by_ids(user_id: str, product_ids: List[str]) -> List[Product]: + q = ",".join(["?"] * len(product_ids)) + rows = await db.fetchall( + f"SELECT id, stall_id, name, price, quantity, category_list, meta FROM nostrmarket.products WHERE user_id = ? AND id IN ({q})", + (user_id, *product_ids), + ) + return [Product.from_row(row) for row in rows] + + +async def get_wallet_for_product(product_id: str) -> Optional[str]: + row = await db.fetchone( + """ + SELECT s.wallet FROM nostrmarket.products p + INNER JOIN nostrmarket.stalls s + ON p.stall_id = s.id + WHERE p.id=? + """, + (product_id,), + ) + return row[0] if row else None + + async def delete_product(user_id: str, product_id: str) -> None: await db.execute( "DELETE FROM nostrmarket.products WHERE user_id =? AND id = ?", @@ -269,3 +308,100 @@ async def delete_product(user_id: str, product_id: str) -> None: product_id, ), ) + + +######################################## ORDERS ######################################## + + +async def create_order(user_id: str, o: Order) -> Order: + await db.execute( + f""" + INSERT INTO nostrmarket.orders (user_id, id, event_id, pubkey, address, contact_data, extra_data, order_items, stall_id, invoice_id, total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + o.id, + o.event_id, + o.pubkey, + o.address, + json.dumps(o.contact.dict() if o.contact else {}), + json.dumps(o.extra.dict()), + json.dumps([i.dict() for i in o.items]), + o.stall_id, + o.invoice_id, + o.total, + ), + ) + order = await get_order(user_id, o.id) + assert order, "Newly created order couldn't be retrieved" + + return order + + +async def get_order(user_id: str, order_id: str) -> Optional[Order]: + row = await db.fetchone( + "SELECT * FROM nostrmarket.orders WHERE user_id =? AND id = ?", + ( + user_id, + order_id, + ), + ) + return Order.from_row(row) if row else None + + +async def get_order_by_event_id(user_id: str, event_id: str) -> Optional[Order]: + row = await db.fetchone( + "SELECT * FROM nostrmarket.orders WHERE user_id =? AND event_id =?", + ( + user_id, + event_id, + ), + ) + return Order.from_row(row) if row else None + + +async def get_orders(user_id: str) -> List[Order]: + rows = await db.fetchall( + "SELECT * FROM nostrmarket.orders WHERE user_id = ? ORDER BY time DESC", + (user_id,), + ) + return [Order.from_row(row) for row in rows] + + +async def get_orders_for_stall(user_id: str, stall_id: str) -> List[Order]: + rows = await db.fetchall( + "SELECT * FROM nostrmarket.orders WHERE user_id = ? AND stall_id = ? ORDER BY time DESC", + ( + user_id, + stall_id, + ), + ) + return [Order.from_row(row) for row in rows] + + +async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]: + await db.execute( + f"UPDATE nostrmarket.orders SET paid = ? WHERE id = ?", + (paid, order_id), + ) + row = await db.fetchone( + "SELECT * FROM nostrmarket.orders WHERE id = ?", + (order_id,), + ) + return Order.from_row(row) if row else None + + +async def update_order_shipped_status( + user_id: str, order_id: str, shipped: bool +) -> Optional[Order]: + await db.execute( + f"UPDATE nostrmarket.orders SET shipped = ? WHERE user_id = ? AND id = ?", + (shipped, user_id, order_id), + ) + + row = await db.fetchone( + "SELECT * FROM nostrmarket.orders WHERE id = ?", + (order_id,), + ) + return Order.from_row(row) if row else None diff --git a/helpers.py b/helpers.py index 8747c48..ee73cd9 100644 --- a/helpers.py +++ b/helpers.py @@ -1,7 +1,7 @@ import base64 import json import secrets -from typing import Optional +from typing import Any, Optional, Tuple import secp256k1 from cffi import FFI @@ -31,7 +31,7 @@ def decrypt_message(encoded_message: str, encryption_key) -> str: return unpadded_data.decode() -def encrypt_message(message: str, encryption_key, iv: Optional[bytes]) -> 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() @@ -73,9 +73,11 @@ def copy_x(output, x32, y32, data): return 1 -def is_json(string: str): +def order_from_json(s: str) -> Tuple[Optional[Any], Optional[str]]: try: - json.loads(string) - except ValueError as e: - return False - return True + order = json.loads(s) + return ( + (order, None) 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 680c3cc..f006d36 100644 --- a/migrations.py +++ b/migrations.py @@ -71,39 +71,28 @@ async def m001_initial(db): """ Initial orders table. """ + empty_object = "{}" await db.execute( f""" CREATE TABLE nostrmarket.orders ( + user_id TEXT NOT NULL, id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - username TEXT, - pubkey TEXT, - shipping_zone TEXT NOT NULL, + event_id TEXT, + pubkey TEXT NOT NULL, + contact_data TEXT NOT NULL DEFAULT '{empty_object}', + extra_data TEXT NOT NULL DEFAULT '{empty_object}', + order_items TEXT NOT NULL, address TEXT, - email TEXT, total REAL NOT NULL, + stall_id TEXT NOT NULL, invoice_id TEXT NOT NULL, - paid BOOLEAN NOT NULL, - shipped BOOLEAN NOT NULL, + paid BOOLEAN NOT NULL DEFAULT false, + shipped BOOLEAN NOT NULL DEFAULT false, time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); """ ) - """ - Initial order details table. - """ - await db.execute( - f""" - CREATE TABLE nostrmarket.order_details ( - id TEXT PRIMARY KEY, - order_id TEXT NOT NULL, - product_id TEXT NOT NULL, - quantity INTEGER NOT NULL - ); - """ - ) - """ Initial market table. """ @@ -117,19 +106,6 @@ async def m001_initial(db): """ ) - """ - Initial market stalls table. - """ - await db.execute( - f""" - CREATE TABLE nostrmarket.market_stalls ( - id TEXT PRIMARY KEY, - market_id TEXT NOT NULL, - stall_id TEXT NOT NULL - ); - """ - ) - """ Initial chat messages table. """ diff --git a/models.py b/models.py index 194cf04..f228ed5 100644 --- a/models.py +++ b/models.py @@ -6,7 +6,14 @@ from typing import List, Optional from pydantic import BaseModel -from .helpers import sign_message_hash +from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis + +from .helpers import ( + decrypt_message, + encrypt_message, + get_shared_secret, + sign_message_hash, +) from .nostr.event import NostrEvent ######################################## NOSTR ######################################## @@ -39,6 +46,28 @@ class Merchant(PartialMerchant): def sign_hash(self, hash: bytes) -> str: return sign_message_hash(self.private_key, hash) + def decrypt_message(self, encrypted_message: str, public_key: str) -> str: + encryption_key = get_shared_secret(self.private_key, public_key) + return decrypt_message(encrypted_message, encryption_key) + + def encrypt_message(self, clear_text_message: str, public_key: str) -> str: + encryption_key = get_shared_secret(self.private_key, public_key) + return encrypt_message(clear_text_message, encryption_key) + + def build_dm_event(self, message: str, to_pubkey: str) -> NostrEvent: + content = self.encrypt_message(message, to_pubkey) + event = NostrEvent( + pubkey=self.public_key, + created_at=round(time.time()), + kind=4, + tags=[["p", to_pubkey]], + content=content, + ) + event.id = event.event_id + event.sig = self.sign_hash(bytes.fromhex(event.id)) + + return event + @classmethod def from_row(cls, row: Row) -> "Merchant": merchant = cls(**dict(row)) @@ -213,3 +242,120 @@ class Product(PartialProduct, Nostrable): product.config = ProductConfig(**json.loads(row["meta"])) product.categories = json.loads(row["category_list"]) return product + + +class ProductOverview(BaseModel): + id: str + name: str + price: float + + +######################################## ORDERS ######################################## + + +class OrderItem(BaseModel): + product_id: str + quantity: int + + +class OrderContact(BaseModel): + nostr: Optional[str] + phone: Optional[str] + email: Optional[str] + + +class OrderExtra(BaseModel): + products: List[ProductOverview] + currency: str + btc_price: str + + @classmethod + async def from_products(cls, products: List[Product]): + currency = products[0].config.currency + exchange_rate = ( + (await btc_price(currency)) if currency and currency != "sat" else 1 + ) + return OrderExtra(products=products, currency=currency, btc_price=exchange_rate) + + +class PartialOrder(BaseModel): + id: str + event_id: Optional[str] + pubkey: str + items: List[OrderItem] + contact: Optional[OrderContact] + address: Optional[str] + + 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]): + assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" + assert ( + len(product_list) != 0 + ), f"No products found for order. Order: '{self.id}'" + + product_ids = [p.id for p in product_list] + for item in self.items: + if item.product_id not in product_ids: + raise ValueError( + f"Order ({self.id}) item product does not exist: {item.product_id}" + ) + + stall_id = product_list[0].stall_id + for p in product_list: + if p.stall_id != stall_id: + raise ValueError( + f"Order ({self.id}) has products from different stalls" + ) + + async def total_sats(self, products: List[Product]) -> float: + product_prices = {} + for p in products: + product_prices[p.id] = p + + amount: 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 + + return amount + + +class Order(PartialOrder): + stall_id: str + invoice_id: str + total: float + paid: bool = False + shipped: bool = False + time: Optional[int] + extra: OrderExtra + + @classmethod + def from_row(cls, row: Row) -> "Order": + contact = OrderContact(**json.loads(row["contact_data"])) + extra = OrderExtra(**json.loads(row["extra_data"])) + items = [OrderItem(**z) for z in json.loads(row["order_items"])] + order = cls(**dict(row), contact=contact, items=items, extra=extra) + return order + + +class OrderStatusUpdate(BaseModel): + id: str + message: Optional[str] + paid: Optional[bool] + shipped: Optional[bool] + + +class PaymentOption(BaseModel): + type: str + link: str + + +class PaymentRequest(BaseModel): + id: str + message: Optional[str] + payment_options: List[PaymentOption] diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 306c123..3e8a47e 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -1,5 +1,9 @@ +from threading import Thread +from typing import Callable + import httpx from loguru import logger +from websocket import WebSocketApp from lnbits.app import settings from lnbits.helpers import url_for @@ -10,7 +14,7 @@ from .event import NostrEvent async def publish_nostr_event(e: NostrEvent): url = url_for("/nostrclient/api/v1/publish", external=True) data = dict(e) - # print("### published", dict(data)) + print("### published", dict(data)) async with httpx.AsyncClient() as client: try: await client.post( @@ -19,3 +23,44 @@ async def publish_nostr_event(e: NostrEvent): ) except Exception as ex: logger.warning(ex) + + +async def connect_to_nostrclient_ws( + on_open: Callable, on_message: Callable +) -> WebSocketApp: + def on_error(_, error): + logger.warning(error) + + logger.debug(f"Subscribing to websockets for nostrclient extension") + ws = WebSocketApp( + f"ws://localhost:{settings.port}/nostrclient/api/v1/filters", + on_message=on_message, + on_open=on_open, + on_error=on_error, + ) + + wst = Thread(target=ws.run_forever) + wst.daemon = True + wst.start() + + return ws + + +# async def handle_event(event, pubkeys): +# tags = [t[1] for t in event["tags"] if t[0] == "p"] +# to_merchant = None +# if tags and len(tags) > 0: +# to_merchant = tags[0] + +# if event["pubkey"] in pubkeys or to_merchant in pubkeys: +# logger.debug(f"Event sent to {to_merchant}") +# pubkey = to_merchant if to_merchant in pubkeys else event["pubkey"] +# # Send event to market extension +# await send_event_to_market(event=event, pubkey=pubkey) + + +# async def send_event_to_market(event: dict, pubkey: str): +# # Sends event to market extension, for decrypt and handling +# market_url = url_for(f"/market/api/v1/nip04/{pubkey}", external=True) +# async with httpx.AsyncClient() as client: +# await client.post(url=market_url, json=event) diff --git a/static/components/order-list/order-list.html b/static/components/order-list/order-list.html new file mode 100644 index 0000000..e629b1f --- /dev/null +++ b/static/components/order-list/order-list.html @@ -0,0 +1,205 @@ +
+ + + + + + + + + +
+ + + Cancel +
+
+
+
+
diff --git a/static/components/order-list/order-list.js b/static/components/order-list/order-list.js new file mode 100644 index 0000000..482cc8a --- /dev/null +++ b/static/components/order-list/order-list.js @@ -0,0 +1,137 @@ +async function orderList(path) { + const template = await loadTemplateAsync(path) + Vue.component('order-list', { + name: 'order-list', + props: ['stall-id', 'adminkey', 'inkey'], + template, + + data: function () { + return { + orders: [], + selectedOrder: null, + shippingMessage: '', + showShipDialog: false, + filter: '', + ordersTable: { + columns: [ + { + name: '', + align: 'left', + label: '', + field: '' + }, + { + name: 'id', + align: 'left', + label: 'ID', + field: 'id' + }, + { + name: 'total', + align: 'left', + label: 'Total', + field: 'total' + }, + { + name: 'paid', + align: 'left', + label: 'Paid', + field: 'paid' + }, + { + name: 'shipped', + align: 'left', + label: 'Shipped', + field: 'shipped' + }, + { + name: 'pubkey', + align: 'left', + label: 'Customer', + field: 'pubkey' + }, + { + name: 'time', + align: 'left', + label: 'Date', + field: 'time' + } + ], + pagination: { + rowsPerPage: 10 + } + } + } + }, + methods: { + toShortId: function (value) { + return value.substring(0, 5) + '...' + value.substring(value.length - 5) + }, + formatDate: function (value) { + return Quasar.utils.date.formatDate( + new Date(value * 1000), + 'YYYY-MM-DD HH:mm' + ) + }, + productOverview: function (order, productId) { + product = order.extra.products.find(p => p.id === productId) + if (product) { + return `${product.name} (${product.price} ${order.extra.currency})` + } + return '' + }, + getOrders: async function () { + try { + const ordersPath = this.stallId + ? `/stall/order/${this.stallId}` + : '/order' + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1' + ordersPath, + this.inkey + ) + this.orders = data.map(s => ({...s, expanded: false})) + console.log('### this.orders', this.orders) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + updateOrderShipped: async function () { + console.log('### order', this.selectedOrder) + this.selectedOrder.shipped = !this.selectedOrder.shipped + try { + await LNbits.api.request( + 'PATCH', + `/nostrmarket/api/v1/order/${this.selectedOrder.id}`, + this.adminkey, + { + id: this.selectedOrder.id, + message: this.shippingMessage, + shipped: this.selectedOrder.shipped + } + ) + this.$q.notify({ + type: 'positive', + message: 'Order updated!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + this.showShipDialog = false + }, + showShipOrderDialog: function (order) { + this.selectedOrder = order + this.shippingMessage = order.shipped + ? `The order has been shipped! Order ID: '${order.id}' ` + : `The order has NOT yet been shipped! Order ID: '${order.id}'` + + // do not change the status yet + this.selectedOrder.shipped = !order.shipped + this.showShipDialog = true + } + }, + created: async function () { + await this.getOrders() + } + }) +} diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index b26caf3..4239f68 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -185,7 +185,13 @@ -
+
+ +
diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html index 86ae34e..bc6236e 100644 --- a/static/components/stall-list/stall-list.html +++ b/static/components/stall-list/stall-list.html @@ -50,7 +50,7 @@ {{props.row.name}} - + {{props.row.currency}} {{props.row.config.description}} diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js index 5fd8ffd..d41c062 100644 --- a/static/components/stall-list/stall-list.js +++ b/static/components/stall-list/stall-list.js @@ -35,6 +35,12 @@ async function stallList(path) { label: 'Name', field: 'id' }, + { + name: 'currency', + align: 'left', + label: 'Currency', + field: 'currency' + }, { name: 'description', align: 'left', diff --git a/static/js/index.js b/static/js/index.js index 5b26d4c..3eb50ad 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -5,6 +5,7 @@ const merchant = async () => { await shippingZones('static/components/shipping-zones/shipping-zones.html') await stallDetails('static/components/stall-details/stall-details.html') await stallList('static/components/stall-list/stall-list.html') + await orderList('static/components/order-list/order-list.html') const nostr = window.NostrTools diff --git a/tasks.py b/tasks.py index 3254dcc..c97c2b2 100644 --- a/tasks.py +++ b/tasks.py @@ -1,18 +1,31 @@ import asyncio import json -import threading +from asyncio import Queue import httpx import websocket from loguru import logger +from websocket import WebSocketApp +from lnbits.core import get_wallet from lnbits.core.models import Payment -from lnbits.helpers import url_for +from lnbits.helpers import Optional, url_for from lnbits.tasks import register_invoice_listener +from .crud import ( + get_merchant_by_pubkey, + get_public_keys_for_merchants, + get_wallet_for_product, + update_order_paid_status, +) +from .helpers import order_from_json +from .models import OrderStatusUpdate, PartialOrder +from .nostr.event import NostrEvent +from .nostr.nostr_client import connect_to_nostrclient_ws, publish_nostr_event + async def wait_for_paid_invoices(): - invoice_queue = asyncio.Queue() + invoice_queue = Queue() register_invoice_listener(invoice_queue) while True: @@ -24,8 +37,141 @@ async def on_invoice_paid(payment: Payment) -> None: if payment.extra.get("tag") != "nostrmarket": return - print("### on_invoice_paid") + order_id = payment.extra.get("order_id") + merchant_pubkey = payment.extra.get("merchant_pubkey") + if not order_id or not merchant_pubkey: + return None + + await handle_order_paid(order_id, merchant_pubkey) -async def subscribe_nostrclient_ws(): +async def handle_order_paid(order_id: str, merchant_pubkey: str): + try: + order = await update_order_paid_status(order_id, True) + assert order, f"Paid order cannot be found. Order id: {order_id}" + order_status = OrderStatusUpdate( + id=order_id, message="Payment received.", paid=True, shipped=order.shipped + ) + + merchant = await get_merchant_by_pubkey(merchant_pubkey) + assert merchant, f"Merchant cannot be found for order {order_id}" + dm_content = json.dumps( + order_status.dict(), separators=(",", ":"), ensure_ascii=False + ) + + dm_event = merchant.build_dm_event(dm_content, order.pubkey) + await publish_nostr_event(dm_event) + except Exception as ex: + logger.warning(ex) + + +async def subscribe_to_nostr_client(recieve_event_queue: Queue, send_req_queue: Queue): print("### subscribe_nostrclient_ws") + + def on_open(_): + logger.info("Connected to 'nostrclient' websocket") + + def on_message(_, message): + print("### on_message", message) + recieve_event_queue.put_nowait(message) + + # wait for 'nostrclient' extension to initialize + await asyncio.sleep(5) + ws: WebSocketApp = None + while True: + try: + req = None + if not ws: + ws = await connect_to_nostrclient_ws(on_open, on_message) + # be sure the connection is open + await asyncio.sleep(3) + req = await send_req_queue.get() + ws.send(json.dumps(req)) + except Exception as ex: + logger.warning(ex) + if req: + await send_req_queue.put(req) + ws = None # todo close + await asyncio.sleep(5) + + +async def wait_for_nostr_events(recieve_event_queue: Queue, send_req_queue: Queue): + public_keys = await get_public_keys_for_merchants() + for p in public_keys: + await send_req_queue.put( + ["REQ", f"direct-messages:{p}", {"kind": 4, "#p": [p]}] + ) + + while True: + message = await recieve_event_queue.get() + await handle_message(message) + + +async def handle_message(msg: str): + try: + type, subscription_id, event = json.loads(msg) + _, public_key = subscription_id.split(":") + if type.upper() == "EVENT": + event = NostrEvent(**event) + if event.kind == 4: + await handle_nip04_message(public_key, event) + + except Exception as ex: + logger.warning(ex) + + +async def handle_nip04_message(public_key: str, event: NostrEvent): + merchant = await get_merchant_by_pubkey(public_key) + assert merchant, f"Merchant not found for public key '{public_key}'" + + clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) + dm_content = await handle_dirrect_message(event.pubkey, event.id, clear_text_msg) + if dm_content: + dm_event = merchant.build_dm_event(dm_content, event.pubkey) + await publish_nostr_event(dm_event) + + +async def handle_dirrect_message( + from_pubkey: str, event_id: str, msg: str +) -> Optional[str]: + order, text_msg = order_from_json(msg) + try: + if order: + order["pubkey"] = from_pubkey + order["event_id"] = event_id + return await handle_new_order(PartialOrder(**order)) + else: + print("### text_msg", text_msg) + return None + except Exception as ex: + logger.warning(ex) + return None + + +async def handle_new_order(order: PartialOrder): + ### todo: check that event_id not parsed already + + order.validate_order() + + first_product_id = order.items[0].product_id + wallet_id = await get_wallet_for_product(first_product_id) + assert wallet_id, f"Cannot find wallet id for product id: {first_product_id}" + + wallet = await get_wallet(wallet_id) + assert wallet, f"Cannot find wallet for product id: {first_product_id}" + + market_url = url_for(f"/nostrmarket/api/v1/order", external=True) + async with httpx.AsyncClient() as client: + resp = await client.post( + url=market_url, + headers={ + "X-Api-Key": wallet.adminkey, + }, + json=order.dict(), + ) + resp.raise_for_status() + data = resp.json() + if data: + return json.dumps(data, separators=(",", ":"), ensure_ascii=False) + + return None diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index fa07f17..b866226 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -150,6 +150,7 @@ + {% endblock %} diff --git a/views_api.py b/views_api.py index 4680977..6d561d8 100644 --- a/views_api.py +++ b/views_api.py @@ -6,18 +6,20 @@ from fastapi import Depends from fastapi.exceptions import HTTPException from loguru import logger +from lnbits.core import create_invoice from lnbits.decorators import ( WalletTypeInfo, + check_admin, get_key_type, require_admin_key, require_invoice_key, ) -from lnbits.extensions.nostrmarket.nostr.event import NostrEvent from lnbits.utils.exchange_rates import currencies -from . import nostrmarket_ext +from . import nostrmarket_ext, scheduled_tasks from .crud import ( create_merchant, + create_order, create_product, create_stall, create_zone, @@ -25,12 +27,19 @@ from .crud import ( delete_stall, delete_zone, get_merchant_for_user, + get_order, + get_order_by_event_id, + get_orders, + get_orders_for_stall, get_product, get_products, + get_products_by_ids, get_stall, get_stalls, + get_wallet_for_product, get_zone, get_zones, + update_order_shipped_status, update_product, update_stall, update_zone, @@ -38,14 +47,21 @@ from .crud import ( from .models import ( Merchant, Nostrable, + Order, + OrderExtra, + OrderStatusUpdate, PartialMerchant, + PartialOrder, PartialProduct, PartialStall, PartialZone, + PaymentOption, + PaymentRequest, Product, Stall, Zone, ) +from .nostr.event import NostrEvent from .nostr.nostr_client import publish_nostr_event ######################################## MERCHANT ######################################## @@ -80,7 +96,7 @@ async def api_get_merchant( logger.warning(ex) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot create merchant", + detail="Cannot get merchant", ) @@ -95,13 +111,13 @@ async def api_get_zones(wallet: WalletTypeInfo = Depends(get_key_type)) -> List[ logger.warning(ex) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot create merchant", + detail="Cannot get zone", ) @nostrmarket_ext.post("/api/v1/zone") async def api_create_zone( - data: PartialZone, wallet: WalletTypeInfo = Depends(get_key_type) + data: PartialZone, wallet: WalletTypeInfo = Depends(require_admin_key) ): try: zone = await create_zone(wallet.wallet.user, data) @@ -110,7 +126,7 @@ async def api_create_zone( logger.warning(ex) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot create merchant", + detail="Cannot create zone", ) @@ -136,7 +152,7 @@ async def api_update_zone( logger.warning(ex) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot create merchant", + detail="Cannot update zone", ) @@ -157,7 +173,7 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi logger.warning(ex) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot create merchant", + detail="Cannot delete zone", ) @@ -274,6 +290,22 @@ async def api_get_stall_products( ) +@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}") +async def api_get_stall_orders( + stall_id: str, + wallet: WalletTypeInfo = Depends(require_invoice_key), +): + try: + orders = await get_orders_for_stall(wallet.wallet.user, stall_id) + return orders + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot get stall products", + ) + + @nostrmarket_ext.delete("/api/v1/stall/{stall_id}") async def api_delete_stall( stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) @@ -418,6 +450,132 @@ async def api_delete_product( ) +######################################## ORDERS ######################################## + + +@nostrmarket_ext.post("/api/v1/order") +async def api_create_order( + data: PartialOrder, wallet: WalletTypeInfo = Depends(require_admin_key) +) -> Optional[PaymentRequest]: + try: + # print("### new order: ", json.dumps(data.dict())) + if await get_order(wallet.wallet.user, data.id): + return None + if data.event_id and await get_order_by_event_id( + wallet.wallet.user, data.event_id + ): + return None + + merchant = await get_merchant_for_user(wallet.wallet.user) + assert merchant, "Cannot find merchant!" + + products = await get_products_by_ids( + wallet.wallet.user, [p.product_id for p in data.items] + ) + data.validate_order_items(products) + + total_amount = await data.total_sats(products) + + wallet_id = await get_wallet_for_product(data.items[0].product_id) + assert wallet_id, "Missing wallet for order `{data.id}`" + + payment_hash, invoice = await create_invoice( + wallet_id=wallet_id, + amount=round(total_amount), + memo=f"Order '{data.id}' for pubkey '{data.pubkey}'", + extra={ + "tag": "nostrmarket", + "order_id": data.id, + "merchant_pubkey": merchant.public_key, + }, + ) + + order = Order( + **data.dict(), + stall_id=products[0].stall_id, + invoice_id=payment_hash, + total=total_amount, + extra=await OrderExtra.from_products(products), + ) + await create_order(wallet.wallet.user, order) + + return PaymentRequest( + id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)] + ) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create order", + ) + + +nostrmarket_ext.get("/api/v1/order/{order_id}") + + +async def api_get_order(order_id: str, wallet: WalletTypeInfo = Depends(get_key_type)): + try: + order = await get_order(wallet.wallet.user, order_id) + if not order: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Order does not exist.", + ) + return order + except HTTPException as ex: + raise ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot get order", + ) + + +@nostrmarket_ext.get("/api/v1/order") +async def api_get_orders(wallet: WalletTypeInfo = Depends(get_key_type)): + try: + orders = await get_orders(wallet.wallet.user) + return orders + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot get orders", + ) + + +@nostrmarket_ext.patch("/api/v1/order/{order_id}") +async def api_update_order_status( + data: OrderStatusUpdate, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Order: + try: + assert data.shipped != None, "Shipped value is required for order" + order = await update_order_shipped_status( + wallet.wallet.user, data.id, data.shipped + ) + assert order, "Cannot find updated order" + + merchant = await get_merchant_for_user(wallet.wallet.user) + assert merchant, f"Merchant cannot be found for order {data.id}" + + data.paid = order.paid + dm_content = json.dumps(data.dict(), separators=(",", ":"), ensure_ascii=False) + + dm_event = merchant.build_dm_event(dm_content, order.pubkey) + await publish_nostr_event(dm_event) + + return order + + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot update order", + ) + + ######################################## OTHER ######################################## @@ -426,6 +584,16 @@ async def api_list_currencies_available(): return list(currencies.keys()) +@nostrmarket_ext.delete("/api/v1", status_code=HTTPStatus.OK) +async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)): + for t in scheduled_tasks: + try: + t.cancel() + except Exception as ex: + logger.warning(ex) + + return {"success": True} + ######################################## HELPERS ########################################