Merge pull request #33 from lnbits/listen_for_orders

Listen for orders
This commit is contained in:
Vlad Stan 2023-04-03 11:59:31 +03:00 committed by GitHub
commit d106879a09
17 changed files with 580 additions and 96 deletions

View file

@ -46,7 +46,7 @@ def nostrmarket_start():
async def _wait_for_nostr_events(): async def _wait_for_nostr_events():
# wait for this extension to initialize # wait for this extension to initialize
await asyncio.sleep(5) await asyncio.sleep(15)
await wait_for_nostr_events(nostr_client) await wait_for_nostr_events(nostr_client)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()

116
crud.py
View file

@ -5,6 +5,8 @@ from lnbits.helpers import urlsafe_short_hash
from . import db from . import db
from .models import ( from .models import (
Customer,
CustomerProfile,
DirectMessage, DirectMessage,
Merchant, Merchant,
MerchantConfig, MerchantConfig,
@ -391,12 +393,13 @@ async def create_order(merchant_id: str, o: Order) -> Order:
address, address,
contact_data, contact_data,
extra_data, extra_data,
order_items, order_items,
shipping_id,
stall_id, stall_id,
invoice_id, invoice_id,
total total
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING 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.contact.dict() if o.contact else {}),
json.dumps(o.extra.dict()), json.dumps(o.extra.dict()),
json.dumps([i.dict() for i in o.items]), json.dumps([i.dict() for i in o.items]),
o.shipping_id,
o.stall_id, o.stall_id,
o.invoice_id, o.invoice_id,
o.total, 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 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( rows = await db.fetchall(
"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? ORDER BY time DESC", f"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? {q} ORDER BY time DESC",
(merchant_id,), (merchant_id, *values),
) )
return [Order.from_row(row) for row in rows] 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( rows = await db.fetchall(
"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? AND stall_id = ? ORDER BY time DESC", f"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? AND stall_id = ? {q} ORDER BY time DESC",
( (merchant_id, stall_id, *values),
merchant_id,
stall_id,
),
) )
return [Order.from_row(row) for row in rows] 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: async def get_last_order_time(public_key: str) -> int:
row = await db.fetchone( 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]: ######################################## CUSTOMERS ########################################
rows = await db.fetchall(
"SELECT DISTINCT public_key FROM nostrmarket.direct_messages WHERE merchant_id = ?",
(merchant_id), 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]

View file

@ -77,8 +77,6 @@ def copy_x(output, x32, y32, data):
def order_from_json(s: str) -> Tuple[Optional[Any], Optional[str]]: def order_from_json(s: str) -> Tuple[Optional[Any], Optional[str]]:
try: try:
order = json.loads(s) order = json.loads(s)
return ( return (order, s) if (type(order) is dict) and "items" in order else (None, s)
(order, s) if (type(order) is dict) and "items" in order else (None, s)
)
except ValueError: except ValueError:
return None, s return None, s

View file

@ -86,6 +86,7 @@ async def m001_initial(db):
order_items TEXT NOT NULL, order_items TEXT NOT NULL,
address TEXT, address TEXT,
total REAL NOT NULL, total REAL NOT NULL,
shipping_id TEXT NOT NULL,
stall_id TEXT NOT NULL, stall_id TEXT NOT NULL,
invoice_id TEXT NOT NULL, invoice_id TEXT NOT NULL,
paid BOOLEAN NOT NULL DEFAULT false, paid BOOLEAN NOT NULL DEFAULT false,
@ -125,3 +126,18 @@ async def m001_initial(db):
await db.execute( await db.execute(
"CREATE INDEX idx_event_id ON nostrmarket.direct_messages (event_id)" "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 '{}'
);
"""
)

View file

@ -2,7 +2,7 @@ import json
import time import time
from abc import abstractmethod from abc import abstractmethod
from sqlite3 import Row from sqlite3 import Row
from typing import List, Optional from typing import List, Optional, Tuple
from pydantic import BaseModel from pydantic import BaseModel
@ -313,6 +313,8 @@ class OrderExtra(BaseModel):
products: List[ProductOverview] products: List[ProductOverview]
currency: str currency: str
btc_price: str btc_price: str
shipping_cost: float = 0
shipping_cost_sat: float = 0
@classmethod @classmethod
async def from_products(cls, products: List[Product]): async def from_products(cls, products: List[Product]):
@ -329,6 +331,7 @@ class PartialOrder(BaseModel):
event_created_at: Optional[int] event_created_at: Optional[int]
public_key: str public_key: str
merchant_public_key: str merchant_public_key: str
shipping_id: str
items: List[OrderItem] items: List[OrderItem]
contact: Optional[OrderContact] contact: Optional[OrderContact]
address: Optional[str] address: Optional[str]
@ -356,20 +359,25 @@ class PartialOrder(BaseModel):
f"Order ({self.id}) has products from different stalls" 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 = {} product_prices = {}
for p in products: for p in products:
product_prices[p.id] = p product_prices[p.id] = p
amount: float = 0 # todo product_cost: float = 0 # todo
for item in self.items: for item in self.items:
price = product_prices[item.product_id].price price = product_prices[item.product_id].price
currency = product_prices[item.product_id].config.currency or "sat" currency = product_prices[item.product_id].config.currency or "sat"
if currency != "sat": if currency != "sat":
price = await fiat_amount_as_satoshis(price, currency) 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): class Order(PartialOrder):
@ -427,3 +435,25 @@ class DirectMessage(PartialDirectMessage):
def from_row(cls, row: Row) -> "DirectMessage": def from_row(cls, row: Row) -> "DirectMessage":
dm = cls(**dict(row)) dm = cls(**dict(row))
return dm 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

View file

@ -70,7 +70,7 @@ class NostrClient:
async def subscribe_to_direct_messages(self, public_key: str, since: int): async def subscribe_to_direct_messages(self, public_key: str, since: int):
in_messages_filter = {"kind": 4, "#p": [public_key]} in_messages_filter = {"kind": 4, "#p": [public_key]}
out_messages_filter = {"kind": 4, "authors": [public_key]} out_messages_filter = {"kind": 4, "authors": [public_key]}
if since != 0: if since and since != 0:
in_messages_filter["since"] = since in_messages_filter["since"] = since
out_messages_filter["since"] = since out_messages_filter["since"] = since
@ -92,6 +92,15 @@ class NostrClient:
["REQ", f"product-events:{public_key}", product_filter] ["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): 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-in:{public_key}"])
await self.send_req_queue.put(["CLOSE", f"direct-messages-out:{public_key}"]) await self.send_req_queue.put(["CLOSE", f"direct-messages-out:{public_key}"])

View file

@ -4,11 +4,15 @@ from typing import List, Optional, Tuple
from loguru import logger from loguru import logger
from lnbits.core import create_invoice, get_wallet from lnbits.core import create_invoice, get_wallet
from lnbits.core.services import websocketUpdater
from . import nostr_client from . import nostr_client
from .crud import ( from .crud import (
CustomerProfile,
create_customer,
create_direct_message, create_direct_message,
create_order, create_order,
get_customer,
get_merchant_by_pubkey, get_merchant_by_pubkey,
get_order, get_order,
get_order_by_event_id, get_order_by_event_id,
@ -16,6 +20,9 @@ from .crud import (
get_products_by_ids, get_products_by_ids,
get_stalls, get_stalls,
get_wallet_for_product, get_wallet_for_product,
get_zone,
increment_customer_unread_messages,
update_customer_profile,
update_order_paid_status, update_order_paid_status,
update_product, update_product,
update_product_quantity, update_product_quantity,
@ -23,6 +30,7 @@ from .crud import (
) )
from .helpers import order_from_json from .helpers import order_from_json
from .models import ( from .models import (
Customer,
Merchant, Merchant,
Nostrable, Nostrable,
Order, Order,
@ -53,8 +61,12 @@ async def create_new_order(
merchant.id, [p.product_id for p in data.items] merchant.id, [p.product_id for p in data.items]
) )
data.validate_order_items(products) 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) wallet_id = await get_wallet_for_product(data.items[0].product_id)
assert wallet_id, "Missing wallet for order `{data.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( payment_hash, invoice = await create_invoice(
wallet_id=wallet_id, 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}'", memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
extra={ extra={
"tag": "nostrmarket", "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( order = Order(
**data.dict(), **data.dict(),
stall_id=products[0].stall_id, stall_id=products[0].stall_id,
invoice_id=payment_hash, invoice_id=payment_hash,
total=total_amount, total=product_cost_sat + shipping_cost_sat,
extra=await OrderExtra.from_products(products), extra=extra,
) )
await create_order(merchant.id, order) 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( return PaymentRequest(
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)] 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) type, *rest = json.loads(msg)
if type.upper() == "EVENT": if type.upper() == "EVENT":
subscription_id, event = rest subscription_id, event = rest
_, merchant_public_key = subscription_id.split(":")
event = NostrEvent(**event) event = NostrEvent(**event)
if event.kind == 0:
await _handle_customer_profile_update(event)
if event.kind == 4: if event.kind == 4:
_, merchant_public_key = subscription_id.split(":")
await _handle_nip04_message(merchant_public_key, event) await _handle_nip04_message(merchant_public_key, event)
return return
except Exception as ex: except Exception as ex:
@ -235,7 +264,13 @@ async def _handle_nip04_message(merchant_public_key: str, event: NostrEvent):
async def _handle_incoming_dms( async def _handle_incoming_dms(
event: NostrEvent, merchant: Merchant, clear_text_msg: str 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.id,
merchant.public_key, merchant.public_key,
event.pubkey, event.pubkey,
@ -243,8 +278,8 @@ async def _handle_incoming_dms(
event.created_at, event.created_at,
clear_text_msg, clear_text_msg,
) )
if dm_content: if dm_reply:
dm_event = merchant.build_dm_event(dm_content, event.pubkey) dm_event = merchant.build_dm_event(dm_reply, event.pubkey)
await nostr_client.publish_nostr_event(dm_event) await nostr_client.publish_nostr_event(dm_event)
@ -287,6 +322,11 @@ async def _handle_dirrect_message(
order["event_created_at"] = event_created_at order["event_created_at"] = event_created_at
return await _handle_new_order(PartialOrder(**order)) return await _handle_new_order(PartialOrder(**order))
await websocketUpdater(
merchant_id,
json.dumps({"type": "new-direct-message", "customerPubkey": from_pubkey}),
)
return None return None
except Exception as ex: except Exception as ex:
logger.warning(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 json.dumps(new_order.dict(), separators=(",", ":"), ensure_ascii=False)
return None 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)

View file

@ -185,7 +185,7 @@ async function customerStall(path) {
items: Array.from(this.cart.products, p => { items: Array.from(this.cart.products, p => {
return {product_id: p[0], quantity: p[1].quantity} return {product_id: p[0], quantity: p[1].quantity}
}), }),
shipping: orderData.shippingzone shipping_id: orderData.shippingzone
} }
orderObj.id = await hash( orderObj.id = await hash(
[orderData.pubkey, created_at, JSON.stringify(orderObj)].join(':') [orderData.pubkey, created_at, JSON.stringify(orderObj)].join(':')
@ -269,7 +269,7 @@ async function customerStall(path) {
items: Array.from(this.cart.products, p => { items: Array.from(this.cart.products, p => {
return {product_id: p[0], quantity: p[1].quantity} return {product_id: p[0], quantity: p[1].quantity}
}), }),
shipping: orderData.shippingzone shipping_id: orderData.shippingzone
} }
let created_at = Math.floor(Date.now() / 1000) let created_at = Math.floor(Date.now() / 1000)
orderObj.id = await hash( orderObj.id = await hash(
@ -375,8 +375,9 @@ async function customerStall(path) {
this.qrCodeDialog.data.message = json.message this.qrCodeDialog.data.message = json.message
return cb() return cb()
} }
let payment_request = json.payment_options.find(o => o.type == 'ln') let payment_request = json.payment_options.find(
.link o => o.type == 'ln'
).link
if (!payment_request) return if (!payment_request) return
this.loading = false this.loading = false
this.qrCodeDialog.data.payment_request = payment_request this.qrCodeDialog.data.payment_request = payment_request

View file

@ -1,7 +1,26 @@
<div> <div>
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none">Messages</h6> <div class="row">
<div class="col-2">
<h6 class="text-subtitle1 q-my-none">Messages</h6>
</div>
<div class="col-4">
<q-badge v-if="unreadMessages" color="green"
><span v-text="unreadMessages"></span>&nbsp; new</q-badge
>
</div>
<div class="col-6">
<q-btn
v-if="activePublicKey"
@click="showClientOrders"
unelevated
outline
class="float-right"
>Client Orders</q-btn
>
</div>
</div>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>
@ -9,7 +28,7 @@
<q-card-section> <q-card-section>
<q-select <q-select
v-model="activePublicKey" v-model="activePublicKey"
:options="customersPublicKeys.map(k => ({label: `${k.slice(0, 16)}...${k.slice(k.length - 16)}`, value: k}))" :options="customers.map(c => ({label: buildCustomerLabel(c), value: c.public_key}))"
label="Select Customer" label="Select Customer"
emit-value emit-value
@input="selectActiveCustomer()" @input="selectActiveCustomer()"

View file

@ -2,23 +2,38 @@ async function directMessages(path) {
const template = await loadTemplateAsync(path) const template = await loadTemplateAsync(path)
Vue.component('direct-messages', { Vue.component('direct-messages', {
name: 'direct-messages', name: 'direct-messages',
props: ['active-public-key', 'adminkey', 'inkey'], props: ['active-chat-customer', 'adminkey', 'inkey'],
template, template,
watch: { watch: {
activeChatCustomer: async function (n) {
this.activePublicKey = n
},
activePublicKey: async function (n) { activePublicKey: async function (n) {
await this.getDirectMessages(n) await this.getDirectMessages(n)
} }
}, },
data: function () { data: function () {
return { return {
customersPublicKeys: [], customers: [],
unreadMessages: 0,
activePublicKey: null,
messages: [], messages: [],
newMessage: '' newMessage: ''
} }
}, },
methods: { methods: {
sendMessage: async function () {}, sendMessage: async function () {},
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
},
getDirectMessages: async function (pubkey) { getDirectMessages: async function (pubkey) {
if (!pubkey) { if (!pubkey) {
this.messages = [] this.messages = []
@ -31,23 +46,21 @@ async function directMessages(path) {
this.inkey this.inkey
) )
this.messages = data this.messages = data
console.log(
'### this.messages',
this.messages.map(m => m.message)
)
this.focusOnChatBox(this.messages.length - 1) this.focusOnChatBox(this.messages.length - 1)
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
getCustomersPublicKeys: async function () { getCustomers: async function () {
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
'/nostrmarket/api/v1/customers', '/nostrmarket/api/v1/customers',
this.inkey this.inkey
) )
this.customersPublicKeys = data this.customers = data
this.unreadMessages = data.filter(c => c.unread_messages).length
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
@ -70,8 +83,19 @@ async function directMessages(path) {
LNbits.utils.notifyApiError(error) 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 () { selectActiveCustomer: async function () {
await this.getDirectMessages(this.activePublicKey) await this.getDirectMessages(this.activePublicKey)
await this.getCustomers()
}, },
focusOnChatBox: function (index) { focusOnChatBox: function (index) {
setTimeout(() => { setTimeout(() => {
@ -85,7 +109,7 @@ async function directMessages(path) {
} }
}, },
created: async function () { created: async function () {
await this.getCustomersPublicKeys() await this.getCustomers()
} }
}) })
} }

View file

@ -1,18 +1,45 @@
<div> <div>
<div class="row q-mb-md"> <div class="row q-mb-md">
<div class="col"> <div class="col-3 q-pr-lg">
<q-select
v-model="search.publicKey"
:options="customers.map(c => ({label: buildCustomerLabel(c), value: c.public_key}))"
label="Customer"
emit-value
class="text-wrap"
>
</q-select>
</div>
<div class="col-3 q-pr-lg">
<q-select
v-model="search.isPaid"
:options="ternaryOptions"
label="Paid"
emit-value
>
</q-select>
</div>
<div class="col-3 q-pr-lg">
<q-select
v-model="search.isShipped"
:options="ternaryOptions"
label="Shipped"
emit-value
>
</q-select>
</div>
<div class="col-3">
<q-btn <q-btn
unelevated unelevated
color="secondary"
outline outline
icon="refresh" icon="search"
@click="getOrders()" @click="getOrders()"
class="float-left" class="float-right"
>Refresh Orders</q-btn >Search Orders</q-btn
> >
</div> </div>
</div> </div>
<div class="row"> <div class="row q-mt-md">
<div class="col"> <div class="col">
<q-table <q-table
flat flat
@ -36,9 +63,18 @@
/> />
</q-td> </q-td>
<q-td key="id" :props="props"> {{toShortId(props.row.id)}} </q-td> <q-td key="id" :props="props">
<q-td key="total" :props="props"> {{props.row.total}} </q-td> {{toShortId(props.row.id)}}
<!-- todo: currency per order --> <q-badge v-if="props.row.isNew" color="orange">new</q-badge></q-td
>
<q-td key="total" :props="props">
{{satBtc(props.row.total)}}
</q-td>
<q-td key="fiat" :props="props">
<span v-if="props.row.extra.currency !== 'sat'">
{{orderTotal(props.row)}} {{props.row.extra.currency}}
</span>
</q-td>
<q-td key="paid" :props="props"> <q-td key="paid" :props="props">
<q-checkbox <q-checkbox
@ -78,7 +114,9 @@
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-1">Quantity</div> <div class="col-1">Quantity</div>
<div class="col-1"></div> <div class="col-1"></div>
<div class="col-10">Name</div> <div class="col-4">Name</div>
<div class="col-2">Price</div>
<div class="col-4"></div>
</div> </div>
</div> </div>
<div class="col-1"></div> <div class="col-1"></div>
@ -92,13 +130,34 @@
> >
<div class="col-1">{{item.quantity}}</div> <div class="col-1">{{item.quantity}}</div>
<div class="col-1">x</div> <div class="col-1">x</div>
<div class="col-10"> <div class="col-4">
{{productOverview(props.row, item.product_id)}} {{productName(props.row, item.product_id)}}
</div> </div>
<div class="col-2">
{{productPrice(props.row, item.product_id)}}
</div>
<div class="col-4"></div>
</div> </div>
</div> </div>
<div class="col-1"></div> <div class="col-1"></div>
</div> </div>
<div
v-if="props.row.extra.currency !== 'sat'"
class="row items-center no-wrap q-mb-md q-mt-md"
>
<div class="col-3 q-pr-lg">Exchange Rate (1 BTC):</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
:value="formatFiat(props.row.extra.btc_price, props.row.extra.currency)"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md q-mt-md"> <div class="row items-center no-wrap q-mb-md q-mt-md">
<div class="col-3 q-pr-lg">Order ID:</div> <div class="col-3 q-pr-lg">Order ID:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">

View file

@ -2,9 +2,18 @@ async function orderList(path) {
const template = await loadTemplateAsync(path) const template = await loadTemplateAsync(path)
Vue.component('order-list', { Vue.component('order-list', {
name: 'order-list', name: 'order-list',
props: ['stall-id', 'adminkey', 'inkey'], props: ['stall-id', 'customer-pubkey-filter', 'adminkey', 'inkey'],
template, 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 () { data: function () {
return { return {
orders: [], orders: [],
@ -12,6 +21,32 @@ async function orderList(path) {
shippingMessage: '', shippingMessage: '',
showShipDialog: false, showShipDialog: false,
filter: '', 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: { ordersTable: {
columns: [ columns: [
{ {
@ -23,15 +58,21 @@ async function orderList(path) {
{ {
name: 'id', name: 'id',
align: 'left', align: 'left',
label: 'ID', label: 'Order ID',
field: 'id' field: 'id'
}, },
{ {
name: 'total', name: 'total',
align: 'left', align: 'left',
label: 'Total', label: 'Total Sats',
field: 'total' field: 'total'
}, },
{
name: 'fiat',
align: 'left',
label: 'Total Fiat',
field: 'fiat'
},
{ {
name: 'paid', name: 'paid',
align: 'left', align: 'left',
@ -73,21 +114,51 @@ async function orderList(path) {
'YYYY-MM-DD HH:mm' '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) product = order.extra.products.find(p => p.id === productId)
if (product) { if (product) {
return `${product.name} (${product.price} ${order.extra.currency})` return product.name
} }
return '' 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 () { getOrders: async function () {
try { try {
const ordersPath = this.stallId const ordersPath = this.stallId
? `/stall/order/${this.stallId}` ? `stall/order/${this.stallId}`
: '/order' : '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( const {data} = await LNbits.api.request(
'GET', 'GET',
'/nostrmarket/api/v1' + ordersPath, `/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`,
this.inkey this.inkey
) )
this.orders = data.map(s => ({...s, expanded: false})) this.orders = data.map(s => ({...s, expanded: false}))
@ -95,6 +166,18 @@ async function orderList(path) {
LNbits.utils.notifyApiError(error) 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 () { updateOrderShipped: async function () {
this.selectedOrder.shipped = !this.selectedOrder.shipped this.selectedOrder.shipped = !this.selectedOrder.shipped
try { try {
@ -117,6 +200,16 @@ async function orderList(path) {
} }
this.showShipDialog = false 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) { showShipOrderDialog: function (order) {
this.selectedOrder = order this.selectedOrder = order
this.shippingMessage = order.shipped this.shippingMessage = order.shipped
@ -129,10 +222,35 @@ async function orderList(path) {
}, },
customerSelected: function (customerPubkey) { customerSelected: function (customerPubkey) {
this.$emit('customer-selected', 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 () { created: async function () {
await this.getOrders() if (this.stallId) {
await this.getOrders()
}
await this.getCustomers()
} }
}) })
} }

View file

@ -21,6 +21,7 @@ const merchant = async () => {
merchant: {}, merchant: {},
shippingZones: [], shippingZones: [],
activeChatCustomer: '', activeChatCustomer: '',
orderPubkey: null,
showKeys: false, showKeys: false,
importKeyDialog: { importKeyDialog: {
show: false, show: false,
@ -102,10 +103,43 @@ const merchant = async () => {
}, },
customerSelectedForOrder: function (customerPubkey) { customerSelectedForOrder: function (customerPubkey) {
this.activeChatCustomer = 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 () { created: async function () {
await this.getMerchant() await this.getMerchant()
await this.waitForNotifications()
} }
}) })
} }

View file

@ -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) { function timeFromNow(time) {
// Get timestamps // Get timestamps
let unixTime = new Date(time).getTime() let unixTime = new Date(time).getTime()

View file

@ -4,6 +4,7 @@ from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import ( from .crud import (
get_all_customers,
get_last_direct_messages_time, get_last_direct_messages_time,
get_last_order_time, get_last_order_time,
get_public_keys_for_merchants, 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) 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: while True:
message = await nostr_client.get_event() message = await nostr_client.get_event()
await process_nostr_message(message) await process_nostr_message(message)

View file

@ -59,6 +59,21 @@
></stall-list> ></stall-list>
</q-card-section> </q-card-section>
</q-card> </q-card>
<q-card class="q-mt-lg">
<q-card-section>
<div class="row">
<div class="col-12">
<order-list
ref="orderListRef"
:adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey"
:customer-pubkey-filter="orderPubkey"
@customer-selected="customerSelectedForOrder"
></order-list>
</div>
</div>
</q-card-section>
</q-card>
</div> </div>
<q-card v-else> <q-card v-else>
<q-card-section> <q-card-section>
@ -133,9 +148,11 @@
</div> </div>
<div v-if="merchant && merchant.id" class="col-12"> <div v-if="merchant && merchant.id" class="col-12">
<direct-messages <direct-messages
:active-public-key="activeChatCustomer" ref="directMessagesRef"
:inkey="g.user.wallets[0].inkey" :inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey" :adminkey="g.user.wallets[0].adminkey"
:active-chat-customer="activeChatCustomer"
@customer-selected="filterOrdersForCustomer"
> >
</direct-messages> </direct-messages>
</div> </div>

View file

@ -1,6 +1,6 @@
import json import json
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import List, Optional, Union
from fastapi import Depends from fastapi import Depends
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
@ -31,6 +31,7 @@ from .crud import (
delete_product, delete_product,
delete_stall, delete_stall,
delete_zone, delete_zone,
get_customers,
get_direct_messages, get_direct_messages,
get_merchant_by_pubkey, get_merchant_by_pubkey,
get_merchant_for_user, get_merchant_for_user,
@ -39,12 +40,11 @@ from .crud import (
get_orders_for_stall, get_orders_for_stall,
get_product, get_product,
get_products, get_products,
get_public_keys_for_direct_messages,
get_public_keys_for_orders,
get_stall, get_stall,
get_stalls, get_stalls,
get_zone, get_zone,
get_zones, get_zones,
update_customer_no_unread_messages,
update_merchant, update_merchant,
update_order_shipped_status, update_order_shipped_status,
update_product, update_product,
@ -52,6 +52,7 @@ from .crud import (
update_zone, update_zone,
) )
from .models import ( from .models import (
Customer,
DirectMessage, DirectMessage,
Merchant, Merchant,
Order, Order,
@ -447,12 +448,17 @@ async def api_get_stall_products(
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}") @nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
async def api_get_stall_orders( async def api_get_stall_orders(
stall_id: str, stall_id: str,
paid: Optional[bool] = None,
shipped: Optional[bool] = None,
pubkey: Optional[str] = None,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
): ):
try: try:
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found" 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 return orders
except AssertionError as ex: except AssertionError as ex:
raise HTTPException( raise HTTPException(
@ -641,10 +647,10 @@ async def api_delete_product(
######################################## ORDERS ######################################## ######################################## ORDERS ########################################
nostrmarket_ext.get("/api/v1/order/{order_id}") @nostrmarket_ext.get("/api/v1/order/{order_id}")
async def api_get_order(
order_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
async def api_get_order(order_id: str, wallet: WalletTypeInfo = Depends(get_key_type)): ):
try: try:
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found" 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") @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: try:
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found" 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 return orders
except AssertionError as ex: except AssertionError as ex:
raise HTTPException( raise HTTPException(
@ -738,6 +751,7 @@ async def api_get_messages(
assert merchant, f"Merchant cannot be found" assert merchant, f"Merchant cannot be found"
messages = await get_direct_messages(merchant.id, public_key) messages = await get_direct_messages(merchant.id, public_key)
await update_customer_no_unread_messages(public_key)
return messages return messages
except AssertionError as ex: except AssertionError as ex:
raise HTTPException( raise HTTPException(
@ -785,17 +799,13 @@ async def api_create_message(
@nostrmarket_ext.get("/api/v1/customers") @nostrmarket_ext.get("/api/v1/customers")
async def api_create_message( async def api_get_customers(
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type),
) -> DirectMessage: ) -> List[Customer]:
try: try:
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, f"Merchant cannot be found" assert merchant, f"Merchant cannot be found"
return await get_customers(merchant.id)
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))
except AssertionError as ex: except AssertionError as ex:
raise HTTPException( raise HTTPException(