Product delete (#64)

* feat: restore stalls from `nostr` as pending

* feat: stall and prod last update time

* feat: restore products and stalls as `pending`

* feat: show pending stalls

* feat: restore stall

* feat: restore a stall from nostr

* feat: add  blank `Restore Product` button

* fix: handle no talls to restore case

* feat: show restore dialog

* feat: allow query for pending products

* feat: restore products

* chore: code clean-up

* fix: last dm and last order query

* chore: code clean-up

* fix: subscribe for stalls and products on merchant create/restore

* feat: add message type to orders

* feat: simplify messages; code format

* feat: add type to DMs; restore DMs from nostr

* fix: parsing ints

* fix: hide copy button if invoice not present

* fix: do not generate invoice if product not found

* feat: order restore: first version

* refactor: move some logic into `services`

* feat: improve restore UX

* fix: too many calls to customer DMs

* fix: allow `All` customers filter

* fix: ws reconnect on server restart

* fix: query for customer profiles only one

* fix: unread messages per customer per merchant

* fix: disable `user-profile-events`

* fix: customer profile is optional

* fix: get customers after new message debounced

* chore: code clean-up

* feat: auto-create zone

* feat: fixed ID for default zone

* feat: notify order paid
This commit is contained in:
Vlad Stan 2023-06-30 12:12:56 +02:00 committed by GitHub
parent 1cb8fe86b1
commit 51c4147e65
17 changed files with 934 additions and 610 deletions

144
crud.py
View file

@ -72,12 +72,12 @@ async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
return Merchant.from_row(row) if row else None return Merchant.from_row(row) if row else None
async def get_public_keys_for_merchants() -> List[str]: async def get_merchants_ids_with_pubkeys() -> List[str]:
rows = await db.fetchall( rows = await db.fetchall(
"""SELECT public_key FROM nostrmarket.merchants""", """SELECT id, public_key FROM nostrmarket.merchants""",
) )
return [row[0] for row in rows] return [(row[0], row[1]) for row in rows]
async def get_merchant_for_user(user_id: str) -> Optional[Merchant]: async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
@ -100,7 +100,7 @@ async def delete_merchant(merchant_id: str) -> None:
async def create_zone(merchant_id: str, data: PartialZone) -> Zone: async def create_zone(merchant_id: str, data: PartialZone) -> Zone:
zone_id = urlsafe_short_hash() zone_id = data.id or urlsafe_short_hash()
await db.execute( await db.execute(
f""" f"""
INSERT INTO nostrmarket.zones (id, merchant_id, name, currency, cost, regions) INSERT INTO nostrmarket.zones (id, merchant_id, name, currency, cost, regions)
@ -168,12 +168,14 @@ async def delete_merchant_zones(merchant_id: str) -> None:
async def create_stall(merchant_id: str, data: PartialStall) -> Stall: async def create_stall(merchant_id: str, data: PartialStall) -> Stall:
stall_id = urlsafe_short_hash() stall_id = data.id or urlsafe_short_hash()
await db.execute( await db.execute(
f""" f"""
INSERT INTO nostrmarket.stalls (merchant_id, id, wallet, name, currency, zones, meta) INSERT INTO nostrmarket.stalls
VALUES (?, ?, ?, ?, ?, ?, ?) (merchant_id, id, wallet, name, currency, pending, event_id, event_created_at, zones, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
""", """,
( (
merchant_id, merchant_id,
@ -181,6 +183,9 @@ async def create_stall(merchant_id: str, data: PartialStall) -> Stall:
data.wallet, data.wallet,
data.name, data.name,
data.currency, data.currency,
data.pending,
data.event_id,
data.event_created_at,
json.dumps( json.dumps(
[z.dict() for z in data.shipping_zones] [z.dict() for z in data.shipping_zones]
), # todo: cost is float. should be int for sats ), # todo: cost is float. should be int for sats
@ -204,30 +209,42 @@ async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
return Stall.from_row(row) if row else None return Stall.from_row(row) if row else None
async def get_stalls(merchant_id: str) -> List[Stall]: async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]:
rows = await db.fetchall( rows = await db.fetchall(
"SELECT * FROM nostrmarket.stalls WHERE merchant_id = ?", "SELECT * FROM nostrmarket.stalls WHERE merchant_id = ? AND pending = ?",
(merchant_id,), (merchant_id, pending,),
) )
return [Stall.from_row(row) for row in rows] return [Stall.from_row(row) for row in rows]
async def get_last_stall_update_time(merchant_id: str) -> int:
row = await db.fetchone(
"""
SELECT event_created_at FROM nostrmarket.stalls
WHERE merchant_id = ? ORDER BY event_created_at DESC LIMIT 1
""",
(merchant_id,),
)
return row[0] or 0 if row else 0
async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]: async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]:
await db.execute( await db.execute(
f""" f"""
UPDATE nostrmarket.stalls SET wallet = ?, name = ?, currency = ?, zones = ?, meta = ? UPDATE nostrmarket.stalls SET wallet = ?, name = ?, currency = ?, pending = ?, event_id = ?, event_created_at = ?, zones = ?, meta = ?
WHERE merchant_id = ? AND id = ? WHERE merchant_id = ? AND id = ?
""", """,
( (
stall.wallet, stall.wallet,
stall.name, stall.name,
stall.currency, stall.currency,
stall.pending,
stall.event_id,
stall.event_created_at,
json.dumps( json.dumps(
[z.dict() for z in stall.shipping_zones] [z.dict() for z in stall.shipping_zones]
), # todo: cost is float. should be int for sats ), # todo: cost is float. should be int for sats
json.dumps(stall.config.dict()), json.dumps(stall.config.dict()),
merchant_id, merchant_id,
stall.id, stall.id
), ),
) )
return await get_stall(merchant_id, stall.id) return await get_stall(merchant_id, stall.id)
@ -254,12 +271,14 @@ async def delete_merchant_stalls(merchant_id: str) -> None:
async def create_product(merchant_id: str, data: PartialProduct) -> Product: async def create_product(merchant_id: str, data: PartialProduct) -> Product:
product_id = urlsafe_short_hash() product_id = data.id or urlsafe_short_hash()
await db.execute( await db.execute(
f""" f"""
INSERT INTO nostrmarket.products (merchant_id, id, stall_id, name, price, quantity, image_urls, category_list, meta) INSERT INTO nostrmarket.products
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) (merchant_id, id, stall_id, name, price, quantity, pending, event_id, event_created_at, image_urls, category_list, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
""", """,
( (
merchant_id, merchant_id,
@ -268,6 +287,9 @@ async def create_product(merchant_id: str, data: PartialProduct) -> Product:
data.name, data.name,
data.price, data.price,
data.quantity, data.quantity,
data.pending,
data.event_id,
data.event_created_at,
json.dumps(data.images), json.dumps(data.images),
json.dumps(data.categories), json.dumps(data.categories),
json.dumps(data.config.dict()), json.dumps(data.config.dict()),
@ -283,13 +305,16 @@ async def update_product(merchant_id: str, product: Product) -> Product:
await db.execute( await db.execute(
f""" f"""
UPDATE nostrmarket.products set name = ?, price = ?, quantity = ?, image_urls = ?, category_list = ?, meta = ? UPDATE nostrmarket.products set name = ?, price = ?, quantity = ?, pending = ?, event_id =?, event_created_at = ?, image_urls = ?, category_list = ?, meta = ?
WHERE merchant_id = ? AND id = ? WHERE merchant_id = ? AND id = ?
""", """,
( (
product.name, product.name,
product.price, product.price,
product.quantity, product.quantity,
product.pending,
product.event_id,
product.event_created_at,
json.dumps(product.images), json.dumps(product.images),
json.dumps(product.categories), json.dumps(product.categories),
json.dumps(product.config.dict()), json.dumps(product.config.dict()),
@ -328,10 +353,10 @@ async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
return Product.from_row(row) if row else None return Product.from_row(row) if row else None
async def get_products(merchant_id: str, stall_id: str) -> List[Product]: async def get_products(merchant_id: str, stall_id: str, pending: Optional[bool] = False) -> List[Product]:
rows = await db.fetchall( rows = await db.fetchall(
"SELECT * FROM nostrmarket.products WHERE merchant_id = ? AND stall_id = ?", "SELECT * FROM nostrmarket.products WHERE merchant_id = ? AND stall_id = ? AND pending = ?",
(merchant_id, stall_id), (merchant_id, stall_id, pending),
) )
return [Product.from_row(row) for row in rows] return [Product.from_row(row) for row in rows]
@ -341,7 +366,11 @@ async def get_products_by_ids(
) -> List[Product]: ) -> List[Product]:
q = ",".join(["?"] * len(product_ids)) q = ",".join(["?"] * len(product_ids))
rows = await db.fetchall( rows = await db.fetchall(
f"SELECT id, stall_id, name, price, quantity, category_list, meta FROM nostrmarket.products WHERE merchant_id = ? AND id IN ({q})", f"""
SELECT id, stall_id, name, price, quantity, category_list, meta
FROM nostrmarket.products
WHERE merchant_id = ? AND pending = false AND id IN ({q})
""",
(merchant_id, *product_ids), (merchant_id, *product_ids),
) )
return [Product.from_row(row) for row in rows] return [Product.from_row(row) for row in rows]
@ -353,12 +382,21 @@ async def get_wallet_for_product(product_id: str) -> Optional[str]:
SELECT s.wallet FROM nostrmarket.products p SELECT s.wallet FROM nostrmarket.products p
INNER JOIN nostrmarket.stalls s INNER JOIN nostrmarket.stalls s
ON p.stall_id = s.id ON p.stall_id = s.id
WHERE p.id=? WHERE p.id = ? AND p.pending = false AND s.pending = false
""", """,
(product_id,), (product_id,),
) )
return row[0] if row else None return row[0] if row else None
async def get_last_product_update_time(merchant_id: str) -> int:
row = await db.fetchone(
"""
SELECT event_created_at FROM nostrmarket.products
WHERE merchant_id = ? ORDER BY event_created_at DESC LIMIT 1
""",
(merchant_id,),
)
return row[0] or 0 if row else 0
async def delete_product(merchant_id: str, product_id: str) -> None: async def delete_product(merchant_id: str, product_id: str) -> None:
await db.execute( await db.execute(
@ -456,7 +494,7 @@ async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
q = f"AND {q}" q = f"AND {q}"
values = (v for v in kwargs.values() if v != None) values = (v for v in kwargs.values() if v != None)
rows = await db.fetchall( rows = await db.fetchall(
f"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? {q} ORDER BY time DESC", f"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? {q} ORDER BY event_created_at DESC",
(merchant_id, *values), (merchant_id, *values),
) )
return [Order.from_row(row) for row in rows] return [Order.from_row(row) for row in rows]
@ -479,17 +517,29 @@ async def get_orders_for_stall(
return [Order.from_row(row) for row in rows] return [Order.from_row(row) for row in rows]
async def get_last_order_time(public_key: str) -> int: async def get_last_order_time(merchant_id: str) -> int:
row = await db.fetchone( row = await db.fetchone(
""" """
SELECT event_created_at FROM nostrmarket.orders SELECT event_created_at FROM nostrmarket.orders
WHERE merchant_public_key = ? ORDER BY event_created_at DESC LIMIT 1 WHERE merchant_id = ? ORDER BY event_created_at DESC LIMIT 1
""", """,
(public_key,), (merchant_id,),
) )
return row[0] if row else 0 return row[0] if row else 0
async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"""
UPDATE nostrmarket.orders SET {q} WHERE merchant_id = ? and id = ?
""",
(*kwargs.values(), merchant_id, order_id)
)
return await get_order(merchant_id, order_id)
async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]: async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
await db.execute( await db.execute(
f"UPDATE nostrmarket.orders SET paid = ? WHERE id = ?", f"UPDATE nostrmarket.orders SET paid = ? WHERE id = ?",
@ -533,8 +583,8 @@ async def create_direct_message(
dm_id = urlsafe_short_hash() dm_id = urlsafe_short_hash()
await db.execute( await db.execute(
f""" f"""
INSERT INTO nostrmarket.direct_messages (merchant_id, id, event_id, event_created_at, message, public_key, incoming) INSERT INTO nostrmarket.direct_messages (merchant_id, id, event_id, event_created_at, message, public_key, type, incoming)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING ON CONFLICT(event_id) DO NOTHING
""", """,
( (
@ -544,6 +594,7 @@ async def create_direct_message(
dm.event_created_at, dm.event_created_at,
dm.message, dm.message,
dm.public_key, dm.public_key,
dm.type,
dm.incoming, dm.incoming,
), ),
) )
@ -586,14 +637,24 @@ async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectM
) )
return [DirectMessage.from_row(row) for row in rows] return [DirectMessage.from_row(row) for row in rows]
async def get_orders_from_direct_messages(merchant_id: str) -> List[DirectMessage]:
rows = await db.fetchall(
"SELECT * FROM nostrmarket.direct_messages WHERE merchant_id = ? AND type >= 0 ORDER BY event_created_at, type",
(merchant_id),
)
return [DirectMessage.from_row(row) for row in rows]
async def get_last_direct_messages_time(public_key: str) -> int:
async def get_last_direct_messages_time(merchant_id: str) -> int:
row = await db.fetchone( row = await db.fetchone(
""" """
SELECT event_created_at FROM nostrmarket.direct_messages SELECT event_created_at FROM nostrmarket.direct_messages
WHERE public_key = ? ORDER BY event_created_at DESC LIMIT 1 WHERE merchant_id = ? ORDER BY event_created_at DESC LIMIT 1
""", """,
(public_key,), (merchant_id,),
) )
return row[0] if row else 0 return row[0] if row else 0
@ -644,8 +705,13 @@ async def get_customers(merchant_id: str) -> List[Customer]:
return [Customer.from_row(row) for row in rows] return [Customer.from_row(row) for row in rows]
async def get_all_customers() -> List[Customer]: async def get_all_unique_customers() -> List[Customer]:
rows = await db.fetchall("SELECT * FROM nostrmarket.customers") q = """
SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at)
FROM nostrmarket.customers
GROUP BY public_key
"""
rows = await db.fetchall(q)
return [Customer.from_row(row) for row in rows] return [Customer.from_row(row) for row in rows]
@ -658,15 +724,15 @@ async def update_customer_profile(
) )
async def increment_customer_unread_messages(public_key: str): async def increment_customer_unread_messages(merchant_id: str, public_key: str):
await db.execute( await db.execute(
f"UPDATE nostrmarket.customers SET unread_messages = unread_messages + 1 WHERE public_key = ?", f"UPDATE nostrmarket.customers SET unread_messages = unread_messages + 1 WHERE merchant_id = ? AND public_key = ?",
(public_key,), (merchant_id, public_key,),
) )
#??? two merchants
async def update_customer_no_unread_messages(public_key: str): async def update_customer_no_unread_messages(merchant_id: str, public_key: str):
await db.execute( await db.execute(
f"UPDATE nostrmarket.customers SET unread_messages = 0 WHERE public_key = ?", f"UPDATE nostrmarket.customers SET unread_messages = 0 WHERE merchant_id =? AND public_key = ?",
(public_key,), (merchant_id, public_key,),
) )

View file

@ -75,14 +75,6 @@ def copy_x(output, x32, y32, data):
return 1 return 1
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)
except ValueError:
return None, s
def normalize_public_key(pubkey: str) -> str: def normalize_public_key(pubkey: str) -> str:
if pubkey.startswith("npub1"): if pubkey.startswith("npub1"):
_, decoded_data = bech32_decode(pubkey) _, decoded_data = bech32_decode(pubkey)

View file

@ -141,3 +141,30 @@ async def m001_initial(db):
); );
""" """
) )
async def m002_update_stall_and_product(db):
await db.execute(
"ALTER TABLE nostrmarket.stalls ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;"
)
await db.execute(
"ALTER TABLE nostrmarket.stalls ADD COLUMN event_id TEXT;"
)
await db.execute(
"ALTER TABLE nostrmarket.stalls ADD COLUMN event_created_at INTEGER;"
)
await db.execute(
"ALTER TABLE nostrmarket.products ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;"
)
await db.execute(
"ALTER TABLE nostrmarket.products ADD COLUMN event_id TEXT;"
)
await db.execute(
"ALTER TABLE nostrmarket.products ADD COLUMN event_created_at INTEGER;"
)
async def m003_update_direct_message_type(db):
await db.execute(
"ALTER TABLE nostrmarket.direct_messages ADD COLUMN type INTEGER NOT NULL DEFAULT -1;"
)

View file

@ -1,8 +1,9 @@
import json import json
import time import time
from abc import abstractmethod from abc import abstractmethod
from enum import Enum
from sqlite3 import Row from sqlite3 import Row
from typing import List, Optional, Tuple from typing import Any, List, Optional, Tuple
from pydantic import BaseModel from pydantic import BaseModel
@ -120,6 +121,7 @@ class Merchant(PartialMerchant, Nostrable):
######################################## ZONES ######################################## ######################################## ZONES ########################################
class PartialZone(BaseModel): class PartialZone(BaseModel):
id: Optional[str]
name: Optional[str] name: Optional[str]
currency: str currency: str
cost: float cost: float
@ -140,19 +142,22 @@ class Zone(PartialZone):
class StallConfig(BaseModel): class StallConfig(BaseModel):
"""Last published nostr event id for this Stall"""
event_id: Optional[str]
image_url: Optional[str] image_url: Optional[str]
description: Optional[str] description: Optional[str]
class PartialStall(BaseModel): class PartialStall(BaseModel):
id: Optional[str]
wallet: str wallet: str
name: str name: str
currency: str = "sat" currency: str = "sat"
shipping_zones: List[Zone] = [] shipping_zones: List[Zone] = []
config: StallConfig = StallConfig() config: StallConfig = StallConfig()
pending: bool = False
"""Last published nostr event for this Stall"""
event_id: Optional[str]
event_created_at: Optional[int]
def validate_stall(self): def validate_stall(self):
for z in self.shipping_zones: for z in self.shipping_zones:
@ -189,7 +194,7 @@ class Stall(PartialStall, Nostrable):
pubkey=pubkey, pubkey=pubkey,
created_at=round(time.time()), created_at=round(time.time()),
kind=5, kind=5,
tags=[["e", self.config.event_id]], tags=[["e", self.event_id]],
content=f"Stall '{self.name}' deleted", content=f"Stall '{self.name}' deleted",
) )
delete_event.id = delete_event.event_id delete_event.id = delete_event.event_id
@ -204,24 +209,29 @@ class Stall(PartialStall, Nostrable):
return stall return stall
######################################## STALLS ######################################## ######################################## PRODUCTS ########################################
class ProductConfig(BaseModel): class ProductConfig(BaseModel):
event_id: Optional[str]
description: Optional[str] description: Optional[str]
currency: Optional[str] currency: Optional[str]
class PartialProduct(BaseModel): class PartialProduct(BaseModel):
id: Optional[str]
stall_id: str stall_id: str
name: str name: str
categories: List[str] = [] categories: List[str] = []
images: List[str] = [] images: List[str] = []
price: float price: float
quantity: int quantity: int
pending: bool = False
config: ProductConfig = ProductConfig() config: ProductConfig = ProductConfig()
"""Last published nostr event for this Product"""
event_id: Optional[str]
event_created_at: Optional[int]
class Product(PartialProduct, Nostrable): class Product(PartialProduct, Nostrable):
id: str id: str
@ -255,7 +265,7 @@ class Product(PartialProduct, Nostrable):
pubkey=pubkey, pubkey=pubkey,
created_at=round(time.time()), created_at=round(time.time()),
kind=5, kind=5,
tags=[["e", self.config.event_id]], tags=[["e", self.event_id]],
content=f"Product '{self.name}' deleted", content=f"Product '{self.name}' deleted",
) )
delete_event.id = delete_event.event_id delete_event.id = delete_event.event_id
@ -300,7 +310,7 @@ class OrderExtra(BaseModel):
@classmethod @classmethod
async def from_products(cls, products: List[Product]): async def from_products(cls, products: List[Product]):
currency = products[0].config.currency currency = products[0].config.currency if len(products) else "sat"
exchange_rate = ( exchange_rate = (
(await btc_price(currency)) if currency and currency != "sat" else 1 (await btc_price(currency)) if currency and currency != "sat" else 1
) )
@ -401,14 +411,34 @@ class PaymentRequest(BaseModel):
######################################## MESSAGE ######################################## ######################################## MESSAGE ########################################
class DirectMessageType(Enum):
"""Various types os direct messages."""
PLAIN_TEXT = -1
CUSTOMER_ORDER = 0
PAYMENT_REQUEST = 1
ORDER_PAID_OR_SHIPPED = 2
class PartialDirectMessage(BaseModel): class PartialDirectMessage(BaseModel):
event_id: Optional[str] event_id: Optional[str]
event_created_at: Optional[int] event_created_at: Optional[int]
message: str message: str
public_key: str public_key: str
type: int = DirectMessageType.PLAIN_TEXT.value
incoming: bool = False incoming: bool = False
time: Optional[int] time: Optional[int]
@classmethod
def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]:
try:
msg_json = json.loads(msg)
if "type" in msg_json:
return DirectMessageType(msg_json["type"]), msg_json
return DirectMessageType.PLAIN_TEXT, None
except Exception:
return DirectMessageType.PLAIN_TEXT, None
class DirectMessage(PartialDirectMessage): class DirectMessage(PartialDirectMessage):
id: str id: str
@ -419,6 +449,7 @@ class DirectMessage(PartialDirectMessage):
return dm return dm
######################################## CUSTOMERS ######################################## ######################################## CUSTOMERS ########################################
@ -437,5 +468,5 @@ class Customer(BaseModel):
@classmethod @classmethod
def from_row(cls, row: Row) -> "Customer": def from_row(cls, row: Row) -> "Customer":
customer = cls(**dict(row)) customer = cls(**dict(row))
customer.profile = CustomerProfile(**json.loads(row["meta"])) customer.profile = CustomerProfile(**json.loads(row["meta"])) if "meta" in row else None
return customer return customer

View file

@ -2,7 +2,7 @@ import asyncio
import json import json
from asyncio import Queue from asyncio import Queue
from threading import Thread from threading import Thread
from typing import Callable from typing import Callable, List
from loguru import logger from loguru import logger
from websocket import WebSocketApp from websocket import WebSocketApp
@ -18,11 +18,6 @@ class NostrClient:
self.send_req_queue: Queue = Queue() self.send_req_queue: Queue = Queue()
self.ws: WebSocketApp = None self.ws: WebSocketApp = None
async def restart(self):
await self.send_req_queue.put(ValueError("Restarting NostrClient..."))
await self.recieve_event_queue.put(ValueError("Restarting NostrClient..."))
self.ws.close()
self.ws = None
async def connect_to_nostrclient_ws( async def connect_to_nostrclient_ws(
self, on_open: Callable, on_message: Callable self, on_open: Callable, on_message: Callable
@ -96,36 +91,80 @@ class NostrClient:
["REQ", f"direct-messages-out:{public_key}", out_messages_filter] ["REQ", f"direct-messages-out:{public_key}", out_messages_filter]
) )
async def subscribe_to_merchant_events(self, public_key: str, since: int): logger.debug(f"Subscribed to direct-messages '{public_key}'.")
async def subscribe_to_stall_events(self, public_key: str, since: int):
stall_filter = {"kinds": [30017], "authors": [public_key]} stall_filter = {"kinds": [30017], "authors": [public_key]}
product_filter = {"kinds": [30018], "authors": [public_key]} if since and since != 0:
stall_filter["since"] = since
await self.send_req_queue.put( await self.send_req_queue.put(
["REQ", f"stall-events:{public_key}", stall_filter] ["REQ", f"stall-events:{public_key}", stall_filter]
) )
logger.debug(f"Subscribed to stall-events: '{public_key}'.")
async def subscribe_to_product_events(self, public_key: str, since: int):
product_filter = {"kinds": [30018], "authors": [public_key]}
if since and since != 0:
product_filter["since"] = since
await self.send_req_queue.put( await self.send_req_queue.put(
["REQ", f"product-events:{public_key}", product_filter] ["REQ", f"product-events:{public_key}", product_filter]
) )
logger.debug(f"Subscribed to product-events: '{public_key}'.")
async def subscribe_to_user_profile(self, public_key: str, since: int): async def subscribe_to_user_profile(self, public_key: str, since: int):
profile_filter = {"kinds": [0], "authors": [public_key]} profile_filter = {"kinds": [0], "authors": [public_key]}
if since and since != 0: if since and since != 0:
profile_filter["since"] = since + 1 profile_filter["since"] = since + 1
await self.send_req_queue.put( # Disabled for now. The number of clients can grow large.
["REQ", f"user-profile-events:{public_key}", profile_filter] # Some relays only allow a small number of subscriptions.
) # There is the risk that more important subscriptions will be blocked.
# 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}"])
logger.debug(f"Unsubscribed from direct-messages '{public_key}'.")
async def unsubscribe_from_merchant_events(self, public_key: str): async def unsubscribe_from_merchant_events(self, public_key: str):
await self.send_req_queue.put(["CLOSE", f"stall-events:{public_key}"]) await self.send_req_queue.put(["CLOSE", f"stall-events:{public_key}"])
await self.send_req_queue.put(["CLOSE", f"product-events:{public_key}"]) await self.send_req_queue.put(["CLOSE", f"product-events:{public_key}"])
def stop(self): logger.debug(f"Unsubscribed from stall-events and product-events '{public_key}'.")
try:
self.ws.close() async def restart(self, public_keys: List[str]):
except Exception as ex: await self.unsubscribe_merchants(public_keys)
logger.warning(ex) # Give some time for the CLOSE events to propagate before restarting
await asyncio.sleep(10)
logger.info("Restating NostrClient...")
await self.send_req_queue.put(ValueError("Restarting NostrClient..."))
await self.recieve_event_queue.put(ValueError("Restarting NostrClient..."))
self.ws.close()
self.ws = None
async def stop(self, public_keys: List[str]):
await self.unsubscribe_merchants(public_keys)
# Give some time for the CLOSE events to propagate before closing the connection
await asyncio.sleep(10)
self.ws.close()
self.ws = None
async def unsubscribe_merchants(self, public_keys: List[str]):
for pk in public_keys:
try:
await self.unsubscribe_from_direct_messages(pk)
await self.unsubscribe_from_merchant_events(pk)
except Exception as ex:
logger.warning(ex)

View file

@ -3,6 +3,7 @@ from typing import List, Optional, Tuple
from loguru import logger from loguru import logger
from lnbits.bolt11 import decode
from lnbits.core import create_invoice, get_wallet from lnbits.core import create_invoice, get_wallet
from lnbits.core.services import websocketUpdater from lnbits.core.services import websocketUpdater
@ -12,6 +13,8 @@ from .crud import (
create_customer, create_customer,
create_direct_message, create_direct_message,
create_order, create_order,
create_product,
create_stall,
get_customer, get_customer,
get_merchant_by_pubkey, get_merchant_by_pubkey,
get_order, get_order,
@ -23,17 +26,21 @@ from .crud import (
get_zone, get_zone,
increment_customer_unread_messages, increment_customer_unread_messages,
update_customer_profile, update_customer_profile,
update_order,
update_order_paid_status, update_order_paid_status,
update_order_shipped_status,
update_product, update_product,
update_product_quantity, update_product_quantity,
update_stall, update_stall,
) )
from .helpers import order_from_json
from .models import ( from .models import (
Customer, Customer,
DirectMessage,
DirectMessageType,
Merchant, Merchant,
Nostrable, Nostrable,
Order, Order,
OrderContact,
OrderExtra, OrderExtra,
OrderItem, OrderItem,
OrderStatusUpdate, OrderStatusUpdate,
@ -42,6 +49,7 @@ from .models import (
PaymentOption, PaymentOption,
PaymentRequest, PaymentRequest,
Product, Product,
Stall,
) )
from .nostr.event import NostrEvent from .nostr.event import NostrEvent
@ -126,10 +134,12 @@ async def update_merchant_to_nostr(
products = await get_products(merchant.id, stall.id) products = await get_products(merchant.id, stall.id)
for product in products: for product in products:
event = await sign_and_send_to_nostr(merchant, product, delete_merchant) event = await sign_and_send_to_nostr(merchant, product, delete_merchant)
product.config.event_id = event.id product.event_id = event.id
product.event_created_at = event.created_at
await update_product(merchant.id, product) await update_product(merchant.id, product)
event = await sign_and_send_to_nostr(merchant, stall, delete_merchant) event = await sign_and_send_to_nostr(merchant, stall, delete_merchant)
stall.config.event_id = event.id stall.event_id = event.id
stall.event_created_at = event.created_at
await update_stall(merchant.id, stall) await update_stall(merchant.id, stall)
if delete_merchant: if delete_merchant:
# merchant profile updates not supported yet # merchant profile updates not supported yet
@ -163,6 +173,16 @@ async def handle_order_paid(order_id: str, merchant_pubkey: str):
# todo: lock # todo: lock
success, message = await update_products_for_order(merchant, order) success, message = await update_products_for_order(merchant, order)
await notify_client_of_order_status(order, merchant, success, message) await notify_client_of_order_status(order, merchant, success, message)
await websocketUpdater(
merchant.id,
json.dumps(
{
"type": "order-paid",
"orderId": order_id,
}
),
)
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
@ -179,12 +199,26 @@ async def notify_client_of_order_status(
shipped=order.shipped, shipped=order.shipped,
) )
dm_content = json.dumps( dm_content = json.dumps(
order_status.dict(), separators=(",", ":"), ensure_ascii=False {"type": DirectMessageType.ORDER_PAID_OR_SHIPPED.value, **order_status.dict()},
separators=(",", ":"),
ensure_ascii=False,
) )
else: else:
dm_content = f"Order cannot be fulfilled. Reason: {message}" dm_content = f"Order cannot be fulfilled. Reason: {message}"
dm_event = merchant.build_dm_event(dm_content, order.public_key) dm_event = merchant.build_dm_event(dm_content, order.public_key)
dm = PartialDirectMessage(
event_id=dm_event.id,
event_created_at=dm_event.created_at,
message=dm_content,
public_key=order.public_key,
type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value
if success
else DirectMessageType.PLAIN_TEXT.value,
)
await create_direct_message(merchant.id, dm)
await nostr_client.publish_nostr_event(dm_event) await nostr_client.publish_nostr_event(dm_event)
@ -201,7 +235,7 @@ async def update_products_for_order(
for p in products: for p in products:
product = await update_product_quantity(p.id, p.quantity) product = await update_product_quantity(p.id, p.quantity)
event = await sign_and_send_to_nostr(merchant, product) event = await sign_and_send_to_nostr(merchant, product)
product.config.event_id = event.id product.event_id = event.id
await update_product(merchant.id, product) await update_product(merchant.id, product)
return True, "ok" return True, "ok"
@ -233,19 +267,84 @@ async def compute_products_new_quantity(
async def process_nostr_message(msg: str): async def process_nostr_message(msg: str):
try: try:
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
event = NostrEvent(**event) event = NostrEvent(**event)
print("kind: ", event.kind, ": ", msg)
if event.kind == 0: if event.kind == 0:
await _handle_customer_profile_update(event) await _handle_customer_profile_update(event)
if event.kind == 4: elif event.kind == 4:
_, merchant_public_key = subscription_id.split(":") _, merchant_public_key = subscription_id.split(":")
await _handle_nip04_message(merchant_public_key, event) await _handle_nip04_message(merchant_public_key, event)
elif event.kind == 30017:
await _handle_stall(event)
elif event.kind == 30018:
await _handle_product(event)
return return
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
async def create_or_update_order_from_dm(merchant_id: str, merchant_pubkey: str, dm: DirectMessage):
type, value = PartialDirectMessage.parse_message(dm.message)
if "id" not in value:
return
if type == DirectMessageType.CUSTOMER_ORDER:
order = await extract_order_from_dm(merchant_id, merchant_pubkey, dm, value)
new_order = await create_order(merchant_id, order)
if new_order.stall_id == "None" and order.stall_id != "None":
await update_order(merchant_id, order.id, **{
"stall_id": order.stall_id,
"extra_data": json.dumps(order.extra.dict())
})
return
if type == DirectMessageType.PAYMENT_REQUEST:
payment_request = PaymentRequest(**value)
pr = next((o.link for o in payment_request.payment_options if o.type == "ln"), None)
if not pr:
return
invoice = decode(pr)
await update_order(merchant_id, payment_request.id, **{
"total": invoice.amount_msat / 1000,
"invoice_id": invoice.payment_hash
})
return
if type == DirectMessageType.ORDER_PAID_OR_SHIPPED:
order_update = OrderStatusUpdate(**value)
if order_update.paid:
await update_order_paid_status(order_update.id, True)
if order_update.shipped:
await update_order_shipped_status(merchant_id, order_update.id, True)
async def extract_order_from_dm(merchant_id: str, merchant_pubkey: str, dm: DirectMessage, value):
order_items = [OrderItem(**i) for i in value.get("items", [])]
products = await get_products_by_ids(merchant_id, [p.product_id for p in order_items])
extra = await OrderExtra.from_products(products)
order = Order(
id=value.get("id"),
event_id=dm.event_id,
event_created_at=dm.event_created_at,
public_key=dm.public_key,
merchant_public_key=merchant_pubkey,
shipping_id=value.get("shipping_id", "None"),
items=order_items,
contact=OrderContact(**value.get("contact")) if value.get("contact") else None,
address=value.get("address"),
stall_id=products[0].stall_id if len(products) else "None",
invoice_id="None",
total=0,
extra=extra
)
return order
async def _handle_nip04_message(merchant_public_key: str, event: NostrEvent): async def _handle_nip04_message(merchant_public_key: str, event: NostrEvent):
merchant = await get_merchant_by_pubkey(merchant_public_key) merchant = await get_merchant_by_pubkey(merchant_public_key)
assert merchant, f"Merchant not found for public key '{merchant_public_key}'" assert merchant, f"Merchant not found for public key '{merchant_public_key}'"
@ -270,7 +369,7 @@ async def _handle_incoming_dms(
if not customer: if not customer:
await _handle_new_customer(event, merchant) await _handle_new_customer(event, merchant)
else: else:
await increment_customer_unread_messages(event.pubkey) await increment_customer_unread_messages(merchant.id, event.pubkey)
dm_reply = await _handle_dirrect_message( dm_reply = await _handle_dirrect_message(
merchant.id, merchant.id,
@ -282,6 +381,14 @@ async def _handle_incoming_dms(
) )
if dm_reply: if dm_reply:
dm_event = merchant.build_dm_event(dm_reply, event.pubkey) dm_event = merchant.build_dm_event(dm_reply, event.pubkey)
dm = PartialDirectMessage(
event_id=dm_event.id,
event_created_at=dm_event.created_at,
message=dm_reply,
public_key=event.pubkey,
type=DirectMessageType.PAYMENT_REQUEST.value,
)
await create_direct_message(merchant.id, dm)
await nostr_client.publish_nostr_event(dm_event) await nostr_client.publish_nostr_event(dm_event)
@ -289,12 +396,14 @@ async def _handle_outgoing_dms(
event: NostrEvent, merchant: Merchant, clear_text_msg: str event: NostrEvent, merchant: Merchant, clear_text_msg: str
): ):
sent_to = event.tag_values("p") sent_to = event.tag_values("p")
type, _ = PartialDirectMessage.parse_message(clear_text_msg)
if len(sent_to) != 0: if len(sent_to) != 0:
dm = PartialDirectMessage( dm = PartialDirectMessage(
event_id=event.id, event_id=event.id,
event_created_at=event.created_at, event_created_at=event.created_at,
message=clear_text_msg, # exclude if json message=clear_text_msg,
public_key=sent_to[0], public_key=sent_to[0],
type=type.value
) )
await create_direct_message(merchant.id, dm) await create_direct_message(merchant.id, dm)
@ -307,22 +416,24 @@ async def _handle_dirrect_message(
event_created_at: int, event_created_at: int,
msg: str, msg: str,
) -> Optional[str]: ) -> Optional[str]:
order, text_msg = order_from_json(msg) type, order = PartialDirectMessage.parse_message(msg)
try: try:
dm = PartialDirectMessage( dm = PartialDirectMessage(
event_id=event_id, event_id=event_id,
event_created_at=event_created_at, event_created_at=event_created_at,
message=text_msg, message=msg,
public_key=from_pubkey, public_key=from_pubkey,
incoming=True, incoming=True,
type=type.value,
) )
await create_direct_message(merchant_id, dm) new_dm = await create_direct_message(merchant_id, dm)
# todo: do the same for new order
await websocketUpdater( await websocketUpdater(
merchant_id, merchant_id,
json.dumps({"type": "new-direct-message", "customerPubkey": from_pubkey}), json.dumps({"type": "new-direct-message", "customerPubkey": from_pubkey, "data": new_dm.dict()}),
) )
if order: if type == DirectMessageType.CUSTOMER_ORDER:
order["public_key"] = from_pubkey order["public_key"] = from_pubkey
order["merchant_public_key"] = merchant_public_key order["merchant_public_key"] = merchant_public_key
order["event_id"] = event_id order["event_id"] = event_id
@ -338,17 +449,23 @@ async def _handle_dirrect_message(
async def _handle_new_order(order: PartialOrder) -> Optional[str]: async def _handle_new_order(order: PartialOrder) -> Optional[str]:
order.validate_order() order.validate_order()
first_product_id = order.items[0].product_id try:
wallet_id = await get_wallet_for_product(first_product_id) first_product_id = order.items[0].product_id
assert wallet_id, f"Cannot find wallet id for product id: {first_product_id}" wallet_id = await get_wallet_for_product(first_product_id)
assert wallet_id, f"Cannot find wallet id for product id: {first_product_id}"
wallet = await get_wallet(wallet_id) wallet = await get_wallet(wallet_id)
assert wallet, f"Cannot find wallet for product id: {first_product_id}" assert wallet, f"Cannot find wallet for product id: {first_product_id}"
new_order = await create_new_order(order.merchant_public_key, order)
if new_order: payment_req = await create_new_order(order.merchant_public_key, order)
return json.dumps(new_order.dict(), separators=(",", ":"), ensure_ascii=False) except Exception as e:
payment_req = PaymentRequest(id=order.id, message=str(e), payment_options=[])
if payment_req:
response = {"type": DirectMessageType.PAYMENT_REQUEST.value, **payment_req.dict()}
return json.dumps(response, separators=(",", ":"), ensure_ascii=False)
return None return None
@ -372,3 +489,58 @@ async def _handle_customer_profile_update(event: NostrEvent):
) )
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
async def _handle_stall(event: NostrEvent):
try:
merchant = await get_merchant_by_pubkey(event.pubkey)
assert merchant, f"Merchant not found for public key '{event.pubkey}'"
stall_json = json.loads(event.content)
if "id" not in stall_json:
return
stall = Stall(
id=stall_json["id"],
name=stall_json.get("name", "Recoverd Stall (no name)"),
wallet="",
currency=stall_json.get("currency", "sat"),
shipping_zones=stall_json.get("shipping", []),
pending=True,
event_id=event.id,
event_created_at=event.created_at,
)
stall.config.description = stall_json.get("description", "")
await create_stall(merchant.id, stall)
except Exception as ex:
logger.error(ex)
async def _handle_product(event: NostrEvent):
try:
merchant = await get_merchant_by_pubkey(event.pubkey)
assert merchant, f"Merchant not found for public key '{event.pubkey}'"
product_json = json.loads(event.content)
assert "id" in product_json, "Product is missing ID"
assert "stall_id" in product_json, "Product is missing Stall ID"
product = Product(
id=product_json["id"],
stall_id=product_json["stall_id"],
name=product_json.get("name", "Recoverd Product (no name)"),
images=product_json.get("images", []),
categories=event.tag_values("t"),
price=product_json.get("price", 0),
quantity=product_json.get("quantity", 0),
pending=True,
event_id=event.id,
event_created_at=event.created_at,
)
product.config.description = product_json.get("description", "")
product.config.currency = product_json.get("currency", "sat")
await create_product(merchant.id, product)
except Exception as ex:
logger.error(ex)

View file

@ -110,7 +110,8 @@
</q-inner-loading> </q-inner-loading>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(qrCodeDialog.data.payment_request)">Copy invoice</q-btn> <q-btn v-if="qrCodeDialog.data.payment_request" outline color="grey"
@click="copyText(qrCodeDialog.data.payment_request)">Copy invoice</q-btn>
<q-btn @click="closeQrCodeDialog" flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn @click="closeQrCodeDialog" flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>

View file

@ -67,6 +67,7 @@ async function directMessages(path) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
sendDirectMesage: async function () { sendDirectMesage: async function () {
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
@ -106,12 +107,13 @@ async function directMessages(path) {
this.showAddPublicKey = false this.showAddPublicKey = false
} }
}, },
handleNewMessage: async function (data) { handleNewMessage: async function (dm) {
if (data.customerPubkey === this.activePublicKey) { if (dm.customerPubkey === this.activePublicKey) {
await this.getDirectMessages(this.activePublicKey) this.messages.push(dm.data)
} else { this.focusOnChatBox(this.messages.length - 1)
await this.getCustomers() // focus back on input box
} }
this.getCustomersDebounced()
}, },
showClientOrders: function () { showClientOrders: function () {
this.$emit('customer-selected', this.activePublicKey) this.$emit('customer-selected', this.activePublicKey)
@ -133,6 +135,7 @@ async function directMessages(path) {
}, },
created: async function () { created: async function () {
await this.getCustomers() await this.getCustomers()
this.getCustomersDebounced = _.debounce(this.getCustomers, 2000, false)
} }
}) })
} }

View file

@ -1,72 +1,47 @@
<div> <div>
<div class="row q-mb-md"> <div class="row q-mb-md">
<div class="col-3 q-pr-lg"> <div class="col-md-4 col-sm-6 q-pr-lg">
<q-select <q-select v-model="search.publicKey"
v-model="search.publicKey" :options="customerOptions" label="Customer" emit-value
:options="customers.map(c => ({label: buildCustomerLabel(c), value: c.public_key}))" class="text-wrap">
label="Customer"
emit-value
class="text-wrap"
>
</q-select> </q-select>
</div> </div>
<div class="col-3 q-pr-lg"> <div class="col-md-2 col-sm-6 q-pr-lg">
<q-select <q-select v-model="search.isPaid" :options="ternaryOptions" label="Paid" emit-value>
v-model="search.isPaid"
:options="ternaryOptions"
label="Paid"
emit-value
>
</q-select> </q-select>
</div> </div>
<div class="col-3 q-pr-lg"> <div class="col-md-2 col-sm-6 q-pr-lg">
<q-select <q-select v-model="search.isShipped" :options="ternaryOptions" label="Shipped" emit-value>
v-model="search.isShipped"
:options="ternaryOptions"
label="Shipped"
emit-value
>
</q-select> </q-select>
</div> </div>
<div class="col-3"> <div class="col-md-4 col-sm-6">
<q-btn
unelevated <q-btn-dropdown @click="getOrders()" :disable="search.restoring" outline unelevated split class="q-pt-md float-right"
outline :label="search.restoring ? 'Restoring Orders...' : 'Search Orders'">
icon="search" <q-spinner v-if="search.restoring" color="primary" size="2.55em" class="q-pt-md float-right"></q-spinner>
@click="getOrders()" <q-item @click="restoreOrders" clickable v-close-popup>
class="float-right" <q-item-section>
>Search Orders</q-btn <q-item-label>Restore Orders</q-item-label>
> <q-item-label caption>Restore previous orders from Nostr</q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div> </div>
</div> </div>
<div class="row q-mt-md"> <div class="row q-mt-md">
<div class="col"> <div class="col">
<q-table <q-table flat dense :data="orders" row-key="id" :columns="ordersTable.columns"
flat :pagination.sync="ordersTable.pagination" :filter="filter">
dense
:data="orders"
row-key="id"
:columns="ordersTable.columns"
:pagination.sync="ordersTable.pagination"
:filter="filter"
>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn size="sm" color="accent" round dense @click="props.row.expanded= !props.row.expanded"
size="sm" :icon="props.row.expanded? 'remove' : 'add'" />
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td> </q-td>
<q-td key="id" :props="props"> <q-td key="id" :props="props">
{{toShortId(props.row.id)}} {{toShortId(props.row.id)}}
<q-badge v-if="props.row.isNew" color="orange">new</q-badge></q-td <q-badge v-if="props.row.isNew" color="orange">new</q-badge></q-td>
>
<q-td key="total" :props="props"> <q-td key="total" :props="props">
{{satBtc(props.row.total)}} {{satBtc(props.row.total)}}
</q-td> </q-td>
@ -77,33 +52,21 @@
</q-td> </q-td>
<q-td key="paid" :props="props"> <q-td key="paid" :props="props">
<q-checkbox <q-checkbox v-model="props.row.paid" :label="props.row.paid ? 'Yes' : 'No'" disable readonly
v-model="props.row.paid" size="sm"></q-checkbox>
:label="props.row.paid ? 'Yes' : 'No'"
disable
readonly
size="sm"
></q-checkbox>
</q-td> </q-td>
<q-td key="shipped" :props="props"> <q-td key="shipped" :props="props">
<q-checkbox <q-checkbox v-model="props.row.shipped" @input="showShipOrderDialog(props.row)"
v-model="props.row.shipped" :label="props.row.shipped ? 'Yes' : 'No'" size="sm"></q-checkbox>
@input="showShipOrderDialog(props.row)"
:label="props.row.shipped ? 'Yes' : 'No'"
size="sm"
></q-checkbox>
</q-td> </q-td>
<q-td key="public_key" :props="props"> <q-td key="public_key" :props="props">
<span <span @click="customerSelected(props.row.public_key)" class="cursor-pointer">
@click="customerSelected(props.row.public_key)"
class="cursor-pointer"
>
{{toShortId(props.row.public_key)}} {{toShortId(props.row.public_key)}}
</span> </span>
</q-td> </q-td>
<q-td key="time" :props="props"> <q-td key="event_created_at" :props="props">
{{formatDate(props.row.time)}} {{formatDate(props.row.event_created_at)}}
</q-td> </q-td>
</q-tr> </q-tr>
<q-tr v-if="props.row.expanded" :props="props"> <q-tr v-if="props.row.expanded" :props="props">
@ -124,10 +87,7 @@
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg"></div> <div class="col-3 q-pr-lg"></div>
<div class="col-8"> <div class="col-8">
<div <div v-for="item in props.row.items" class="row items-center no-wrap q-mb-md">
v-for="item in props.row.items"
class="row items-center no-wrap q-mb-md"
>
<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-4"> <div class="col-4">
@ -141,34 +101,18 @@
</div> </div>
<div class="col-1"></div> <div class="col-1"></div>
</div> </div>
<div <div v-if="props.row.extra.currency !== 'sat'" class="row items-center no-wrap q-mb-md q-mt-md">
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-3 q-pr-lg">Exchange Rate (1 BTC):</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-input <q-input filled dense readonly disabled
filled :value="formatFiat(props.row.extra.btc_price, props.row.extra.currency)" type="text"></q-input>
dense
readonly
disabled
:value="formatFiat(props.row.extra.btc_price, props.row.extra.currency)"
type="text"
></q-input>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</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">
<q-input <q-input filled dense readonly disabled v-model.trim="props.row.id" type="text"></q-input>
filled
dense
readonly
disabled
v-model.trim="props.row.id"
type="text"
></q-input>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
@ -176,14 +120,7 @@
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Address:</div> <div class="col-3 q-pr-lg">Address:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-input <q-input filled dense readonly disabled v-model.trim="props.row.address" type="text"></q-input>
filled
dense
readonly
disabled
v-model.trim="props.row.address"
type="text"
></q-input>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
@ -191,63 +128,29 @@
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Customer Public Key:</div> <div class="col-3 q-pr-lg">Customer Public Key:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-input <q-input filled dense readonly disabled v-model.trim="props.row.public_key" type="text"></q-input>
filled
dense
readonly
disabled
v-model.trim="props.row.public_key"
type="text"
></q-input>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
<div <div v-if="props.row.contact.phone" class="row items-center no-wrap q-mb-md">
v-if="props.row.contact.phone"
class="row items-center no-wrap q-mb-md"
>
<div class="col-3 q-pr-lg">Phone:</div> <div class="col-3 q-pr-lg">Phone:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-input <q-input filled dense readonly disabled v-model.trim="props.row.contact.phone" type="text"></q-input>
filled
dense
readonly
disabled
v-model.trim="props.row.contact.phone"
type="text"
></q-input>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
<div <div v-if="props.row.contact.email" class="row items-center no-wrap q-mb-md">
v-if="props.row.contact.email"
class="row items-center no-wrap q-mb-md"
>
<div class="col-3 q-pr-lg">Email:</div> <div class="col-3 q-pr-lg">Email:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-input <q-input filled dense readonly disabled v-model.trim="props.row.contact.email" type="text"></q-input>
filled
dense
readonly
disabled
v-model.trim="props.row.contact.email"
type="text"
></q-input>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Invoice ID:</div> <div class="col-3 q-pr-lg">Invoice ID:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-input <q-input filled dense readonly disabled v-model.trim="props.row.invoice_id" type="text"></q-input>
filled
dense
readonly
disabled
v-model.trim="props.row.invoice_id"
type="text"
></q-input>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
@ -260,28 +163,16 @@
<q-dialog v-model="showShipDialog" position="top"> <q-dialog v-model="showShipDialog" position="top">
<q-card v-if="selectedOrder" class="q-pa-lg q-pt-xl" style="width: 500px"> <q-card v-if="selectedOrder" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="updateOrderShipped" class="q-gutter-md"> <q-form @submit="updateOrderShipped" class="q-gutter-md">
<q-input <q-input filled dense v-model.trim="shippingMessage" label="Shipping Message" type="textarea"
filled rows="4"></q-input>
dense
v-model.trim="shippingMessage"
label="Shipping Message"
type="textarea"
rows="4"
></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn unelevated color="primary" type="submit"
unelevated :label="selectedOrder.shipped? 'Unship Order' : 'Ship Order'"></q-btn>
color="primary"
type="submit"
:label="selectedOrder.shipped? 'Unship Order' : 'Ship Order'"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto" <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
>Cancel</q-btn
>
</div> </div>
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>

View file

@ -8,8 +8,8 @@ async function orderList(path) {
watch: { watch: {
customerPubkeyFilter: async function (n) { customerPubkeyFilter: async function (n) {
this.search.publicKey = n this.search.publicKey = n
this.search.isPaid = {label: 'All', id: null} this.search.isPaid = { label: 'All', id: null }
this.search.isShipped = {label: 'All', id: null} this.search.isShipped = { label: 'All', id: null }
await this.getOrders() await this.getOrders()
} }
}, },
@ -22,7 +22,7 @@ async function orderList(path) {
showShipDialog: false, showShipDialog: false,
filter: '', filter: '',
search: { search: {
publicKey: '', publicKey: null,
isPaid: { isPaid: {
label: 'All', label: 'All',
id: null id: null
@ -30,7 +30,8 @@ async function orderList(path) {
isShipped: { isShipped: {
label: 'All', label: 'All',
id: null id: null
} },
restoring: false
}, },
customers: [], customers: [],
ternaryOptions: [ ternaryOptions: [
@ -92,10 +93,10 @@ async function orderList(path) {
field: 'pubkey' field: 'pubkey'
}, },
{ {
name: 'time', name: 'event_created_at',
align: 'left', align: 'left',
label: 'Date', label: 'Created At',
field: 'time' field: 'event_created_at'
} }
], ],
pagination: { pagination: {
@ -104,6 +105,13 @@ async function orderList(path) {
} }
} }
}, },
computed: {
customerOptions: function () {
const options = this.customers.map(c => ({ label: this.buildCustomerLabel(c), value: c.public_key }))
options.unshift({ label: 'All', value: null, id: null })
return options
}
},
methods: { methods: {
toShortId: function (value) { toShortId: function (value) {
return value.substring(0, 5) + '...' + value.substring(value.length - 5) return value.substring(0, 5) + '...' + value.substring(value.length - 5)
@ -156,28 +164,48 @@ async function orderList(path) {
if (this.search.isShipped.id) { if (this.search.isShipped.id) {
query.push(`shipped=${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}?${query.join('&')}`, `/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 }))
console.log("### this.orders", this.orders)
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
getOrder: async function (orderId) { getOrder: async function (orderId) {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'GET', 'GET',
`/nostrmarket/api/v1/order/${orderId}`, `/nostrmarket/api/v1/order/${orderId}`,
this.inkey this.inkey
) )
return {...data, expanded: false, isNew: true} return { ...data, expanded: false, isNew: true }
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
restoreOrders: async function () {
try {
this.search.restoring = true
await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/order/restore`,
this.adminkey
)
await this.getOrders()
this.$q.notify({
type: 'positive',
message: 'Orders restored!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.search.restoring = false
}
},
updateOrderShipped: async function () { updateOrderShipped: async function () {
this.selectedOrder.shipped = !this.selectedOrder.shipped this.selectedOrder.shipped = !this.selectedOrder.shipped
try { try {
@ -213,8 +241,8 @@ async function orderList(path) {
showShipOrderDialog: function (order) { showShipOrderDialog: function (order) {
this.selectedOrder = order this.selectedOrder = order
this.shippingMessage = order.shipped this.shippingMessage = order.shipped
? `The order has been shipped! Order ID: '${order.id}' ` ? 'The order has been shipped!'
: `The order has NOT yet been shipped! Order ID: '${order.id}'` : 'The order has NOT yet been shipped!'
// do not change the status yet // do not change the status yet
this.selectedOrder.shipped = !order.shipped this.selectedOrder.shipped = !order.shipped
@ -225,7 +253,7 @@ async function orderList(path) {
}, },
getCustomers: async function () { getCustomers: async function () {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'GET', 'GET',
'/nostrmarket/api/v1/customer', '/nostrmarket/api/v1/customer',
this.inkey this.inkey
@ -244,6 +272,12 @@ async function orderList(path) {
c.public_key.length - 16 c.public_key.length - 16
)}` )}`
return label return label
},
orderPaid: function(orderId) {
const order = this.orders.find(o => o.id === orderId)
if (order) {
order.paid = true
}
} }
}, },
created: async function () { created: async function () {

View file

@ -10,54 +10,29 @@
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">ID:</div> <div class="col-3 q-pr-lg">ID:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-input <q-input filled dense readonly disabled v-model.trim="stall.id" type="text"></q-input>
filled
dense
readonly
disabled
v-model.trim="stall.id"
type="text"
></q-input>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Name:</div> <div class="col-3 q-pr-lg">Name:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-input <q-input filled dense v-model.trim="stall.name" type="text"></q-input>
filled
dense
v-model.trim="stall.name"
type="text"
></q-input>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Description:</div> <div class="col-3 q-pr-lg">Description:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-input <q-input filled dense v-model.trim="stall.config.description" type="textarea" rows="3"
filled label="Description"></q-input>
dense
v-model.trim="stall.config.description"
type="textarea"
rows="3"
label="Description"
></q-input>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Wallet:</div> <div class="col-3 q-pr-lg">Wallet:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-select <q-select filled dense emit-value v-model="stall.wallet" :options="walletOptions" label="Wallet *">
filled
dense
emit-value
v-model="stall.wallet"
:options="walletOptions"
label="Wallet *"
>
</q-select> </q-select>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
@ -65,51 +40,25 @@
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Currency:</div> <div class="col-3 q-pr-lg">Currency:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-select <q-select filled dense v-model="stall.currency" type="text" label="Unit" :options="currencies"></q-select>
filled
dense
v-model="stall.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Shipping Zones:</div> <div class="col-3 q-pr-lg">Shipping Zones:</div>
<div class="col-6 col-sm-8 q-pr-lg"> <div class="col-6 col-sm-8 q-pr-lg">
<q-select <q-select :options="filteredZoneOptions" filled dense multiple v-model.trim="stall.shipping_zones"
:options="filteredZoneOptions" label="Shipping Zones"></q-select>
filled
dense
multiple
v-model.trim="stall.shipping_zones"
label="Shipping Zones"
></q-select>
</div> </div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
</div> </div>
</div> </div>
<div class="row items-center q-mt-xl"> <div class="row items-center q-mt-xl">
<div class="col-6 q-pr-lg"> <div class="col-6 q-pr-lg">
<q-btn <q-btn unelevated color="secondary" class="float-left" @click="updateStall()">Update Stall</q-btn>
unelevated
color="secondary"
class="float-left"
@click="updateStall()"
>Update Stall</q-btn
>
</div> </div>
<div class="col-6"> <div class="col-6">
<q-btn <q-btn unelevated color="pink" icon="cancel" class="float-right" @click="deleteStall()">Delete Stall</q-btn>
unelevated
color="pink"
icon="cancel"
class="float-right"
@click="deleteStall()"
>Delete Stall</q-btn
>
</div> </div>
</div> </div>
</q-tab-panel> </q-tab-panel>
@ -117,14 +66,23 @@
<div v-if="stall"> <div v-if="stall">
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg"> <div class="col-3 q-pr-lg">
<q-btn
unelevated <q-btn-dropdown @click="showNewProductDialog()" unelevated split color="green" class="float-left"
color="green" label="New Product">
icon="plus" <q-item @click="showNewProductDialog()" clickable v-close-popup>
class="float-left" <q-item-section>
@click="showNewProductDialog()" <q-item-label>New Product</q-item-label>
>New Product</q-btn <q-item-label caption>Create a new product</q-item-label>
> </q-item-section>
</q-item>
<q-item @click="openSelectPendingProductDialog" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Product</q-item-label>
<q-item-label caption>Restore existing product from Nostr</q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div> </div>
<div class="col-6 col-sm-8 q-pr-lg"></div> <div class="col-6 col-sm-8 q-pr-lg"></div>
<div class="col-3 col-sm-1"></div> <div class="col-3 col-sm-1"></div>
@ -132,34 +90,15 @@
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-12"> <div class="col-12">
<q-table <q-table flat dense :data="products" row-key="id" :columns="productsTable.columns"
flat :pagination.sync="productsTable.pagination" :filter="productsFilter">
dense
:data="products"
row-key="id"
:columns="productsTable.columns"
:pagination.sync="productsTable.pagination"
:filter="productsFilter"
>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn size="sm" color="pink" dense @click="deleteProduct(props.row.id)" icon="delete" />
size="sm"
color="pink"
dense
@click="deleteProduct(props.row.id)"
icon="delete"
/>
</q-td> </q-td>
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn size="sm" color="accent" dense @click="editProduct(props.row)" icon="edit" />
size="sm"
color="accent"
dense
@click="editProduct(props.row)"
icon="edit"
/>
</q-td> </q-td>
<q-td key="id" :props="props"> {{props.row.id}} </q-td> <q-td key="id" :props="props"> {{props.row.id}} </q-td>
@ -186,112 +125,74 @@
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="orders"> <q-tab-panel name="orders">
<div v-if="stall"> <div v-if="stall">
<order-list <order-list :adminkey="adminkey" :inkey="inkey" :stall-id="stallId"
:adminkey="adminkey" @customer-selected="customerSelectedForOrder"></order-list>
:inkey="inkey"
:stall-id="stallId"
@customer-selected="customerSelectedForOrder"
></order-list>
</div> </div>
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </q-tab-panels>
<q-dialog v-model="productDialog.showDialog" position="top"> <q-dialog v-model="productDialog.showDialog" position="top">
<q-card v-if="stall" class="q-pa-lg q-pt-xl" style="width: 500px"> <q-card v-if="stall" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendProductFormData" class="q-gutter-md"> <q-form @submit="sendProductFormData" class="q-gutter-md">
<q-input <q-input filled dense v-model.trim="productDialog.data.name" label="Name"></q-input>
filled
dense
v-model.trim="productDialog.data.name"
label="Name"
></q-input>
<q-input <q-input filled dense v-model.trim="productDialog.data.config.description" label="Description"></q-input>
filled <q-select filled multiple dense emit-value v-model.trim="productDialog.data.categories" use-input use-chips
dense multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
v-model.trim="productDialog.data.config.description" label="Categories (Hit Enter to add)" placeholder="crafts,robots,etc"></q-select>
label="Description"
></q-input>
<q-select
filled
multiple
dense
emit-value
v-model.trim="productDialog.data.categories"
use-input
use-chips
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add-unique"
label="Categories (Hit Enter to add)"
placeholder="crafts,robots,etc"
></q-select>
<q-input <q-input filled dense v-model.trim="productDialog.data.image" @keydown.enter="addProductImage" type="url"
filled label="Image URL">
dense <q-btn @click="addProductImage" dense flat icon="add"></q-btn></q-input>
v-model.trim="productDialog.data.image"
@keydown.enter="addProductImage"
type="url"
label="Image URL"
>
<q-btn @click="addProductImage" dense flat icon="add"></q-btn
></q-input>
<q-chip <q-chip v-for="imageUrl in productDialog.data.images" :key="imageUrl" removable
v-for="imageUrl in productDialog.data.images" @remove="removeProductImage(imageUrl)" color="primary" text-color="white">
:key="imageUrl"
removable
@remove="removeProductImage(imageUrl)"
color="primary"
text-color="white"
>
<span v-text="imageUrl.split('/').pop()"></span> <span v-text="imageUrl.split('/').pop()"></span>
</q-chip> </q-chip>
<q-input <q-input filled dense v-model.number="productDialog.data.price" type="number"
filled :label="'Price (' + stall.currency + ') *'" :step="stall.currency != 'sat' ? '0.01' : '1'"
dense :mask="stall.currency != 'sat' ? '#.##' : '#'" fill-mask="0" reverse-fill-mask></q-input>
v-model.number="productDialog.data.price" <q-input filled dense v-model.number="productDialog.data.quantity" type="number" label="Quantity"></q-input>
type="number"
:label="'Price (' + stall.currency + ') *'"
:step="stall.currency != 'sat' ? '0.01' : '1'"
:mask="stall.currency != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
></q-input>
<q-input
filled
dense
v-model.number="productDialog.data.quantity"
type="number"
label="Quantity"
></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn v-if="productDialog.data.id" type="submit"
v-if="productDialog.data.id" :label="productDialog.data.pending ? 'Restore Product' : 'Update Product'" unelevated
unelevated color="primary"></q-btn>
color="primary"
type="submit"
>Update Product</q-btn
>
<q-btn <q-btn v-else unelevated color="primary" :disable="!productDialog.data.price
v-else
unelevated
color="primary"
:disable="!productDialog.data.price
|| !productDialog.data.name || !productDialog.data.name
|| !productDialog.data.quantity" || !productDialog.data.quantity" type="submit">Create Product</q-btn>
type="submit"
>Create Product</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto" <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
>Cancel</q-btn
>
</div> </div>
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> <q-dialog v-model="productDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingProducts && pendingProducts.length" class="row q-mt-lg">
<q-item v-for="pendingProduct of pendingProducts" :key="pendingProduct.id" tag="label" class="full-width"
v-ripple>
<q-item-section>
<q-item-label><span v-text="pendingProduct.name"></span></q-item-label>
<q-item-label caption><span v-text="pendingProduct.config?.description"></span></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn @click="openRestoreProductDialog(pendingProduct)" v-close-popup flat color="green"
class="q-ml-auto float-right">Restore</q-btn>
</q-item-section>
<q-item-section class="float-right">
<q-btn @click="deleteProduct(pendingProduct.id)" v-close-popup color="red" class="q-ml-auto float-right"
icon="cancel"></q-btn>
</q-item-section>
</q-item>
</div>
<div v-else>
There are no products to be restored.
</div>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>

View file

@ -20,8 +20,10 @@ async function stallDetails(path) {
tab: 'products', tab: 'products',
stall: null, stall: null,
products: [], products: [],
pendingProducts: [],
productDialog: { productDialog: {
showDialog: false, showDialog: false,
showRestore: false,
url: true, url: true,
data: { data: {
id: null, id: null,
@ -106,15 +108,15 @@ async function stallDetails(path) {
mapStall: function (stall) { mapStall: function (stall) {
stall.shipping_zones.forEach( stall.shipping_zones.forEach(
z => z =>
(z.label = z.name (z.label = z.name
? `${z.name} (${z.countries.join(', ')})` ? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', ')) : z.countries.join(', '))
) )
return stall return stall
}, },
getStall: async function () { getStall: async function () {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'GET', 'GET',
'/nostrmarket/api/v1/stall/' + this.stallId, '/nostrmarket/api/v1/stall/' + this.stallId,
this.inkey this.inkey
@ -126,7 +128,7 @@ async function stallDetails(path) {
}, },
updateStall: async function () { updateStall: async function () {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'PUT', 'PUT',
'/nostrmarket/api/v1/stall/' + this.stallId, '/nostrmarket/api/v1/stall/' + this.stallId,
this.adminkey, this.adminkey,
@ -189,14 +191,14 @@ async function stallDetails(path) {
this.productDialog.data.images.splice(index, 1) this.productDialog.data.images.splice(index, 1)
} }
}, },
getProducts: async function () { getProducts: async function (pending = false) {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'GET', 'GET',
'/nostrmarket/api/v1/stall/product/' + this.stall.id, `/nostrmarket/api/v1/stall/product/${this.stall.id}?pending=${pending}`,
this.inkey this.inkey
) )
this.products = data return data
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
@ -215,6 +217,7 @@ async function stallDetails(path) {
} }
this.productDialog.showDialog = false this.productDialog.showDialog = false
if (this.productDialog.data.id) { if (this.productDialog.data.id) {
data.pending = false
this.updateProduct(data) this.updateProduct(data)
} else { } else {
this.createProduct(data) this.createProduct(data)
@ -222,7 +225,7 @@ async function stallDetails(path) {
}, },
updateProduct: async function (product) { updateProduct: async function (product) {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'PATCH', 'PATCH',
'/nostrmarket/api/v1/product/' + product.id, '/nostrmarket/api/v1/product/' + product.id,
this.adminkey, this.adminkey,
@ -231,6 +234,8 @@ async function stallDetails(path) {
const index = this.products.findIndex(r => r.id === product.id) const index = this.products.findIndex(r => r.id === product.id)
if (index !== -1) { if (index !== -1) {
this.products.splice(index, 1, data) this.products.splice(index, 1, data)
} else {
this.products.unshift(data)
} }
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
@ -244,7 +249,7 @@ async function stallDetails(path) {
}, },
createProduct: async function (payload) { createProduct: async function (payload) {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'POST', 'POST',
'/nostrmarket/api/v1/product', '/nostrmarket/api/v1/product',
this.adminkey, this.adminkey,
@ -262,7 +267,7 @@ async function stallDetails(path) {
} }
}, },
editProduct: async function (product) { editProduct: async function (product) {
this.productDialog.data = {...product} this.productDialog.data = { ...product }
this.productDialog.showDialog = true this.productDialog.showDialog = true
}, },
deleteProduct: async function (productId) { deleteProduct: async function (productId) {
@ -289,8 +294,8 @@ async function stallDetails(path) {
} }
}) })
}, },
showNewProductDialog: async function () { showNewProductDialog: async function (data) {
this.productDialog.data = { this.productDialog.data = data || {
id: null, id: null,
name: '', name: '',
description: '', description: '',
@ -305,13 +310,21 @@ async function stallDetails(path) {
} }
this.productDialog.showDialog = true this.productDialog.showDialog = true
}, },
openSelectPendingProductDialog: async function () {
this.productDialog.showRestore = true
this.pendingProducts = await this.getProducts(true)
},
openRestoreProductDialog: async function (pendingProduct) {
pendingProduct.pending = true
await this.showNewProductDialog(pendingProduct)
},
customerSelectedForOrder: function (customerPubkey) { customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey) this.$emit('customer-selected-for-order', customerPubkey)
} }
}, },
created: async function () { created: async function () {
await this.getStall() await this.getStall()
await this.getProducts() this.products = await this.getProducts()
} }
}) })
} }

View file

@ -1,21 +1,23 @@
<div> <div>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col q-pr-lg"> <div class="col q-pr-lg">
<q-btn
@click="openCreateStallDialog" <q-btn-dropdown @click="openCreateStallDialog()" unelevated split color="green" class="float-left"
unelevated label="New Stall (Store)">
color="green" <q-item @click="openCreateStallDialog()" clickable v-close-popup>
class="float-left" <q-item-section>
>New Stall (Store)</q-btn <q-item-label>New Stall</q-item-label>
> <q-item-label caption>Create a new stall</q-item-label>
<q-input </q-item-section>
borderless </q-item>
dense <q-item @click="openSelectPendingStallDialog" clickable v-close-popup>
debounce="300" <q-item-section>
v-model="filter" <q-item-label>Restore Stall</q-item-label>
placeholder="Search" <q-item-label caption>Restore existing stall from Nostr</q-item-label>
class="float-right" </q-item-section>
> </q-item>
</q-btn-dropdown>
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search" class="float-right">
<template v-slot:append> <template v-slot:append>
<q-icon name="search"></q-icon> <q-icon name="search"></q-icon>
</template> </template>
@ -23,26 +25,13 @@
</div> </div>
</div> </div>
<q-table <q-table flat dense :data="stalls" row-key="id" :columns="stallsTable.columns"
flat :pagination.sync="stallsTable.pagination" :filter="filter">
dense
:data="stalls"
row-key="id"
:columns="stallsTable.columns"
:pagination.sync="stallsTable.pagination"
:filter="filter"
>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn size="sm" color="accent" round dense @click="props.row.expanded= !props.row.expanded"
size="sm" :icon="props.row.expanded? 'remove' : 'add'" />
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td> </q-td>
<q-td key="id" :props="props"> {{props.row.name}} </q-td> <q-td key="id" :props="props"> {{props.row.name}} </q-td>
@ -61,17 +50,10 @@
<q-td colspan="100%"> <q-td colspan="100%">
<div class="row items-center q-mb-lg"> <div class="row items-center q-mb-lg">
<div class="col-12"> <div class="col-12">
<stall-details <stall-details :stall-id="props.row.id" :adminkey="adminkey" :inkey="inkey"
:stall-id="props.row.id" :wallet-options="walletOptions" :zone-options="zoneOptions" :currencies="currencies"
:adminkey="adminkey" @stall-deleted="handleStallDeleted" @stall-updated="handleStallUpdated"
:inkey="inkey" @customer-selected-for-order="customerSelectedForOrder"></stall-details>
:wallet-options="walletOptions"
:zone-options="zoneOptions"
:currencies="currencies"
@stall-deleted="handleStallDeleted"
@stall-updated="handleStallUpdated"
@customer-selected-for-order="customerSelectedForOrder"
></stall-details>
</div> </div>
</div> </div>
</q-td> </q-td>
@ -83,64 +65,54 @@
<q-dialog v-model="stallDialog.show" position="top"> <q-dialog v-model="stallDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px"> <q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendStallFormData" class="q-gutter-md"> <q-form @submit="sendStallFormData" class="q-gutter-md">
<q-input <q-input filled dense v-model.trim="stallDialog.data.name" label="Name"></q-input>
filled <q-input filled dense v-model.trim="stallDialog.data.description" type="textarea" rows="3"
dense label="Description"></q-input>
v-model.trim="stallDialog.data.name" <q-select filled dense emit-value v-model="stallDialog.data.wallet" :options="walletOptions" label="Wallet *">
label="Name"
></q-input>
<q-input
filled
dense
v-model.trim="stallDialog.data.description"
type="textarea"
rows="3"
label="Description"
></q-input>
<q-select
filled
dense
emit-value
v-model="stallDialog.data.wallet"
:options="walletOptions"
label="Wallet *"
>
</q-select> </q-select>
<q-select <q-select filled dense v-model="stallDialog.data.currency" type="text" label="Unit"
filled :options="currencies"></q-select>
dense <q-select :options="filteredZoneOptions" filled dense multiple v-model.trim="stallDialog.data.shippingZones"
v-model="stallDialog.data.currency" label="Shipping Zones"></q-select>
type="text"
label="Unit"
:options="currencies"
></q-select>
<q-select
:options="filteredZoneOptions"
filled
dense
multiple
v-model.trim="stallDialog.data.shippingZones"
label="Shipping Zones"
></q-select>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn unelevated color="primary" :disable="!stallDialog.data.name
unelevated
color="primary"
:disable="!stallDialog.data.name
|| !stallDialog.data.currency || !stallDialog.data.currency
|| !stallDialog.data.wallet || !stallDialog.data.wallet
|| !stallDialog.data.shippingZones || !stallDialog.data.shippingZones
|| !stallDialog.data.shippingZones.length" || !stallDialog.data.shippingZones.length" type="submit"
type="submit" :label="stallDialog.data.id ? 'Restore Stall' : 'Create Stall'"></q-btn>
>Create Stall</q-btn <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div> </div>
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="stallDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingStalls && pendingStalls.length" class="row q-mt-lg">
<q-item v-for="pendingStall of pendingStalls" :key="pendingStall.id" tag="label" class="full-width" v-ripple>
<q-item-section>
<q-item-label><span v-text="pendingStall.name"></span></q-item-label>
<q-item-label caption><span v-text="pendingStall.config?.description"></span></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn @click="openRestoreStallDialog(pendingStall)" v-close-popup flat color="green"
class="q-ml-auto float-right">Restore</q-btn>
</q-item-section>
<q-item-section class="float-right">
<q-btn @click="deleteStall(pendingStall)" v-close-popup color="red" class="q-ml-auto float-right"
icon="cancel"></q-btn>
</q-item-section>
</q-item>
</div>
<div v-else>
There are no stalls to be restored.
</div>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div> </div>
</div> </div>

View file

@ -9,9 +9,11 @@ async function stallList(path) {
return { return {
filter: '', filter: '',
stalls: [], stalls: [],
pendingStalls: [],
currencies: [], currencies: [],
stallDialog: { stallDialog: {
show: false, show: false,
showRestore: false,
data: { data: {
name: '', name: '',
description: '', description: '',
@ -69,7 +71,7 @@ async function stallList(path) {
}, },
methods: { methods: {
sendStallFormData: async function () { sendStallFormData: async function () {
await this.createStall({ const stallData = {
name: this.stallDialog.data.name, name: this.stallDialog.data.name,
wallet: this.stallDialog.data.wallet, wallet: this.stallDialog.data.wallet,
currency: this.stallDialog.data.currency, currency: this.stallDialog.data.currency,
@ -77,11 +79,18 @@ async function stallList(path) {
config: { config: {
description: this.stallDialog.data.description description: this.stallDialog.data.description
} }
}) }
if (this.stallDialog.data.id) {
stallData.id = this.stallDialog.data.id
await this.restoreStall(stallData)
} else {
await this.createStall(stallData)
}
}, },
createStall: async function (stall) { createStall: async function (stall) {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'POST', 'POST',
'/nostrmarket/api/v1/stall', '/nostrmarket/api/v1/stall',
this.adminkey, this.adminkey,
@ -98,39 +107,86 @@ async function stallList(path) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
restoreStall: async function (stallData) {
try {
stallData.pending = false
const { data } = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/stall/${stallData.id}`,
this.adminkey,
stallData
)
this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data)
this.$q.notify({
type: 'positive',
message: 'Stall restored!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteStall: async function (pendingStall) {
LNbits.utils
.confirmDialog(
`
Are you sure you want to delete this pending stall '${pendingStall.name}'?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/stall/' + pendingStall.id,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Pending Stall Deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
getCurrencies: async function () { getCurrencies: async function () {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'GET', 'GET',
'/nostrmarket/api/v1/currencies', '/nostrmarket/api/v1/currencies',
this.inkey this.inkey
) )
this.currencies = ['sat', ...data] return ['sat', ...data]
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
return []
}, },
getStalls: async function () { getStalls: async function (pending = false) {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'GET', 'GET',
'/nostrmarket/api/v1/stall', `/nostrmarket/api/v1/stall?pending=${pending}`,
this.inkey this.inkey
) )
this.stalls = data.map(s => ({...s, expanded: false})) return data.map(s => ({ ...s, expanded: false }))
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
return []
}, },
getZones: async function () { getZones: async function () {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'GET', 'GET',
'/nostrmarket/api/v1/zone', '/nostrmarket/api/v1/zone',
this.inkey this.inkey
) )
this.zoneOptions = data.map(z => ({ return data.map(z => ({
...z, ...z,
label: z.name label: z.name
? `${z.name} (${z.countries.join(', ')})` ? `${z.name} (${z.countries.join(', ')})`
@ -139,6 +195,7 @@ async function stallList(path) {
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
return []
}, },
handleStallDeleted: function (stallId) { handleStallDeleted: function (stallId) {
this.stalls = _.reject(this.stalls, function (obj) { this.stalls = _.reject(this.stalls, function (obj) {
@ -152,9 +209,9 @@ async function stallList(path) {
this.stalls.splice(index, 1, stall) this.stalls.splice(index, 1, stall)
} }
}, },
openCreateStallDialog: async function () { openCreateStallDialog: async function (stallData) {
await this.getCurrencies() this.currencies = await this.getCurrencies()
await this.getZones() this.zoneOptions = await this.getZones()
if (!this.zoneOptions || !this.zoneOptions.length) { if (!this.zoneOptions || !this.zoneOptions.length) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -162,7 +219,7 @@ async function stallList(path) {
}) })
return return
} }
this.stallDialog.data = { this.stallDialog.data = stallData || {
name: '', name: '',
description: '', description: '',
wallet: null, wallet: null,
@ -171,14 +228,35 @@ async function stallList(path) {
} }
this.stallDialog.show = true this.stallDialog.show = true
}, },
openSelectPendingStallDialog: async function () {
this.stallDialog.showRestore = true
this.pendingStalls = await this.getStalls(true)
},
openRestoreStallDialog: async function (pendingStall) {
const shippingZonesIds = this.zoneOptions.map(z => z.id)
await this.openCreateStallDialog({
id: pendingStall.id,
name: pendingStall.name,
description: pendingStall.config?.description,
currency: pendingStall.currency,
shippingZones: (pendingStall.shipping_zones || [])
.filter(z => shippingZonesIds.indexOf(z.id) !== -1)
.map(z => ({
...z,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', ')
}))
})
},
customerSelectedForOrder: function (customerPubkey) { customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey) this.$emit('customer-selected-for-order', customerPubkey)
} }
}, },
created: async function () { created: async function () {
await this.getStalls() this.stalls = await this.getStalls()
await this.getCurrencies() this.currencies = await this.getCurrencies()
await this.getZones() this.zoneOptions = await this.getZones()
} }
}) })
} }

View file

@ -28,7 +28,8 @@ const merchant = async () => {
data: { data: {
privateKey: null privateKey: null
} }
} },
wsConnection: null
} }
}, },
methods: { methods: {
@ -114,8 +115,9 @@ const merchant = async () => {
const scheme = location.protocol === 'http:' ? 'ws' : 'wss' const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
const port = location.port ? `:${location.port}` : '' const port = location.port ? `:${location.port}` : ''
const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}` const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}`
const wsConnection = new WebSocket(wsUrl) console.log('Reconnecting to websocket: ', wsUrl)
wsConnection.onmessage = async e => { this.wsConnection = new WebSocket(wsUrl)
this.wsConnection.onmessage = async e => {
const data = JSON.parse(e.data) const data = JSON.parse(e.data)
if (data.type === 'new-order') { if (data.type === 'new-order') {
this.$q.notify({ this.$q.notify({
@ -124,10 +126,20 @@ const merchant = async () => {
message: 'New Order' message: 'New Order'
}) })
await this.$refs.orderListRef.addOrder(data) await this.$refs.orderListRef.addOrder(data)
} else if (data.type === 'order-paid') {
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Order Paid'
})
await this.$refs.orderListRef.addOrder(data)
} else if (data.type === 'new-direct-message') { } else if (data.type === 'new-direct-message') {
await this.$refs.directMessagesRef.handleNewMessage(data) await this.$refs.directMessagesRef.handleNewMessage(data)
} }
// order paid
// order shipped
} }
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
timeout: 5000, timeout: 5000,
@ -157,7 +169,11 @@ const merchant = async () => {
}, },
created: async function () { created: async function () {
await this.getMerchant() await this.getMerchant()
await this.waitForNotifications() setInterval(async () => {
if (!this.wsConnection || this.wsConnection.readyState !== WebSocket.OPEN) {
await this.waitForNotifications()
}
}, 1000)
} }
}) })
} }

View file

@ -4,10 +4,12 @@ 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_all_unique_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_last_product_update_time,
get_last_stall_update_time,
get_merchants_ids_with_pubkeys,
) )
from .nostr.nostr_client import NostrClient from .nostr.nostr_client import NostrClient
from .services import handle_order_paid, process_nostr_message from .services import handle_order_paid, process_nostr_message
@ -35,15 +37,23 @@ async def on_invoice_paid(payment: Payment) -> None:
async def wait_for_nostr_events(nostr_client: NostrClient): async def wait_for_nostr_events(nostr_client: NostrClient):
public_keys = await get_public_keys_for_merchants() merchant_ids = await get_merchants_ids_with_pubkeys()
for p in public_keys: for id, pk in merchant_ids:
last_order_time = await get_last_order_time(p) last_order_time = await get_last_order_time(id)
last_dm_time = await get_last_direct_messages_time(p) last_dm_time = await get_last_direct_messages_time(id)
since = max(last_order_time, last_dm_time) since = max(last_order_time, last_dm_time)
await nostr_client.subscribe_to_direct_messages(p, since) await nostr_client.subscribe_to_direct_messages(pk, since)
customers = await get_all_customers() for id, pk in merchant_ids:
last_stall_update = await get_last_stall_update_time(id)
await nostr_client.subscribe_to_stall_events(pk, last_stall_update)
for id, pk in merchant_ids:
last_product_update = await get_last_product_update_time(id)
await nostr_client.subscribe_to_product_events(pk, last_product_update)
customers = await get_all_unique_customers()
for c in customers: for c in customers:
await nostr_client.subscribe_to_user_profile(c.public_key, c.event_created_at) await nostr_client.subscribe_to_user_profile(c.public_key, c.event_created_at)

View file

@ -1,6 +1,6 @@
import json import json
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional, Union from typing import List, Optional
from fastapi import Depends from fastapi import Depends
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
@ -37,9 +37,11 @@ from .crud import (
get_direct_messages, get_direct_messages,
get_merchant_by_pubkey, get_merchant_by_pubkey,
get_merchant_for_user, get_merchant_for_user,
get_merchants_ids_with_pubkeys,
get_order, get_order,
get_orders, get_orders,
get_orders_for_stall, get_orders_for_stall,
get_orders_from_direct_messages,
get_product, get_product,
get_products, get_products,
get_stall, get_stall,
@ -57,6 +59,7 @@ from .helpers import normalize_public_key
from .models import ( from .models import (
Customer, Customer,
DirectMessage, DirectMessage,
DirectMessageType,
Merchant, Merchant,
Order, Order,
OrderStatusUpdate, OrderStatusUpdate,
@ -69,7 +72,11 @@ from .models import (
Stall, Stall,
Zone, Zone,
) )
from .services import sign_and_send_to_nostr, update_merchant_to_nostr from .services import (
create_or_update_order_from_dm,
sign_and_send_to_nostr,
update_merchant_to_nostr,
)
######################################## MERCHANT ######################################## ######################################## MERCHANT ########################################
@ -88,6 +95,20 @@ async def api_create_merchant(
assert merchant == None, "A merchant already exists for this user" assert merchant == None, "A merchant already exists for this user"
merchant = await create_merchant(wallet.wallet.user, data) merchant = await create_merchant(wallet.wallet.user, data)
await create_zone(
merchant.id,
PartialZone(
id="online",
name="Online",
currency="sat",
cost=0,
countries=["Free (digital)"],
),
)
await nostr_client.subscribe_to_stall_events(data.public_key, 0)
await nostr_client.subscribe_to_product_events(data.public_key, 0)
await nostr_client.subscribe_to_direct_messages(data.public_key, 0) await nostr_client.subscribe_to_direct_messages(data.public_key, 0)
return merchant return merchant
@ -138,6 +159,7 @@ async def api_delete_merchant(
await delete_merchant_zones(merchant.id) await delete_merchant_zones(merchant.id)
await nostr_client.unsubscribe_from_direct_messages(merchant.public_key) await nostr_client.unsubscribe_from_direct_messages(merchant.public_key)
await nostr_client.unsubscribe_from_merchant_events(merchant.public_key)
await delete_merchant(merchant.id) await delete_merchant(merchant.id)
except AssertionError as ex: except AssertionError as ex:
raise HTTPException( raise HTTPException(
@ -317,6 +339,7 @@ async def api_create_stall(
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Stall: ) -> Stall:
try: try:
# shipping_zones = await
data.validate_stall() data.validate_stall()
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
@ -325,7 +348,7 @@ async def api_create_stall(
event = await sign_and_send_to_nostr(merchant, stall) event = await sign_and_send_to_nostr(merchant, stall)
stall.config.event_id = event.id stall.event_id = event.id
await update_stall(merchant.id, stall) await update_stall(merchant.id, stall)
return stall return stall
@ -359,7 +382,7 @@ async def api_update_stall(
event = await sign_and_send_to_nostr(merchant, stall) event = await sign_and_send_to_nostr(merchant, stall)
stall.config.event_id = event.id stall.event_id = event.id
await update_stall(merchant.id, stall) await update_stall(merchant.id, stall)
return stall return stall
@ -406,11 +429,13 @@ async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_
@nostrmarket_ext.get("/api/v1/stall") @nostrmarket_ext.get("/api/v1/stall")
async def api_get_stalls(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_get_stalls(
pending: Optional[bool] = False, 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"
stalls = await get_stalls(merchant.id) stalls = await get_stalls(merchant.id, pending)
return stalls return stalls
except AssertionError as ex: except AssertionError as ex:
raise HTTPException( raise HTTPException(
@ -428,12 +453,13 @@ async def api_get_stalls(wallet: WalletTypeInfo = Depends(get_key_type)):
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}") @nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
async def api_get_stall_products( async def api_get_stall_products(
stall_id: str, stall_id: str,
pending: Optional[bool] = False,
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"
products = await get_products(merchant.id, stall_id) products = await get_products(merchant.id, stall_id, pending)
return products return products
except AssertionError as ex: except AssertionError as ex:
raise HTTPException( raise HTTPException(
@ -494,7 +520,7 @@ async def api_delete_stall(
event = await sign_and_send_to_nostr(merchant, stall, True) event = await sign_and_send_to_nostr(merchant, stall, True)
stall.config.event_id = event.id stall.event_id = event.id
await update_stall(merchant.id, stall) await update_stall(merchant.id, stall)
except AssertionError as ex: except AssertionError as ex:
@ -532,7 +558,7 @@ async def api_create_product(
event = await sign_and_send_to_nostr(merchant, product) event = await sign_and_send_to_nostr(merchant, product)
product.config.event_id = event.id product.event_id = event.id
await update_product(merchant.id, product) await update_product(merchant.id, product)
return product return product
@ -568,7 +594,7 @@ async def api_update_product(
product = await update_product(merchant.id, product) product = await update_product(merchant.id, product)
event = await sign_and_send_to_nostr(merchant, product) event = await sign_and_send_to_nostr(merchant, product)
product.config.event_id = event.id product.event_id = event.id
await update_product(merchant.id, product) await update_product(merchant.id, product)
return product return product
@ -626,9 +652,7 @@ async def api_delete_product(
) )
await delete_product(merchant.id, product_id) await delete_product(merchant.id, product_id)
event = await sign_and_send_to_nostr(merchant, product, True) await sign_and_send_to_nostr(merchant, product, True)
product.config.event_id = event.id
await update_product(merchant.id, product)
except AssertionError as ex: except AssertionError as ex:
raise HTTPException( raise HTTPException(
@ -720,9 +744,23 @@ async def api_update_order_status(
assert order, "Cannot find updated order" assert order, "Cannot find updated order"
data.paid = order.paid data.paid = order.paid
dm_content = json.dumps(data.dict(), separators=(",", ":"), ensure_ascii=False) dm_content = json.dumps(
{"type": DirectMessageType.ORDER_PAID_OR_SHIPPED.value, **data.dict()},
separators=(",", ":"),
ensure_ascii=False,
)
dm_event = merchant.build_dm_event(dm_content, order.public_key) dm_event = merchant.build_dm_event(dm_content, order.public_key)
dm = PartialDirectMessage(
event_id=dm_event.id,
event_created_at=dm_event.created_at,
message=dm_content,
public_key=order.public_key,
type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
)
await create_direct_message(merchant.id, dm)
await nostr_client.publish_nostr_event(dm_event) await nostr_client.publish_nostr_event(dm_event)
return order return order
@ -740,6 +778,38 @@ async def api_update_order_status(
) )
@nostrmarket_ext.put("/api/v1/order/restore")
async def api_restore_orders(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Order:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
dms = await get_orders_from_direct_messages(merchant.id)
for dm in dms:
try:
await create_or_update_order_from_dm(
merchant.id, merchant.public_key, dm
)
except Exception as e:
logger.debug(
f"Failed to restore order friom event '{dm.event_id}': '{str(e)}'."
)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot restore orders",
)
######################################## DIRECT MESSAGES ######################################## ######################################## DIRECT MESSAGES ########################################
@ -752,7 +822,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) await update_customer_no_unread_messages(merchant.id, public_key)
return messages return messages
except AssertionError as ex: except AssertionError as ex:
raise HTTPException( raise HTTPException(
@ -822,7 +892,7 @@ async def api_get_customers(
@nostrmarket_ext.post("/api/v1/customer") @nostrmarket_ext.post("/api/v1/customer")
async def api_createcustomer( async def api_create_customer(
data: Customer, data: Customer,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Customer: ) -> Customer:
@ -867,7 +937,9 @@ async def api_list_currencies_available():
@nostrmarket_ext.put("/api/v1/restart") @nostrmarket_ext.put("/api/v1/restart")
async def restart_nostr_client(wallet: WalletTypeInfo = Depends(require_admin_key)): async def restart_nostr_client(wallet: WalletTypeInfo = Depends(require_admin_key)):
try: try:
await nostr_client.restart() ids = await get_merchants_ids_with_pubkeys()
merchant_public_keys = [id[0] for id in ids]
await nostr_client.restart(merchant_public_keys)
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
@ -880,5 +952,11 @@ async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)):
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
nostr_client.stop() try:
ids = await get_merchants_ids_with_pubkeys()
merchant_public_keys = [id[0] for id in ids]
await nostr_client.stop(merchant_public_keys)
except Exception as ex:
logger.warning(ex)
return {"success": True} return {"success": True}