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
async def get_public_keys_for_merchants() -> List[str]:
async def get_merchants_ids_with_pubkeys() -> List[str]:
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]:
@ -100,7 +100,7 @@ async def delete_merchant(merchant_id: str) -> None:
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(
f"""
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:
stall_id = urlsafe_short_hash()
stall_id = data.id or urlsafe_short_hash()
await db.execute(
f"""
INSERT INTO nostrmarket.stalls (merchant_id, id, wallet, name, currency, zones, meta)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO nostrmarket.stalls
(merchant_id, id, wallet, name, currency, pending, event_id, event_created_at, zones, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
""",
(
merchant_id,
@ -181,6 +183,9 @@ async def create_stall(merchant_id: str, data: PartialStall) -> Stall:
data.wallet,
data.name,
data.currency,
data.pending,
data.event_id,
data.event_created_at,
json.dumps(
[z.dict() for z in data.shipping_zones]
), # 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
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(
"SELECT * FROM nostrmarket.stalls WHERE merchant_id = ?",
(merchant_id,),
"SELECT * FROM nostrmarket.stalls WHERE merchant_id = ? AND pending = ?",
(merchant_id, pending,),
)
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]:
await db.execute(
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 = ?
""",
(
stall.wallet,
stall.name,
stall.currency,
stall.pending,
stall.event_id,
stall.event_created_at,
json.dumps(
[z.dict() for z in stall.shipping_zones]
), # todo: cost is float. should be int for sats
json.dumps(stall.config.dict()),
merchant_id,
stall.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:
product_id = urlsafe_short_hash()
product_id = data.id or urlsafe_short_hash()
await db.execute(
f"""
INSERT INTO nostrmarket.products (merchant_id, id, stall_id, name, price, quantity, image_urls, category_list, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO nostrmarket.products
(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,
@ -268,6 +287,9 @@ async def create_product(merchant_id: str, data: PartialProduct) -> Product:
data.name,
data.price,
data.quantity,
data.pending,
data.event_id,
data.event_created_at,
json.dumps(data.images),
json.dumps(data.categories),
json.dumps(data.config.dict()),
@ -283,13 +305,16 @@ async def update_product(merchant_id: str, product: Product) -> Product:
await db.execute(
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 = ?
""",
(
product.name,
product.price,
product.quantity,
product.pending,
product.event_id,
product.event_created_at,
json.dumps(product.images),
json.dumps(product.categories),
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
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(
"SELECT * FROM nostrmarket.products WHERE merchant_id = ? AND stall_id = ?",
(merchant_id, stall_id),
"SELECT * FROM nostrmarket.products WHERE merchant_id = ? AND stall_id = ? AND pending = ?",
(merchant_id, stall_id, pending),
)
return [Product.from_row(row) for row in rows]
@ -341,7 +366,11 @@ async def get_products_by_ids(
) -> List[Product]:
q = ",".join(["?"] * len(product_ids))
rows = await db.fetchall(
f"SELECT id, stall_id, name, price, quantity, category_list, meta FROM nostrmarket.products WHERE 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),
)
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
INNER JOIN nostrmarket.stalls s
ON p.stall_id = s.id
WHERE p.id=?
WHERE p.id = ? AND p.pending = false AND s.pending = false
""",
(product_id,),
)
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:
await db.execute(
@ -456,7 +494,7 @@ async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
q = f"AND {q}"
values = (v for v in kwargs.values() if v != None)
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),
)
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]
async def get_last_order_time(public_key: str) -> int:
async def get_last_order_time(merchant_id: str) -> int:
row = await db.fetchone(
"""
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
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]:
await db.execute(
f"UPDATE nostrmarket.orders SET paid = ? WHERE id = ?",
@ -533,8 +583,8 @@ async def create_direct_message(
dm_id = urlsafe_short_hash()
await db.execute(
f"""
INSERT INTO nostrmarket.direct_messages (merchant_id, id, event_id, event_created_at, message, public_key, incoming)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO nostrmarket.direct_messages (merchant_id, id, event_id, event_created_at, message, public_key, type, incoming)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING
""",
(
@ -544,6 +594,7 @@ async def create_direct_message(
dm.event_created_at,
dm.message,
dm.public_key,
dm.type,
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]
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(
"""
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
@ -644,8 +705,13 @@ async def get_customers(merchant_id: str) -> List[Customer]:
return [Customer.from_row(row) for row in rows]
async def get_all_customers() -> List[Customer]:
rows = await db.fetchall("SELECT * FROM nostrmarket.customers")
async def get_all_unique_customers() -> List[Customer]:
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]
@ -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(
f"UPDATE nostrmarket.customers SET unread_messages = unread_messages + 1 WHERE public_key = ?",
(public_key,),
f"UPDATE nostrmarket.customers SET unread_messages = unread_messages + 1 WHERE merchant_id = ? AND public_key = ?",
(merchant_id, public_key,),
)
async def update_customer_no_unread_messages(public_key: str):
#??? two merchants
async def update_customer_no_unread_messages(merchant_id: str, public_key: str):
await db.execute(
f"UPDATE nostrmarket.customers SET unread_messages = 0 WHERE public_key = ?",
(public_key,),
f"UPDATE nostrmarket.customers SET unread_messages = 0 WHERE merchant_id =? AND public_key = ?",
(merchant_id, public_key,),
)

View file

@ -75,14 +75,6 @@ def copy_x(output, x32, y32, data):
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:
if pubkey.startswith("npub1"):
_, 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 time
from abc import abstractmethod
from enum import Enum
from sqlite3 import Row
from typing import List, Optional, Tuple
from typing import Any, List, Optional, Tuple
from pydantic import BaseModel
@ -120,6 +121,7 @@ class Merchant(PartialMerchant, Nostrable):
######################################## ZONES ########################################
class PartialZone(BaseModel):
id: Optional[str]
name: Optional[str]
currency: str
cost: float
@ -140,19 +142,22 @@ class Zone(PartialZone):
class StallConfig(BaseModel):
"""Last published nostr event id for this Stall"""
event_id: Optional[str]
image_url: Optional[str]
description: Optional[str]
class PartialStall(BaseModel):
id: Optional[str]
wallet: str
name: str
currency: str = "sat"
shipping_zones: List[Zone] = []
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):
for z in self.shipping_zones:
@ -189,7 +194,7 @@ class Stall(PartialStall, Nostrable):
pubkey=pubkey,
created_at=round(time.time()),
kind=5,
tags=[["e", self.config.event_id]],
tags=[["e", self.event_id]],
content=f"Stall '{self.name}' deleted",
)
delete_event.id = delete_event.event_id
@ -204,24 +209,29 @@ class Stall(PartialStall, Nostrable):
return stall
######################################## STALLS ########################################
######################################## PRODUCTS ########################################
class ProductConfig(BaseModel):
event_id: Optional[str]
description: Optional[str]
currency: Optional[str]
class PartialProduct(BaseModel):
id: Optional[str]
stall_id: str
name: str
categories: List[str] = []
images: List[str] = []
price: float
quantity: int
pending: bool = False
config: ProductConfig = ProductConfig()
"""Last published nostr event for this Product"""
event_id: Optional[str]
event_created_at: Optional[int]
class Product(PartialProduct, Nostrable):
id: str
@ -255,7 +265,7 @@ class Product(PartialProduct, Nostrable):
pubkey=pubkey,
created_at=round(time.time()),
kind=5,
tags=[["e", self.config.event_id]],
tags=[["e", self.event_id]],
content=f"Product '{self.name}' deleted",
)
delete_event.id = delete_event.event_id
@ -300,7 +310,7 @@ class OrderExtra(BaseModel):
@classmethod
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 = (
(await btc_price(currency)) if currency and currency != "sat" else 1
)
@ -401,14 +411,34 @@ class PaymentRequest(BaseModel):
######################################## 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):
event_id: Optional[str]
event_created_at: Optional[int]
message: str
public_key: str
type: int = DirectMessageType.PLAIN_TEXT.value
incoming: bool = False
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):
id: str
@ -419,6 +449,7 @@ class DirectMessage(PartialDirectMessage):
return dm
######################################## CUSTOMERS ########################################
@ -437,5 +468,5 @@ class Customer(BaseModel):
@classmethod
def from_row(cls, row: Row) -> "Customer":
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

View file

@ -2,7 +2,7 @@ import asyncio
import json
from asyncio import Queue
from threading import Thread
from typing import Callable
from typing import Callable, List
from loguru import logger
from websocket import WebSocketApp
@ -18,11 +18,6 @@ class NostrClient:
self.send_req_queue: Queue = Queue()
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(
self, on_open: Callable, on_message: Callable
@ -96,36 +91,80 @@ class NostrClient:
["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]}
product_filter = {"kinds": [30018], "authors": [public_key]}
if since and since != 0:
stall_filter["since"] = since
await self.send_req_queue.put(
["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(
["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):
profile_filter = {"kinds": [0], "authors": [public_key]}
if since and since != 0:
profile_filter["since"] = since + 1
await self.send_req_queue.put(
["REQ", f"user-profile-events:{public_key}", profile_filter]
)
# Disabled for now. The number of clients can grow large.
# 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):
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}"])
logger.debug(f"Unsubscribed from direct-messages '{public_key}'.")
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"product-events:{public_key}"])
def stop(self):
try:
self.ws.close()
except Exception as ex:
logger.warning(ex)
logger.debug(f"Unsubscribed from stall-events and product-events '{public_key}'.")
async def restart(self, public_keys: List[str]):
await self.unsubscribe_merchants(public_keys)
# 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 lnbits.bolt11 import decode
from lnbits.core import create_invoice, get_wallet
from lnbits.core.services import websocketUpdater
@ -12,6 +13,8 @@ from .crud import (
create_customer,
create_direct_message,
create_order,
create_product,
create_stall,
get_customer,
get_merchant_by_pubkey,
get_order,
@ -23,17 +26,21 @@ from .crud import (
get_zone,
increment_customer_unread_messages,
update_customer_profile,
update_order,
update_order_paid_status,
update_order_shipped_status,
update_product,
update_product_quantity,
update_stall,
)
from .helpers import order_from_json
from .models import (
Customer,
DirectMessage,
DirectMessageType,
Merchant,
Nostrable,
Order,
OrderContact,
OrderExtra,
OrderItem,
OrderStatusUpdate,
@ -42,6 +49,7 @@ from .models import (
PaymentOption,
PaymentRequest,
Product,
Stall,
)
from .nostr.event import NostrEvent
@ -126,10 +134,12 @@ async def update_merchant_to_nostr(
products = await get_products(merchant.id, stall.id)
for product in products:
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)
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)
if delete_merchant:
# merchant profile updates not supported yet
@ -163,6 +173,16 @@ async def handle_order_paid(order_id: str, merchant_pubkey: str):
# todo: lock
success, message = await update_products_for_order(merchant, order)
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:
logger.warning(ex)
@ -179,12 +199,26 @@ async def notify_client_of_order_status(
shipped=order.shipped,
)
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:
dm_content = f"Order cannot be fulfilled. Reason: {message}"
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)
@ -201,7 +235,7 @@ async def update_products_for_order(
for p in products:
product = await update_product_quantity(p.id, p.quantity)
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)
return True, "ok"
@ -233,19 +267,84 @@ async def compute_products_new_quantity(
async def process_nostr_message(msg: str):
try:
type, *rest = json.loads(msg)
if type.upper() == "EVENT":
subscription_id, event = rest
event = NostrEvent(**event)
print("kind: ", event.kind, ": ", msg)
if event.kind == 0:
await _handle_customer_profile_update(event)
if event.kind == 4:
elif event.kind == 4:
_, merchant_public_key = subscription_id.split(":")
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
except Exception as 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):
merchant = await get_merchant_by_pubkey(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:
await _handle_new_customer(event, merchant)
else:
await increment_customer_unread_messages(event.pubkey)
await increment_customer_unread_messages(merchant.id, event.pubkey)
dm_reply = await _handle_dirrect_message(
merchant.id,
@ -282,6 +381,14 @@ async def _handle_incoming_dms(
)
if dm_reply:
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)
@ -289,12 +396,14 @@ async def _handle_outgoing_dms(
event: NostrEvent, merchant: Merchant, clear_text_msg: str
):
sent_to = event.tag_values("p")
type, _ = PartialDirectMessage.parse_message(clear_text_msg)
if len(sent_to) != 0:
dm = PartialDirectMessage(
event_id=event.id,
event_created_at=event.created_at,
message=clear_text_msg, # exclude if json
message=clear_text_msg,
public_key=sent_to[0],
type=type.value
)
await create_direct_message(merchant.id, dm)
@ -307,22 +416,24 @@ async def _handle_dirrect_message(
event_created_at: int,
msg: str,
) -> Optional[str]:
order, text_msg = order_from_json(msg)
type, order = PartialDirectMessage.parse_message(msg)
try:
dm = PartialDirectMessage(
event_id=event_id,
event_created_at=event_created_at,
message=text_msg,
message=msg,
public_key=from_pubkey,
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(
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["merchant_public_key"] = merchant_public_key
order["event_id"] = event_id
@ -338,17 +449,23 @@ async def _handle_dirrect_message(
async def _handle_new_order(order: PartialOrder) -> Optional[str]:
order.validate_order()
first_product_id = order.items[0].product_id
wallet_id = await get_wallet_for_product(first_product_id)
assert wallet_id, f"Cannot find wallet id for product id: {first_product_id}"
try:
first_product_id = order.items[0].product_id
wallet_id = await get_wallet_for_product(first_product_id)
assert wallet_id, f"Cannot find wallet id for product id: {first_product_id}"
wallet = await get_wallet(wallet_id)
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
wallet = await get_wallet(wallet_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:
return json.dumps(new_order.dict(), separators=(",", ":"), ensure_ascii=False)
payment_req = await create_new_order(order.merchant_public_key, order)
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
@ -372,3 +489,58 @@ async def _handle_customer_profile_update(event: NostrEvent):
)
except Exception as 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>
</div>
<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>
</div>
</q-card>

View file

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

View file

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

View file

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

View file

@ -10,54 +10,29 @@
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="stall.id"
type="text"
></q-input>
<q-input filled dense readonly disabled v-model.trim="stall.id" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Name:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
v-model.trim="stall.name"
type="text"
></q-input>
<q-input filled dense v-model.trim="stall.name" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Description:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
v-model.trim="stall.config.description"
type="textarea"
rows="3"
label="Description"
></q-input>
<q-input filled dense v-model.trim="stall.config.description" type="textarea" rows="3"
label="Description"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Wallet:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
filled
dense
emit-value
v-model="stall.wallet"
:options="walletOptions"
label="Wallet *"
>
<q-select filled dense emit-value v-model="stall.wallet" :options="walletOptions" label="Wallet *">
</q-select>
</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="col-3 q-pr-lg">Currency:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
filled
dense
v-model="stall.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
<q-select filled dense v-model="stall.currency" type="text" label="Unit" :options="currencies"></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Shipping Zones:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
:options="filteredZoneOptions"
filled
dense
multiple
v-model.trim="stall.shipping_zones"
label="Shipping Zones"
></q-select>
<q-select :options="filteredZoneOptions" filled dense multiple v-model.trim="stall.shipping_zones"
label="Shipping Zones"></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
</div>
<div class="row items-center q-mt-xl">
<div class="col-6 q-pr-lg">
<q-btn
unelevated
color="secondary"
class="float-left"
@click="updateStall()"
>Update Stall</q-btn
>
<q-btn unelevated color="secondary" class="float-left" @click="updateStall()">Update Stall</q-btn>
</div>
<div class="col-6">
<q-btn
unelevated
color="pink"
icon="cancel"
class="float-right"
@click="deleteStall()"
>Delete Stall</q-btn
>
<q-btn unelevated color="pink" icon="cancel" class="float-right" @click="deleteStall()">Delete Stall</q-btn>
</div>
</div>
</q-tab-panel>
@ -117,14 +66,23 @@
<div v-if="stall">
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">
<q-btn
unelevated
color="green"
icon="plus"
class="float-left"
@click="showNewProductDialog()"
>New Product</q-btn
>
<q-btn-dropdown @click="showNewProductDialog()" unelevated split color="green" class="float-left"
label="New Product">
<q-item @click="showNewProductDialog()" clickable v-close-popup>
<q-item-section>
<q-item-label>New Product</q-item-label>
<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 class="col-6 col-sm-8 q-pr-lg"></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="col-12">
<q-table
flat
dense
:data="products"
row-key="id"
:columns="productsTable.columns"
:pagination.sync="productsTable.pagination"
:filter="productsFilter"
>
<q-table flat dense :data="products" row-key="id" :columns="productsTable.columns"
:pagination.sync="productsTable.pagination" :filter="productsFilter">
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="pink"
dense
@click="deleteProduct(props.row.id)"
icon="delete"
/>
<q-btn size="sm" color="pink" dense @click="deleteProduct(props.row.id)" icon="delete" />
</q-td>
<q-td auto-width>
<q-btn
size="sm"
color="accent"
dense
@click="editProduct(props.row)"
icon="edit"
/>
<q-btn size="sm" color="accent" dense @click="editProduct(props.row)" icon="edit" />
</q-td>
<q-td key="id" :props="props"> {{props.row.id}} </q-td>
@ -186,112 +125,74 @@
</q-tab-panel>
<q-tab-panel name="orders">
<div v-if="stall">
<order-list
:adminkey="adminkey"
:inkey="inkey"
:stall-id="stallId"
@customer-selected="customerSelectedForOrder"
></order-list>
<order-list :adminkey="adminkey" :inkey="inkey" :stall-id="stallId"
@customer-selected="customerSelectedForOrder"></order-list>
</div>
</q-tab-panel>
</q-tab-panels>
<q-dialog v-model="productDialog.showDialog" position="top">
<q-card v-if="stall" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendProductFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="productDialog.data.name"
label="Name"
></q-input>
<q-input filled dense v-model.trim="productDialog.data.name" label="Name"></q-input>
<q-input
filled
dense
v-model.trim="productDialog.data.config.description"
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 filled dense v-model.trim="productDialog.data.config.description" 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
filled
dense
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-input filled dense 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
v-for="imageUrl in productDialog.data.images"
:key="imageUrl"
removable
@remove="removeProductImage(imageUrl)"
color="primary"
text-color="white"
>
<q-chip v-for="imageUrl in productDialog.data.images" :key="imageUrl" removable
@remove="removeProductImage(imageUrl)" color="primary" text-color="white">
<span v-text="imageUrl.split('/').pop()"></span>
</q-chip>
<q-input
filled
dense
v-model.number="productDialog.data.price"
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>
<q-input filled dense v-model.number="productDialog.data.price" 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">
<q-btn
v-if="productDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Product</q-btn
>
<q-btn v-if="productDialog.data.id" type="submit"
:label="productDialog.data.pending ? 'Restore Product' : 'Update Product'" unelevated
color="primary"></q-btn>
<q-btn
v-else
unelevated
color="primary"
:disable="!productDialog.data.price
<q-btn v-else unelevated color="primary" :disable="!productDialog.data.price
|| !productDialog.data.name
|| !productDialog.data.quantity"
type="submit"
>Create Product</q-btn
>
|| !productDialog.data.quantity" type="submit">Create Product</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>
</q-form>
</q-card>
</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',
stall: null,
products: [],
pendingProducts: [],
productDialog: {
showDialog: false,
showRestore: false,
url: true,
data: {
id: null,
@ -106,15 +108,15 @@ async function stallDetails(path) {
mapStall: function (stall) {
stall.shipping_zones.forEach(
z =>
(z.label = z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', '))
(z.label = z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', '))
)
return stall
},
getStall: async function () {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.inkey
@ -126,7 +128,7 @@ async function stallDetails(path) {
},
updateStall: async function () {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'PUT',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.adminkey,
@ -189,14 +191,14 @@ async function stallDetails(path) {
this.productDialog.data.images.splice(index, 1)
}
},
getProducts: async function () {
getProducts: async function (pending = false) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/stall/product/' + this.stall.id,
`/nostrmarket/api/v1/stall/product/${this.stall.id}?pending=${pending}`,
this.inkey
)
this.products = data
return data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
@ -215,6 +217,7 @@ async function stallDetails(path) {
}
this.productDialog.showDialog = false
if (this.productDialog.data.id) {
data.pending = false
this.updateProduct(data)
} else {
this.createProduct(data)
@ -222,7 +225,7 @@ async function stallDetails(path) {
},
updateProduct: async function (product) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'PATCH',
'/nostrmarket/api/v1/product/' + product.id,
this.adminkey,
@ -231,6 +234,8 @@ async function stallDetails(path) {
const index = this.products.findIndex(r => r.id === product.id)
if (index !== -1) {
this.products.splice(index, 1, data)
} else {
this.products.unshift(data)
}
this.$q.notify({
type: 'positive',
@ -244,7 +249,7 @@ async function stallDetails(path) {
},
createProduct: async function (payload) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/product',
this.adminkey,
@ -262,7 +267,7 @@ async function stallDetails(path) {
}
},
editProduct: async function (product) {
this.productDialog.data = {...product}
this.productDialog.data = { ...product }
this.productDialog.showDialog = true
},
deleteProduct: async function (productId) {
@ -289,8 +294,8 @@ async function stallDetails(path) {
}
})
},
showNewProductDialog: async function () {
this.productDialog.data = {
showNewProductDialog: async function (data) {
this.productDialog.data = data || {
id: null,
name: '',
description: '',
@ -305,13 +310,21 @@ async function stallDetails(path) {
}
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) {
this.$emit('customer-selected-for-order', customerPubkey)
}
},
created: async function () {
await this.getStall()
await this.getProducts()
this.products = await this.getProducts()
}
})
}

View file

@ -1,21 +1,23 @@
<div>
<div class="row items-center no-wrap q-mb-md">
<div class="col q-pr-lg">
<q-btn
@click="openCreateStallDialog"
unelevated
color="green"
class="float-left"
>New Stall (Store)</q-btn
>
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
class="float-right"
>
<q-btn-dropdown @click="openCreateStallDialog()" unelevated split color="green" class="float-left"
label="New Stall (Store)">
<q-item @click="openCreateStallDialog()" clickable v-close-popup>
<q-item-section>
<q-item-label>New Stall</q-item-label>
<q-item-label caption>Create a new stall</q-item-label>
</q-item-section>
</q-item>
<q-item @click="openSelectPendingStallDialog" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Stall</q-item-label>
<q-item-label caption>Restore existing stall from Nostr</q-item-label>
</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>
<q-icon name="search"></q-icon>
</template>
@ -23,26 +25,13 @@
</div>
</div>
<q-table
flat
dense
:data="stalls"
row-key="id"
:columns="stallsTable.columns"
:pagination.sync="stallsTable.pagination"
:filter="filter"
>
<q-table flat dense :data="stalls" row-key="id" :columns="stallsTable.columns"
:pagination.sync="stallsTable.pagination" :filter="filter">
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
<q-btn size="sm" color="accent" round dense @click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'" />
</q-td>
<q-td key="id" :props="props"> {{props.row.name}} </q-td>
@ -61,17 +50,10 @@
<q-td colspan="100%">
<div class="row items-center q-mb-lg">
<div class="col-12">
<stall-details
:stall-id="props.row.id"
:adminkey="adminkey"
:inkey="inkey"
:wallet-options="walletOptions"
:zone-options="zoneOptions"
:currencies="currencies"
@stall-deleted="handleStallDeleted"
@stall-updated="handleStallUpdated"
@customer-selected-for-order="customerSelectedForOrder"
></stall-details>
<stall-details :stall-id="props.row.id" :adminkey="adminkey" :inkey="inkey"
:wallet-options="walletOptions" :zone-options="zoneOptions" :currencies="currencies"
@stall-deleted="handleStallDeleted" @stall-updated="handleStallUpdated"
@customer-selected-for-order="customerSelectedForOrder"></stall-details>
</div>
</div>
</q-td>
@ -83,64 +65,54 @@
<q-dialog v-model="stallDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendStallFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="stallDialog.data.name"
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-input filled dense v-model.trim="stallDialog.data.name" 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
filled
dense
v-model="stallDialog.data.currency"
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>
<q-select filled dense v-model="stallDialog.data.currency" 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">
<q-btn
unelevated
color="primary"
:disable="!stallDialog.data.name
<q-btn unelevated color="primary" :disable="!stallDialog.data.name
|| !stallDialog.data.currency
|| !stallDialog.data.wallet
|| !stallDialog.data.shippingZones
|| !stallDialog.data.shippingZones.length"
type="submit"
>Create Stall</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
|| !stallDialog.data.shippingZones.length" type="submit"
:label="stallDialog.data.id ? 'Restore Stall' : 'Create Stall'"></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</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>

View file

@ -9,9 +9,11 @@ async function stallList(path) {
return {
filter: '',
stalls: [],
pendingStalls: [],
currencies: [],
stallDialog: {
show: false,
showRestore: false,
data: {
name: '',
description: '',
@ -69,7 +71,7 @@ async function stallList(path) {
},
methods: {
sendStallFormData: async function () {
await this.createStall({
const stallData = {
name: this.stallDialog.data.name,
wallet: this.stallDialog.data.wallet,
currency: this.stallDialog.data.currency,
@ -77,11 +79,18 @@ async function stallList(path) {
config: {
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) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/stall',
this.adminkey,
@ -98,39 +107,86 @@ async function stallList(path) {
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 () {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/currencies',
this.inkey
)
this.currencies = ['sat', ...data]
return ['sat', ...data]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getStalls: async function () {
getStalls: async function (pending = false) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/stall',
`/nostrmarket/api/v1/stall?pending=${pending}`,
this.inkey
)
this.stalls = data.map(s => ({...s, expanded: false}))
return data.map(s => ({ ...s, expanded: false }))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getZones: async function () {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/zone',
this.inkey
)
this.zoneOptions = data.map(z => ({
return data.map(z => ({
...z,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
@ -139,6 +195,7 @@ async function stallList(path) {
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
handleStallDeleted: function (stallId) {
this.stalls = _.reject(this.stalls, function (obj) {
@ -152,9 +209,9 @@ async function stallList(path) {
this.stalls.splice(index, 1, stall)
}
},
openCreateStallDialog: async function () {
await this.getCurrencies()
await this.getZones()
openCreateStallDialog: async function (stallData) {
this.currencies = await this.getCurrencies()
this.zoneOptions = await this.getZones()
if (!this.zoneOptions || !this.zoneOptions.length) {
this.$q.notify({
type: 'warning',
@ -162,7 +219,7 @@ async function stallList(path) {
})
return
}
this.stallDialog.data = {
this.stallDialog.data = stallData || {
name: '',
description: '',
wallet: null,
@ -171,14 +228,35 @@ async function stallList(path) {
}
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) {
this.$emit('customer-selected-for-order', customerPubkey)
}
},
created: async function () {
await this.getStalls()
await this.getCurrencies()
await this.getZones()
this.stalls = await this.getStalls()
this.currencies = await this.getCurrencies()
this.zoneOptions = await this.getZones()
}
})
}

View file

@ -28,7 +28,8 @@ const merchant = async () => {
data: {
privateKey: null
}
}
},
wsConnection: null
}
},
methods: {
@ -114,8 +115,9 @@ const merchant = async () => {
const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
const port = location.port ? `:${location.port}` : ''
const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}`
const wsConnection = new WebSocket(wsUrl)
wsConnection.onmessage = async e => {
console.log('Reconnecting to websocket: ', wsUrl)
this.wsConnection = new WebSocket(wsUrl)
this.wsConnection.onmessage = async e => {
const data = JSON.parse(e.data)
if (data.type === 'new-order') {
this.$q.notify({
@ -124,10 +126,20 @@ const merchant = async () => {
message: 'New Order'
})
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') {
await this.$refs.directMessagesRef.handleNewMessage(data)
}
// order paid
// order shipped
}
} catch (error) {
this.$q.notify({
timeout: 5000,
@ -157,7 +169,11 @@ const merchant = async () => {
},
created: async function () {
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 .crud import (
get_all_customers,
get_all_unique_customers,
get_last_direct_messages_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 .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):
public_keys = await get_public_keys_for_merchants()
for p in public_keys:
last_order_time = await get_last_order_time(p)
last_dm_time = await get_last_direct_messages_time(p)
merchant_ids = await get_merchants_ids_with_pubkeys()
for id, pk in merchant_ids:
last_order_time = await get_last_order_time(id)
last_dm_time = await get_last_direct_messages_time(id)
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:
await nostr_client.subscribe_to_user_profile(c.public_key, c.event_created_at)

View file

@ -1,6 +1,6 @@
import json
from http import HTTPStatus
from typing import List, Optional, Union
from typing import List, Optional
from fastapi import Depends
from fastapi.exceptions import HTTPException
@ -37,9 +37,11 @@ from .crud import (
get_direct_messages,
get_merchant_by_pubkey,
get_merchant_for_user,
get_merchants_ids_with_pubkeys,
get_order,
get_orders,
get_orders_for_stall,
get_orders_from_direct_messages,
get_product,
get_products,
get_stall,
@ -57,6 +59,7 @@ from .helpers import normalize_public_key
from .models import (
Customer,
DirectMessage,
DirectMessageType,
Merchant,
Order,
OrderStatusUpdate,
@ -69,7 +72,11 @@ from .models import (
Stall,
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 ########################################
@ -88,6 +95,20 @@ async def api_create_merchant(
assert merchant == None, "A merchant already exists for this user"
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)
return merchant
@ -138,6 +159,7 @@ async def api_delete_merchant(
await delete_merchant_zones(merchant.id)
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)
except AssertionError as ex:
raise HTTPException(
@ -317,6 +339,7 @@ async def api_create_stall(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Stall:
try:
# shipping_zones = await
data.validate_stall()
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)
stall.config.event_id = event.id
stall.event_id = event.id
await update_stall(merchant.id, stall)
return stall
@ -359,7 +382,7 @@ async def api_update_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)
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")
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:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stalls = await get_stalls(merchant.id)
stalls = await get_stalls(merchant.id, pending)
return stalls
except AssertionError as ex:
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}")
async def api_get_stall_products(
stall_id: str,
pending: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
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
except AssertionError as ex:
raise HTTPException(
@ -494,7 +520,7 @@ async def api_delete_stall(
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)
except AssertionError as ex:
@ -532,7 +558,7 @@ async def api_create_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)
return product
@ -568,7 +594,7 @@ async def api_update_product(
product = await update_product(merchant.id, 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)
return product
@ -626,9 +652,7 @@ async def api_delete_product(
)
await delete_product(merchant.id, product_id)
event = await sign_and_send_to_nostr(merchant, product, True)
product.config.event_id = event.id
await update_product(merchant.id, product)
await sign_and_send_to_nostr(merchant, product, True)
except AssertionError as ex:
raise HTTPException(
@ -720,9 +744,23 @@ async def api_update_order_status(
assert order, "Cannot find updated order"
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 = 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)
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 ########################################
@ -752,7 +822,7 @@ async def api_get_messages(
assert merchant, f"Merchant cannot be found"
messages = await get_direct_messages(merchant.id, public_key)
await update_customer_no_unread_messages(public_key)
await update_customer_no_unread_messages(merchant.id, public_key)
return messages
except AssertionError as ex:
raise HTTPException(
@ -822,7 +892,7 @@ async def api_get_customers(
@nostrmarket_ext.post("/api/v1/customer")
async def api_createcustomer(
async def api_create_customer(
data: Customer,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Customer:
@ -867,7 +937,9 @@ async def api_list_currencies_available():
@nostrmarket_ext.put("/api/v1/restart")
async def restart_nostr_client(wallet: WalletTypeInfo = Depends(require_admin_key)):
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:
logger.warning(ex)
@ -880,5 +952,11 @@ async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)):
except Exception as 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}