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 @@
-
Messages
+
+
+
Messages
+
+
+   new +
+
+ Client Orders +
+
@@ -9,7 +28,7 @@ m.message) - ) + this.focusOnChatBox(this.messages.length - 1) } catch (error) { LNbits.utils.notifyApiError(error) } }, - getCustomersPublicKeys: async function () { + getCustomers: async function () { try { const {data} = await LNbits.api.request( 'GET', '/nostrmarket/api/v1/customers', this.inkey ) - this.customersPublicKeys = data + this.customers = data + this.unreadMessages = data.filter(c => c.unread_messages).length } catch (error) { LNbits.utils.notifyApiError(error) } @@ -70,8 +83,19 @@ async function directMessages(path) { LNbits.utils.notifyApiError(error) } }, + handleNewMessage: async function (data) { + if (data.customerPubkey === this.activePublicKey) { + await this.getDirectMessages(this.activePublicKey) + } else { + await this.getCustomers() + } + }, + showClientOrders: function () { + this.$emit('customer-selected', this.activePublicKey) + }, selectActiveCustomer: async function () { await this.getDirectMessages(this.activePublicKey) + await this.getCustomers() }, focusOnChatBox: function (index) { setTimeout(() => { @@ -85,7 +109,7 @@ async function directMessages(path) { } }, created: async function () { - await this.getCustomersPublicKeys() + await this.getCustomers() } }) } diff --git a/static/components/order-list/order-list.html b/static/components/order-list/order-list.html index 15465bd..3f02776 100644 --- a/static/components/order-list/order-list.html +++ b/static/components/order-list/order-list.html @@ -1,18 +1,45 @@
-
+
+ + +
+
+ + +
+
+ + +
+
Refresh OrdersSearch Orders
-
+
- {{toShortId(props.row.id)}} - {{props.row.total}} - + + {{toShortId(props.row.id)}} + new + + {{satBtc(props.row.total)}} + + + + {{orderTotal(props.row)}} {{props.row.extra.currency}} + +
Quantity
-
Name
+
Name
+
Price
+
@@ -92,13 +130,34 @@ >
{{item.quantity}}
x
-
- {{productOverview(props.row, item.product_id)}} +
+ {{productName(props.row, item.product_id)}}
+
+ {{productPrice(props.row, item.product_id)}} +
+
+
+
Exchange Rate (1 BTC):
+
+ +
+
+
Order ID:
diff --git a/static/components/order-list/order-list.js b/static/components/order-list/order-list.js index e325fa5..f1c883d 100644 --- a/static/components/order-list/order-list.js +++ b/static/components/order-list/order-list.js @@ -2,9 +2,18 @@ async function orderList(path) { const template = await loadTemplateAsync(path) Vue.component('order-list', { name: 'order-list', - props: ['stall-id', 'adminkey', 'inkey'], + props: ['stall-id', 'customer-pubkey-filter', 'adminkey', 'inkey'], template, + watch: { + customerPubkeyFilter: async function (n) { + this.search.publicKey = n + this.search.isPaid = {label: 'All', id: null} + this.search.isShipped = {label: 'All', id: null} + await this.getOrders() + } + }, + data: function () { return { orders: [], @@ -12,6 +21,32 @@ async function orderList(path) { shippingMessage: '', showShipDialog: false, filter: '', + search: { + publicKey: '', + isPaid: { + label: 'All', + id: null + }, + isShipped: { + label: 'All', + id: null + } + }, + customers: [], + ternaryOptions: [ + { + label: 'All', + id: null + }, + { + label: 'Yes', + id: 'true' + }, + { + label: 'No', + id: 'false' + } + ], ordersTable: { columns: [ { @@ -23,15 +58,21 @@ async function orderList(path) { { name: 'id', align: 'left', - label: 'ID', + label: 'Order ID', field: 'id' }, { name: 'total', align: 'left', - label: 'Total', + label: 'Total Sats', field: 'total' }, + { + name: 'fiat', + align: 'left', + label: 'Total Fiat', + field: 'fiat' + }, { name: 'paid', align: 'left', @@ -73,21 +114,51 @@ async function orderList(path) { 'YYYY-MM-DD HH:mm' ) }, - productOverview: function (order, productId) { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, true) + }, + formatFiat(value, currency) { + return Math.trunc(value) + ' ' + currency + }, + productName: function (order, productId) { product = order.extra.products.find(p => p.id === productId) if (product) { - return `${product.name} (${product.price} ${order.extra.currency})` + return product.name } return '' }, + productPrice: function (order, productId) { + product = order.extra.products.find(p => p.id === productId) + if (product) { + return `${product.price} ${order.extra.currency}` + } + return '' + }, + orderTotal: function (order) { + return order.items.reduce((t, item) => { + product = order.extra.products.find(p => p.id === item.product_id) + return t + item.quantity * product.price + }, 0) + }, getOrders: async function () { try { const ordersPath = this.stallId - ? `/stall/order/${this.stallId}` - : '/order' + ? `stall/order/${this.stallId}` + : 'order' + + const query = [] + if (this.search.publicKey) { + query.push(`pubkey=${this.search.publicKey}`) + } + if (this.search.isPaid.id) { + query.push(`paid=${this.search.isPaid.id}`) + } + if (this.search.isShipped.id) { + query.push(`shipped=${this.search.isShipped.id}`) + } const {data} = await LNbits.api.request( 'GET', - '/nostrmarket/api/v1' + ordersPath, + `/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`, this.inkey ) this.orders = data.map(s => ({...s, expanded: false})) @@ -95,6 +166,18 @@ async function orderList(path) { LNbits.utils.notifyApiError(error) } }, + getOrder: async function (orderId) { + try { + const {data} = await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/order/${orderId}`, + this.inkey + ) + return {...data, expanded: false, isNew: true} + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, updateOrderShipped: async function () { this.selectedOrder.shipped = !this.selectedOrder.shipped try { @@ -117,6 +200,16 @@ async function orderList(path) { } this.showShipDialog = false }, + addOrder: async function (data) { + if ( + !this.search.publicKey || + this.search.publicKey === data.customerPubkey + ) { + const order = await this.getOrder(data.orderId) + this.orders.unshift(order) + } + }, + showShipOrderDialog: function (order) { this.selectedOrder = order this.shippingMessage = order.shipped @@ -129,10 +222,35 @@ async function orderList(path) { }, customerSelected: function (customerPubkey) { this.$emit('customer-selected', customerPubkey) + }, + getCustomers: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/customers', + this.inkey + ) + this.customers = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + buildCustomerLabel: function (c) { + let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}` + if (c.unread_messages) { + label += `[new: ${c.unread_messages}]` + } + label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice( + c.public_key.length - 16 + )}` + return label } }, created: async function () { - await this.getOrders() + if (this.stallId) { + await this.getOrders() + } + await this.getCustomers() } }) } diff --git a/static/js/index.js b/static/js/index.js index 58b2c5c..2a8c6be 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -21,6 +21,7 @@ const merchant = async () => { merchant: {}, shippingZones: [], activeChatCustomer: '', + orderPubkey: null, showKeys: false, importKeyDialog: { show: false, @@ -102,10 +103,43 @@ const merchant = async () => { }, customerSelectedForOrder: function (customerPubkey) { this.activeChatCustomer = customerPubkey + }, + filterOrdersForCustomer: function (customerPubkey) { + this.orderPubkey = customerPubkey + }, + waitForNotifications: async function () { + try { + const scheme = location.protocol === 'http:' ? 'ws' : 'wss' + const port = location.port ? `:${location.port}` : '' + const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}` + const wsConnection = new WebSocket(wsUrl) + wsConnection.onmessage = async e => { + const data = JSON.parse(e.data) + if (data.type === 'new-order') { + this.$q.notify({ + timeout: 5000, + type: 'positive', + message: 'New Order' + }) + await this.$refs.orderListRef.addOrder(data) + } else if (data.type === 'new-customer') { + } else if (data.type === 'new-direct-message') { + await this.$refs.directMessagesRef.handleNewMessage(data) + } + } + } catch (error) { + this.$q.notify({ + timeout: 5000, + type: 'warning', + message: 'Failed to watch for updated', + caption: `${error}` + }) + } } }, created: async function () { await this.getMerchant() + await this.waitForNotifications() } }) } diff --git a/static/js/utils.js b/static/js/utils.js index 86c4d00..49a585a 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -48,6 +48,16 @@ function isJson(str) { } } +function satOrBtc(val, showUnit = true, showSats = false) { + const value = showSats + ? LNbits.utils.formatSat(val) + : val == 0 + ? 0.0 + : (val / 100000000).toFixed(8) + if (!showUnit) return value + return showSats ? value + ' sat' : value + ' BTC' +} + function timeFromNow(time) { // Get timestamps let unixTime = new Date(time).getTime() diff --git a/tasks.py b/tasks.py index ad791e3..35ff479 100644 --- a/tasks.py +++ b/tasks.py @@ -4,6 +4,7 @@ from lnbits.core.models import Payment from lnbits.tasks import register_invoice_listener from .crud import ( + get_all_customers, get_last_direct_messages_time, get_last_order_time, get_public_keys_for_merchants, @@ -42,6 +43,10 @@ async def wait_for_nostr_events(nostr_client: NostrClient): await nostr_client.subscribe_to_direct_messages(p, since) + customers = await get_all_customers() + for c in customers: + await nostr_client.subscribe_to_user_profile(c.public_key, c.event_created_at) + while True: message = await nostr_client.get_event() await process_nostr_message(message) diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index 94aa403..9064c0c 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -59,6 +59,21 @@ > + + +
+
+ +
+
+
+
@@ -133,9 +148,11 @@
diff --git a/views_api.py b/views_api.py index e7bc07c..6629f83 100644 --- a/views_api.py +++ b/views_api.py @@ -1,6 +1,6 @@ import json from http import HTTPStatus -from typing import List, Optional +from typing import List, Optional, Union from fastapi import Depends from fastapi.exceptions import HTTPException @@ -31,6 +31,7 @@ from .crud import ( delete_product, delete_stall, delete_zone, + get_customers, get_direct_messages, get_merchant_by_pubkey, get_merchant_for_user, @@ -39,12 +40,11 @@ from .crud import ( get_orders_for_stall, get_product, get_products, - get_public_keys_for_direct_messages, - get_public_keys_for_orders, get_stall, get_stalls, get_zone, get_zones, + update_customer_no_unread_messages, update_merchant, update_order_shipped_status, update_product, @@ -52,6 +52,7 @@ from .crud import ( update_zone, ) from .models import ( + Customer, DirectMessage, Merchant, Order, @@ -447,12 +448,17 @@ 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: Optional[bool] = None, + shipped: Optional[bool] = None, + pubkey: Optional[str] = None, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" - orders = await get_orders_for_stall(merchant.id, stall_id) + orders = await get_orders_for_stall( + merchant.id, stall_id, paid=paid, shipped=shipped, public_key=pubkey + ) return orders except AssertionError as ex: raise HTTPException( @@ -641,10 +647,10 @@ async def api_delete_product( ######################################## ORDERS ######################################## -nostrmarket_ext.get("/api/v1/order/{order_id}") - - -async def api_get_order(order_id: str, wallet: WalletTypeInfo = Depends(get_key_type)): +@nostrmarket_ext.get("/api/v1/order/{order_id}") +async def api_get_order( + order_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key) +): try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -672,12 +678,19 @@ async def api_get_order(order_id: str, wallet: WalletTypeInfo = Depends(get_key_ @nostrmarket_ext.get("/api/v1/order") -async def api_get_orders(wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_get_orders( + paid: Optional[bool] = None, + shipped: Optional[bool] = None, + pubkey: Optional[str] = None, + wallet: WalletTypeInfo = Depends(get_key_type), +): try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" - orders = await get_orders(merchant.id) + orders = await get_orders( + merchant_id=merchant.id, paid=paid, shipped=shipped, public_key=pubkey + ) return orders except AssertionError as ex: raise HTTPException( @@ -738,6 +751,7 @@ async def api_get_messages( assert merchant, f"Merchant cannot be found" messages = await get_direct_messages(merchant.id, public_key) + await update_customer_no_unread_messages(public_key) return messages except AssertionError as ex: raise HTTPException( @@ -785,17 +799,13 @@ async def api_create_message( @nostrmarket_ext.get("/api/v1/customers") -async def api_create_message( +async def api_get_customers( wallet: WalletTypeInfo = Depends(get_key_type), -) -> DirectMessage: +) -> List[Customer]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, f"Merchant cannot be found" - - dm_pubkeys = await get_public_keys_for_direct_messages(merchant.id) - orders_pubkeys = await get_public_keys_for_orders(merchant.id) - - return list(dict.fromkeys(dm_pubkeys + orders_pubkeys)) + return await get_customers(merchant.id) except AssertionError as ex: raise HTTPException(