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():
# 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()

116
crud.py
View file

@ -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]

View file

@ -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

View file

@ -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 '{}'
);
"""
)

View file

@ -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

View file

@ -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}"])

View file

@ -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)

View file

@ -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

View file

@ -1,7 +1,26 @@
<div>
<q-card>
<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 class="q-pa-none">
<q-separator></q-separator>
@ -9,7 +28,7 @@
<q-card-section>
<q-select
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"
emit-value
@input="selectActiveCustomer()"

View file

@ -2,23 +2,38 @@ async function directMessages(path) {
const template = await loadTemplateAsync(path)
Vue.component('direct-messages', {
name: 'direct-messages',
props: ['active-public-key', 'adminkey', 'inkey'],
props: ['active-chat-customer', 'adminkey', 'inkey'],
template,
watch: {
activeChatCustomer: async function (n) {
this.activePublicKey = n
},
activePublicKey: async function (n) {
await this.getDirectMessages(n)
}
},
data: function () {
return {
customersPublicKeys: [],
customers: [],
unreadMessages: 0,
activePublicKey: null,
messages: [],
newMessage: ''
}
},
methods: {
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) {
if (!pubkey) {
this.messages = []
@ -31,23 +46,21 @@ async function directMessages(path) {
this.inkey
)
this.messages = data
console.log(
'### this.messages',
this.messages.map(m => 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()
}
})
}

View file

@ -1,18 +1,45 @@
<div>
<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
unelevated
color="secondary"
outline
icon="refresh"
icon="search"
@click="getOrders()"
class="float-left"
>Refresh Orders</q-btn
class="float-right"
>Search Orders</q-btn
>
</div>
</div>
<div class="row">
<div class="row q-mt-md">
<div class="col">
<q-table
flat
@ -36,9 +63,18 @@
/>
</q-td>
<q-td key="id" :props="props"> {{toShortId(props.row.id)}} </q-td>
<q-td key="total" :props="props"> {{props.row.total}} </q-td>
<!-- todo: currency per order -->
<q-td key="id" :props="props">
{{toShortId(props.row.id)}}
<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-checkbox
@ -78,7 +114,9 @@
<div class="row items-center no-wrap q-mb-md">
<div class="col-1">Quantity</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 class="col-1"></div>
@ -92,13 +130,34 @@
>
<div class="col-1">{{item.quantity}}</div>
<div class="col-1">x</div>
<div class="col-10">
{{productOverview(props.row, item.product_id)}}
<div class="col-4">
{{productName(props.row, item.product_id)}}
</div>
<div class="col-2">
{{productPrice(props.row, item.product_id)}}
</div>
<div class="col-4"></div>
</div>
</div>
<div class="col-1"></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="col-3 q-pr-lg">Order ID:</div>
<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)
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()
}
})
}

View file

@ -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()
}
})
}

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) {
// Get timestamps
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 .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)

View file

@ -59,6 +59,21 @@
></stall-list>
</q-card-section>
</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>
<q-card v-else>
<q-card-section>
@ -133,9 +148,11 @@
</div>
<div v-if="merchant && merchant.id" class="col-12">
<direct-messages
:active-public-key="activeChatCustomer"
ref="directMessagesRef"
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
:active-chat-customer="activeChatCustomer"
@customer-selected="filterOrdersForCustomer"
>
</direct-messages>
</div>

View file

@ -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(