Merge pull request #15 from lnbits/customer_chat

Customer chat
This commit is contained in:
Vlad Stan 2023-03-14 16:46:09 +02:00 committed by GitHub
commit 1660ef6ef8
20 changed files with 997 additions and 430 deletions

View file

@ -42,9 +42,13 @@ from .views_api import * # noqa
def nostrmarket_start():
async def _subscribe_to_nostr_client():
# wait for 'nostrclient' extension to initialize
await asyncio.sleep(10)
await subscribe_to_nostr_client(recieve_event_queue, send_req_queue)
async def _wait_for_nostr_events():
# wait for this extension to initialize
await asyncio.sleep(5)
await wait_for_nostr_events(recieve_event_queue, send_req_queue)
loop = asyncio.get_event_loop()

301
crud.py
View file

@ -5,8 +5,10 @@ from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import (
DirectMessage,
Merchant,
Order,
PartialDirectMessage,
PartialMerchant,
PartialProduct,
PartialStall,
@ -71,19 +73,26 @@ async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
return Merchant.from_row(row) if row else None
async def delete_merchants(merchant_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.merchants WHERE id = ?",
(merchant_id,),
)
######################################## ZONES ########################################
async def create_zone(user_id: str, data: PartialZone) -> Zone:
async def create_zone(merchant_id: str, data: PartialZone) -> Zone:
zone_id = urlsafe_short_hash()
await db.execute(
f"""
INSERT INTO nostrmarket.zones (id, user_id, name, currency, cost, regions)
INSERT INTO nostrmarket.zones (id, merchant_id, name, currency, cost, regions)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
zone_id,
user_id,
merchant_id,
data.name,
data.currency,
data.cost,
@ -91,55 +100,67 @@ async def create_zone(user_id: str, data: PartialZone) -> Zone:
),
)
zone = await get_zone(user_id, zone_id)
zone = await get_zone(merchant_id, zone_id)
assert zone, "Newly created zone couldn't be retrieved"
return zone
async def update_zone(user_id: str, z: Zone) -> Optional[Zone]:
async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]:
await db.execute(
f"UPDATE nostrmarket.zones SET name = ?, cost = ?, regions = ? WHERE id = ? AND user_id = ?",
(z.name, z.cost, json.dumps(z.countries), z.id, user_id),
f"UPDATE nostrmarket.zones SET name = ?, cost = ?, regions = ? WHERE id = ? AND merchant_id = ?",
(z.name, z.cost, json.dumps(z.countries), z.id, merchant_id),
)
return await get_zone(user_id, z.id)
return await get_zone(merchant_id, z.id)
async def get_zone(user_id: str, zone_id: str) -> Optional[Zone]:
async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.zones WHERE user_id = ? AND id = ?",
"SELECT * FROM nostrmarket.zones WHERE merchant_id = ? AND id = ?",
(
user_id,
merchant_id,
zone_id,
),
)
return Zone.from_row(row) if row else None
async def get_zones(user_id: str) -> List[Zone]:
async def get_zones(merchant_id: str) -> List[Zone]:
rows = await db.fetchall(
"SELECT * FROM nostrmarket.zones WHERE user_id = ?", (user_id,)
"SELECT * FROM nostrmarket.zones WHERE merchant_id = ?", (merchant_id,)
)
return [Zone.from_row(row) for row in rows]
async def delete_zone(zone_id: str) -> None:
# todo: add user_id
await db.execute("DELETE FROM nostrmarket.zones WHERE id = ?", (zone_id,))
async def delete_zone(merchant_id: str, zone_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.zones WHERE merchant_id = ? AND id = ?",
(
merchant_id,
zone_id,
),
)
async def delete_merchant_zones(merchant_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.zones WHERE merchant_id = ?", (merchant_id,)
)
######################################## STALL ########################################
async def create_stall(user_id: str, data: PartialStall) -> Stall:
async def create_stall(merchant_id: str, data: PartialStall) -> Stall:
stall_id = urlsafe_short_hash()
await db.execute(
f"""
INSERT INTO nostrmarket.stalls (user_id, id, wallet, name, currency, zones, meta)
INSERT INTO nostrmarket.stalls (merchant_id, id, wallet, name, currency, zones, meta)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
merchant_id,
stall_id,
data.wallet,
data.name,
@ -151,35 +172,35 @@ async def create_stall(user_id: str, data: PartialStall) -> Stall:
),
)
stall = await get_stall(user_id, stall_id)
stall = await get_stall(merchant_id, stall_id)
assert stall, "Newly created stall couldn't be retrieved"
return stall
async def get_stall(user_id: str, stall_id: str) -> Optional[Stall]:
async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.stalls WHERE user_id = ? AND id = ?",
"SELECT * FROM nostrmarket.stalls WHERE merchant_id = ? AND id = ?",
(
user_id,
merchant_id,
stall_id,
),
)
return Stall.from_row(row) if row else None
async def get_stalls(user_id: str) -> List[Stall]:
async def get_stalls(merchant_id: str) -> List[Stall]:
rows = await db.fetchall(
"SELECT * FROM nostrmarket.stalls WHERE user_id = ?",
(user_id,),
"SELECT * FROM nostrmarket.stalls WHERE merchant_id = ?",
(merchant_id,),
)
return [Stall.from_row(row) for row in rows]
async def update_stall(user_id: str, stall: Stall) -> Optional[Stall]:
async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]:
await db.execute(
f"""
UPDATE nostrmarket.stalls SET wallet = ?, name = ?, currency = ?, zones = ?, meta = ?
WHERE user_id = ? AND id = ?
WHERE merchant_id = ? AND id = ?
""",
(
stall.wallet,
@ -189,36 +210,43 @@ async def update_stall(user_id: str, stall: Stall) -> Optional[Stall]:
[z.dict() for z in stall.shipping_zones]
), # todo: cost is float. should be int for sats
json.dumps(stall.config.dict()),
user_id,
merchant_id,
stall.id,
),
)
return await get_stall(user_id, stall.id)
return await get_stall(merchant_id, stall.id)
async def delete_stall(user_id: str, stall_id: str) -> None:
async def delete_stall(merchant_id: str, stall_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.stalls WHERE user_id =? AND id = ?",
"DELETE FROM nostrmarket.stalls WHERE merchant_id =? AND id = ?",
(
user_id,
merchant_id,
stall_id,
),
)
async def delete_merchant_stalls(merchant_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.stalls WHERE merchant_id = ?",
(merchant_id,),
)
######################################## PRODUCTS ########################################
async def create_product(user_id: str, data: PartialProduct) -> Product:
async def create_product(merchant_id: str, data: PartialProduct) -> Product:
product_id = urlsafe_short_hash()
await db.execute(
f"""
INSERT INTO nostrmarket.products (user_id, id, stall_id, name, image, price, quantity, category_list, meta)
INSERT INTO nostrmarket.products (merchant_id, id, stall_id, name, image, price, quantity, category_list, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
merchant_id,
product_id,
data.stall_id,
data.name,
@ -229,18 +257,18 @@ async def create_product(user_id: str, data: PartialProduct) -> Product:
json.dumps(data.config.dict()),
),
)
product = await get_product(user_id, product_id)
product = await get_product(merchant_id, product_id)
assert product, "Newly created product couldn't be retrieved"
return product
async def update_product(user_id: str, product: Product) -> Product:
async def update_product(merchant_id: str, product: Product) -> Product:
await db.execute(
f"""
UPDATE nostrmarket.products set name = ?, image = ?, price = ?, quantity = ?, category_list = ?, meta = ?
WHERE user_id = ? AND id = ?
WHERE merchant_id = ? AND id = ?
""",
(
product.name,
@ -249,40 +277,42 @@ async def update_product(user_id: str, product: Product) -> Product:
product.quantity,
json.dumps(product.categories),
json.dumps(product.config.dict()),
user_id,
merchant_id,
product.id,
),
)
updated_product = await get_product(user_id, product.id)
updated_product = await get_product(merchant_id, product.id)
assert updated_product, "Updated product couldn't be retrieved"
return updated_product
async def get_product(user_id: str, product_id: str) -> Optional[Product]:
async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.products WHERE user_id =? AND id = ?",
"SELECT * FROM nostrmarket.products WHERE merchant_id =? AND id = ?",
(
user_id,
merchant_id,
product_id,
),
)
return Product.from_row(row) if row else None
async def get_products(user_id: str, stall_id: str) -> List[Product]:
async def get_products(merchant_id: str, stall_id: str) -> List[Product]:
rows = await db.fetchall(
"SELECT * FROM nostrmarket.products WHERE user_id = ? AND stall_id = ?",
(user_id, stall_id),
"SELECT * FROM nostrmarket.products WHERE merchant_id = ? AND stall_id = ?",
(merchant_id, stall_id),
)
return [Product.from_row(row) for row in rows]
async def get_products_by_ids(user_id: str, product_ids: List[str]) -> List[Product]:
async def get_products_by_ids(
merchant_id: str, product_ids: List[str]
) -> 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 user_id = ? AND id IN ({q})",
(user_id, *product_ids),
f"SELECT id, stall_id, name, price, quantity, category_list, meta FROM nostrmarket.products WHERE merchant_id = ? AND id IN ({q})",
(merchant_id, *product_ids),
)
return [Product.from_row(row) for row in rows]
@ -300,30 +330,54 @@ async def get_wallet_for_product(product_id: str) -> Optional[str]:
return row[0] if row else None
async def delete_product(user_id: str, product_id: str) -> None:
async def delete_product(merchant_id: str, product_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.products WHERE user_id =? AND id = ?",
"DELETE FROM nostrmarket.products WHERE merchant_id =? AND id = ?",
(
user_id,
merchant_id,
product_id,
),
)
async def delete_merchant_products(merchant_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.products WHERE merchant_id = ?",
(merchant_id,),
)
######################################## ORDERS ########################################
async def create_order(user_id: str, o: Order) -> Order:
async def create_order(merchant_id: str, o: Order) -> Order:
await db.execute(
f"""
INSERT INTO nostrmarket.orders (user_id, id, event_id, pubkey, address, contact_data, extra_data, order_items, stall_id, invoice_id, total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO nostrmarket.orders (
merchant_id,
id,
event_id,
event_created_at,
merchant_public_key,
public_key,
address,
contact_data,
extra_data,
order_items,
stall_id,
invoice_id,
total
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING
""",
(
user_id,
merchant_id,
o.id,
o.event_id,
o.pubkey,
o.event_created_at,
o.merchant_public_key,
o.public_key,
o.address,
json.dumps(o.contact.dict() if o.contact else {}),
json.dumps(o.extra.dict()),
@ -333,53 +387,64 @@ async def create_order(user_id: str, o: Order) -> Order:
o.total,
),
)
order = await get_order(user_id, o.id)
order = await get_order(merchant_id, o.id)
assert order, "Newly created order couldn't be retrieved"
return order
async def get_order(user_id: str, order_id: str) -> Optional[Order]:
async def get_order(merchant_id: str, order_id: str) -> Optional[Order]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.orders WHERE user_id =? AND id = ?",
"SELECT * FROM nostrmarket.orders WHERE merchant_id =? AND id = ?",
(
user_id,
merchant_id,
order_id,
),
)
return Order.from_row(row) if row else None
async def get_order_by_event_id(user_id: str, event_id: str) -> Optional[Order]:
async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.orders WHERE user_id =? AND event_id =?",
"SELECT * FROM nostrmarket.orders WHERE merchant_id =? AND event_id =?",
(
user_id,
merchant_id,
event_id,
),
)
return Order.from_row(row) if row else None
async def get_orders(user_id: str) -> List[Order]:
async def get_orders(merchant_id: str) -> List[Order]:
rows = await db.fetchall(
"SELECT * FROM nostrmarket.orders WHERE user_id = ? ORDER BY time DESC",
(user_id,),
"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? ORDER BY time DESC",
(merchant_id,),
)
return [Order.from_row(row) for row in rows]
async def get_orders_for_stall(user_id: str, stall_id: str) -> List[Order]:
async def get_orders_for_stall(merchant_id: str, stall_id: str) -> List[Order]:
rows = await db.fetchall(
"SELECT * FROM nostrmarket.orders WHERE user_id = ? AND stall_id = ? ORDER BY time DESC",
"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? AND stall_id = ? ORDER BY time DESC",
(
user_id,
merchant_id,
stall_id,
),
)
return [Order.from_row(row) for row in rows]
async def get_last_order_time(public_key: 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
""",
(public_key,),
)
return row[0] if row else 0
async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
await db.execute(
f"UPDATE nostrmarket.orders SET paid = ? WHERE id = ?",
@ -393,11 +458,11 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]
async def update_order_shipped_status(
user_id: str, order_id: str, shipped: bool
merchant_id: str, order_id: str, shipped: bool
) -> Optional[Order]:
await db.execute(
f"UPDATE nostrmarket.orders SET shipped = ? WHERE user_id = ? AND id = ?",
(shipped, user_id, order_id),
f"UPDATE nostrmarket.orders SET shipped = ? WHERE merchant_id = ? AND id = ?",
(shipped, merchant_id, order_id),
)
row = await db.fetchone(
@ -405,3 +470,91 @@ async def update_order_shipped_status(
(order_id,),
)
return Order.from_row(row) if row else None
async def delete_merchant_orders(merchant_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.orders WHERE merchant_id = ?",
(merchant_id,),
)
######################################## MESSAGES ########################################L
async def create_direct_message(
merchant_id: str, dm: PartialDirectMessage
) -> DirectMessage:
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 (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING
""",
(
merchant_id,
dm_id,
dm.event_id,
dm.event_created_at,
dm.message,
dm.public_key,
dm.incoming,
),
)
if dm.event_id:
msg = await get_direct_message_by_event_id(merchant_id, dm.event_id)
else:
msg = await get_direct_message(merchant_id, dm_id)
assert msg, "Newly created dm couldn't be retrieved"
return msg
async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.direct_messages WHERE merchant_id = ? AND id = ?",
(
merchant_id,
dm_id,
),
)
return DirectMessage.from_row(row) if row else None
async def get_direct_message_by_event_id(
merchant_id: str, event_id: str
) -> Optional[DirectMessage]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.direct_messages WHERE merchant_id = ? AND event_id = ?",
(
merchant_id,
event_id,
),
)
return DirectMessage.from_row(row) if row else None
async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectMessage]:
rows = await db.fetchall(
"SELECT * FROM nostrmarket.direct_messages WHERE merchant_id = ? AND public_key = ? ORDER BY event_created_at",
(merchant_id, public_key),
)
return [DirectMessage.from_row(row) for row in rows]
async def get_last_direct_messages_time(public_key: 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
""",
(public_key,),
)
return row[0] if row else 0
async def delete_merchant_direct_messages(merchant_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.direct_messages WHERE merchant_id = ?",
(merchant_id,),
)

View file

@ -16,6 +16,8 @@ def get_shared_secret(privkey: str, pubkey: str):
def decrypt_message(encoded_message: str, encryption_key) -> str:
encoded_data = encoded_message.split("?iv=")
if len(encoded_data) == 1:
return encoded_data[0]
encoded_content, encoded_iv = encoded_data[0], encoded_data[1]
iv = base64.b64decode(encoded_iv)

View file

@ -18,11 +18,11 @@ async def m001_initial(db):
"""
Initial stalls table.
"""
# user_id, id, wallet, name, currency, zones, meta
await db.execute(
"""
CREATE TABLE nostrmarket.stalls (
user_id TEXT NOT NULL,
merchant_id TEXT NOT NULL,
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
@ -39,7 +39,7 @@ async def m001_initial(db):
await db.execute(
"""
CREATE TABLE nostrmarket.products (
user_id TEXT NOT NULL,
merchant_id TEXT NOT NULL,
id TEXT PRIMARY KEY,
stall_id TEXT NOT NULL,
name TEXT NOT NULL,
@ -59,7 +59,7 @@ async def m001_initial(db):
"""
CREATE TABLE nostrmarket.zones (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
merchant_id TEXT NOT NULL,
name TEXT NOT NULL,
currency TEXT NOT NULL,
cost REAL NOT NULL,
@ -75,10 +75,12 @@ async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE nostrmarket.orders (
user_id TEXT NOT NULL,
merchant_id TEXT NOT NULL,
id TEXT PRIMARY KEY,
event_id TEXT,
pubkey TEXT NOT NULL,
event_created_at INTEGER NOT NULL,
public_key TEXT NOT NULL,
merchant_public_key TEXT NOT NULL,
contact_data TEXT NOT NULL DEFAULT '{empty_object}',
extra_data TEXT NOT NULL DEFAULT '{empty_object}',
order_items TEXT NOT NULL,
@ -88,20 +90,8 @@ async def m001_initial(db):
invoice_id TEXT NOT NULL,
paid BOOLEAN NOT NULL DEFAULT false,
shipped BOOLEAN NOT NULL DEFAULT false,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
"""
Initial market table.
"""
await db.execute(
"""
CREATE TABLE nostrmarket.markets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
UNIQUE(event_id)
);
"""
)
@ -111,13 +101,17 @@ async def m001_initial(db):
"""
await db.execute(
f"""
CREATE TABLE nostrmarket.messages (
CREATE TABLE nostrmarket.direct_messages (
merchant_id TEXT NOT NULL,
id TEXT PRIMARY KEY,
msg TEXT NOT NULL,
pubkey TEXT NOT NULL,
conversation_id TEXT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
event_id TEXT,
event_created_at INTEGER NOT NULL,
message TEXT NOT NULL,
public_key TEXT NOT NULL,
incoming BOOLEAN NOT NULL DEFAULT false,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
UNIQUE(event_id)
);
"""
)
@ -126,8 +120,8 @@ async def m001_initial(db):
Create indexes for message fetching
"""
await db.execute(
"CREATE INDEX idx_messages_timestamp ON nostrmarket.messages (timestamp DESC)"
"CREATE INDEX idx_messages_timestamp ON nostrmarket.direct_messages (time DESC)"
)
await db.execute(
"CREATE INDEX idx_messages_conversations ON nostrmarket.messages (conversation_id)"
"CREATE INDEX idx_event_id ON nostrmarket.direct_messages (event_id)"
)

View file

@ -281,7 +281,9 @@ class OrderExtra(BaseModel):
class PartialOrder(BaseModel):
id: str
event_id: Optional[str]
pubkey: str
event_created_at: Optional[int]
public_key: str
merchant_public_key: str
items: List[OrderItem]
contact: Optional[OrderContact]
address: Optional[str]
@ -359,3 +361,24 @@ class PaymentRequest(BaseModel):
id: str
message: Optional[str]
payment_options: List[PaymentOption]
######################################## MESSAGE ########################################
class PartialDirectMessage(BaseModel):
event_id: Optional[str]
event_created_at: Optional[int]
message: str
public_key: str
incoming: bool = False
time: Optional[int]
class DirectMessage(PartialDirectMessage):
id: str
@classmethod
def from_row(cls, row: Row) -> "DirectMessage":
dm = cls(**dict(row))
return dm

View file

@ -1,28 +1,18 @@
from threading import Thread
from typing import Callable
import httpx
from loguru import logger
from websocket import WebSocketApp
from lnbits.app import settings
from lnbits.helpers import url_for
from .. import send_req_queue
from .event import NostrEvent
async def publish_nostr_event(e: NostrEvent):
url = url_for("/nostrclient/api/v1/publish", external=True)
data = dict(e)
print("### published", dict(data))
async with httpx.AsyncClient() as client:
try:
await client.post(
url,
json=data,
)
except Exception as ex:
logger.warning(ex)
print("### publish_nostr_event", e.dict())
await send_req_queue.put(["EVENT", e.dict()])
async def connect_to_nostrclient_ws(
@ -33,7 +23,7 @@ async def connect_to_nostrclient_ws(
logger.debug(f"Subscribing to websockets for nostrclient extension")
ws = WebSocketApp(
f"ws://localhost:{settings.port}/nostrclient/api/v1/filters",
f"ws://localhost:{settings.port}/nostrclient/api/v1/relay",
on_message=on_message,
on_open=on_open,
on_error=on_error,
@ -44,23 +34,3 @@ async def connect_to_nostrclient_ws(
wst.start()
return ws
# async def handle_event(event, pubkeys):
# tags = [t[1] for t in event["tags"] if t[0] == "p"]
# to_merchant = None
# if tags and len(tags) > 0:
# to_merchant = tags[0]
# if event["pubkey"] in pubkeys or to_merchant in pubkeys:
# logger.debug(f"Event sent to {to_merchant}")
# pubkey = to_merchant if to_merchant in pubkeys else event["pubkey"]
# # Send event to market extension
# await send_event_to_market(event=event, pubkey=pubkey)
# async def send_event_to_market(event: dict, pubkey: str):
# # Sends event to market extension, for decrypt and handling
# market_url = url_for(f"/market/api/v1/nip04/{pubkey}", external=True)
# async with httpx.AsyncClient() as client:
# await client.post(url=market_url, json=event)

223
services.py Normal file
View file

@ -0,0 +1,223 @@
import json
from typing import Optional
from loguru import logger
from lnbits.core import create_invoice, get_wallet
from .crud import (
create_direct_message,
create_order,
get_merchant_by_pubkey,
get_order,
get_order_by_event_id,
get_products_by_ids,
get_wallet_for_product,
update_order_paid_status,
)
from .helpers import order_from_json
from .models import (
Merchant,
Nostrable,
Order,
OrderExtra,
OrderStatusUpdate,
PartialDirectMessage,
PartialOrder,
PaymentOption,
PaymentRequest,
)
from .nostr.event import NostrEvent
from .nostr.nostr_client import publish_nostr_event
async def create_new_order(
merchant_public_key: str, data: PartialOrder
) -> Optional[PaymentRequest]:
merchant = await get_merchant_by_pubkey(merchant_public_key)
assert merchant, "Cannot find merchant!"
if await get_order(merchant.id, data.id):
return None
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
return None
products = await get_products_by_ids(
merchant.id, [p.product_id for p in data.items]
)
data.validate_order_items(products)
total_amount = await data.total_sats(products)
wallet_id = await get_wallet_for_product(data.items[0].product_id)
assert wallet_id, "Missing wallet for order `{data.id}`"
payment_hash, invoice = await create_invoice(
wallet_id=wallet_id,
amount=round(total_amount),
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
extra={
"tag": "nostrmarket",
"order_id": data.id,
"merchant_pubkey": merchant.public_key,
},
)
order = Order(
**data.dict(),
stall_id=products[0].stall_id,
invoice_id=payment_hash,
total=total_amount,
extra=await OrderExtra.from_products(products),
)
await create_order(merchant.id, order)
return PaymentRequest(
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)]
)
async def sign_and_send_to_nostr(
merchant: Merchant, n: Nostrable, delete=False
) -> NostrEvent:
event = (
n.to_nostr_delete_event(merchant.public_key)
if delete
else n.to_nostr_event(merchant.public_key)
)
event.sig = merchant.sign_hash(bytes.fromhex(event.id))
await publish_nostr_event(event)
return event
async def handle_order_paid(order_id: str, merchant_pubkey: str):
try:
order = await update_order_paid_status(order_id, True)
assert order, f"Paid order cannot be found. Order id: {order_id}"
order_status = OrderStatusUpdate(
id=order_id, message="Payment received.", paid=True, shipped=order.shipped
)
merchant = await get_merchant_by_pubkey(merchant_pubkey)
assert merchant, f"Merchant cannot be found for order {order_id}"
dm_content = json.dumps(
order_status.dict(), separators=(",", ":"), ensure_ascii=False
)
dm_event = merchant.build_dm_event(dm_content, order.public_key)
await publish_nostr_event(dm_event)
except Exception as ex:
logger.warning(ex)
async def process_nostr_message(msg: str):
try:
type, *rest = json.loads(msg)
if type.upper() == "EVENT":
subscription_id, event = rest
subscription_name, merchant_public_key = subscription_id.split(":")
event = NostrEvent(**event)
if event.kind == 4:
await _handle_nip04_message(
subscription_name, merchant_public_key, event
)
return
except Exception as ex:
logger.warning(ex)
async def _handle_nip04_message(
subscription_name: str, 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}'"
clear_text_msg = merchant.decrypt_message(event.content, event.pubkey)
# print("### clear_text_msg", subscription_name, clear_text_msg)
if subscription_name == "direct-messages-in":
await _handle_incoming_dms(event, merchant, clear_text_msg)
else:
await _handle_outgoing_dms(event, merchant, clear_text_msg)
async def _handle_incoming_dms(
event: NostrEvent, merchant: Merchant, clear_text_msg: str
):
dm_content = await _handle_dirrect_message(
merchant.id,
merchant.public_key,
event.pubkey,
event.id,
event.created_at,
clear_text_msg,
)
if dm_content:
dm_event = merchant.build_dm_event(dm_content, event.pubkey)
await publish_nostr_event(dm_event)
async def _handle_outgoing_dms(
event: NostrEvent, merchant: Merchant, clear_text_msg: str
):
sent_to = event.tag_values("p")
if len(sent_to) != 0:
dm = PartialDirectMessage(
event_id=event.id,
event_created_at=event.created_at,
message=clear_text_msg, # exclude if json
public_key=sent_to[0],
incoming=True,
)
await create_direct_message(merchant.id, dm)
async def _handle_dirrect_message(
merchant_id: str,
merchant_public_key: str,
from_pubkey: str,
event_id: str,
event_created_at: int,
msg: str,
) -> Optional[str]:
order, text_msg = order_from_json(msg)
try:
if order:
order["public_key"] = from_pubkey
order["merchant_public_key"] = merchant_public_key
order["event_id"] = event_id
order["event_created_at"] = event_created_at
return await _handle_new_order(PartialOrder(**order))
else:
# print("### text_msg", text_msg, event_created_at, event_id)
dm = PartialDirectMessage(
event_id=event_id,
event_created_at=event_created_at,
message=text_msg,
public_key=from_pubkey,
incoming=True,
)
await create_direct_message(merchant_id, dm)
return None
except Exception as ex:
logger.warning(ex)
return None
async def _handle_new_order(order: PartialOrder) -> Optional[str]:
### todo: check that event_id not parsed already
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}"
wallet = await get_wallet(wallet_id)
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
new_order = await create_new_order(wallet.user, order)
if new_order:
return json.dumps(new_order.dict(), separators=(",", ":"), ensure_ascii=False)
return None

View file

@ -345,8 +345,9 @@ async function customerStall(path) {
let json = JSON.parse(text)
if (json.id != this.activeOrder) return
if (json.payment_options) {
let payment_request = json.payment_options.find(o => o.type == 'ln')
.link
let payment_request = json.payment_options.find(
o => o.type == 'ln'
).link
if (!payment_request) return
this.loading = false
this.qrCodeDialog.data.payment_request = payment_request

View file

@ -0,0 +1,59 @@
<div>
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">Messages</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
</q-card-section>
<q-card-section>
<!-- <q-select
v-model="customerKey"
:options="Object.keys(messages).map(k => ({label: `${k.slice(0, 25)}...`, value: k}))"
label="Customers"
@input="chatRoom(customerKey)"
emit-value
></q-select> -->
</q-card-section>
<q-card-section>
<div class="chat-container" ref="chatCard">
<div class="chat-box">
<div class="chat-messages" style="height: 45vh">
<q-chat-message
v-for="(dm, index) in messages"
:key="index"
:name="dm.incoming ? 'customer': 'me'"
:text="[dm.message]"
:sent="!dm.incoming"
:bg-color="dm.incoming ? 'white' : 'light-green-2'"
:class="'chat-mesage-index-'+index"
/>
</div>
</div>
<q-card-section>
<q-form @submit="sendDirectMesage" class="full-width chat-input">
<q-input
ref="newMessage"
v-model="newMessage"
placeholder="Message"
class="full-width"
dense
outlined
>
<template>
<q-btn
round
dense
flat
type="submit"
icon="send"
color="primary"
/>
</template>
</q-input>
</q-form>
</q-card-section>
</div>
</q-card-section>
</q-card>
</div>

View file

@ -0,0 +1,72 @@
async function directMessages(path) {
const template = await loadTemplateAsync(path)
Vue.component('direct-messages', {
name: 'direct-messages',
props: ['adminkey', 'inkey'],
template,
data: function () {
return {
activePublicKey:
'83d07a79496f4cbdc50ca585741a79a2df1fd938cfa449f0fccb0ab7352115dd',
messages: [],
newMessage: ''
}
},
methods: {
sendMessage: async function () {},
getDirectMessages: async function () {
if (!this.activePublicKey) {
return
}
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/message/' + this.activePublicKey,
this.inkey
)
this.messages = data
console.log(
'### this.messages',
this.messages.map(m => m.message)
)
this.focusOnChatBox(this.messages.length - 1)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendDirectMesage: async function () {
try {
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/message',
this.adminkey,
{
message: this.newMessage,
public_key: this.activePublicKey
}
)
this.messages = this.messages.concat([data])
console.log('### this.messages', this.messages)
this.newMessage = ''
this.focusOnChatBox(this.messages.length - 1)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
focusOnChatBox: function (index) {
setTimeout(() => {
const lastChatBox = document.getElementsByClassName(
`chat-mesage-index-${index}`
)
if (lastChatBox && lastChatBox[0]) {
lastChatBox[0].scrollIntoView()
}
}, 100)
}
},
created: async function () {
await this.getDirectMessages()
}
})
}

View file

@ -0,0 +1,37 @@
<div>
<q-btn-dropdown
split
unelevated
color="primary"
icon="storefront"
label="Merchant"
>
<q-list>
<q-item disable clickable v-close-popup>
<q-item-section>
<q-item-label>Merchant Profile</q-item-label>
<q-item-label caption
>Edit the merchand name, description, etc</q-item-label
>
</q-item-section>
</q-item>
<q-item @click="toggleMerchantKeys" clickable v-close-popup>
<q-item-section>
<q-item-label v-if="!showKeys">Show Keys</q-item-label>
<q-item-label v-else>Hide Keys</q-item-label>
<q-item-label caption
>Show merchant public and private keys</q-item-label
>
</q-item-section>
</q-item>
<q-item @click="deleteMerchant" clickable v-close-popup>
<q-item-section>
<q-item-label>Delete Merchant</q-item-label>
<q-item-label caption
>Delete all stalls, products and orders</q-item-label
>
</q-item-section>
</q-item>
</q-list></q-btn-dropdown
>
</div>

View file

@ -0,0 +1,48 @@
async function merchantDetails(path) {
const template = await loadTemplateAsync(path)
Vue.component('merchant-details', {
name: 'merchant-details',
props: ['merchant-id', 'adminkey', 'inkey'],
template,
data: function () {
return {
showKeys: false
}
},
methods: {
toggleMerchantKeys: async function () {
this.showKeys = !this.showKeys
this.$emit('show-keys', this.showKeys)
},
deleteMerchant: function () {
LNbits.utils
.confirmDialog(
`
Stalls, products and orders will be deleted also!
Are you sure you want to delete this merchant?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/merchant/' + this.merchantId,
this.adminkey
)
this.$emit('merchant-deleted', this.merchantId)
this.$q.notify({
type: 'positive',
message: 'Merchant Deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
}
},
created: async function () {}
})
}

View file

@ -43,8 +43,8 @@
></q-checkbox>
</q-td>
<q-td key="pubkey" :props="props">
{{toShortId(props.row.pubkey)}}
<q-td key="public_key" :props="props">
{{toShortId(props.row.public_key)}}
</q-td>
<q-td key="time" :props="props"> {{formatDate(props.row.time)}} </q-td>
</q-tr>
@ -115,7 +115,7 @@
dense
readonly
disabled
v-model.trim="props.row.pubkey"
v-model.trim="props.row.public_key"
type="text"
></q-input>
</div>

View file

@ -4,6 +4,7 @@
unelevated
color="primary"
icon="public"
label="Zones"
@click="openZoneDialog()"
>
<q-list>

View file

@ -6,7 +6,7 @@
unelevated
color="green"
class="float-left"
>New Stall</q-btn
>New Stall (Store)</q-btn
>
<q-input
borderless

View file

@ -6,6 +6,10 @@ const merchant = async () => {
await stallDetails('static/components/stall-details/stall-details.html')
await stallList('static/components/stall-list/stall-list.html')
await orderList('static/components/order-list/order-list.html')
await directMessages('static/components/direct-messages/direct-messages.html')
await merchantDetails(
'static/components/merchant-details/merchant-details.html'
)
const nostr = window.NostrTools
@ -51,6 +55,9 @@ const merchant = async () => {
showImportKeysDialog: async function () {
this.importKeyDialog.show = true
},
toggleMerchantKeys: function (value) {
this.showKeys = value
},
createMerchant: async function (privateKey) {
try {
const pubkey = nostr.getPublicKey(privateKey)

127
tasks.py
View file

@ -2,26 +2,20 @@ import asyncio
import json
from asyncio import Queue
import httpx
import websocket
from loguru import logger
from websocket import WebSocketApp
from lnbits.core import get_wallet
from lnbits.core.models import Payment
from lnbits.helpers import Optional, url_for
from lnbits.tasks import register_invoice_listener
from .crud import (
get_merchant_by_pubkey,
get_last_direct_messages_time,
get_last_order_time,
get_public_keys_for_merchants,
get_wallet_for_product,
update_order_paid_status,
)
from .helpers import order_from_json
from .models import OrderStatusUpdate, PartialOrder
from .nostr.event import NostrEvent
from .nostr.nostr_client import connect_to_nostrclient_ws, publish_nostr_event
from .nostr.nostr_client import connect_to_nostrclient_ws
from .services import handle_order_paid, process_nostr_message
async def wait_for_paid_invoices():
@ -45,38 +39,14 @@ async def on_invoice_paid(payment: Payment) -> None:
await handle_order_paid(order_id, merchant_pubkey)
async def handle_order_paid(order_id: str, merchant_pubkey: str):
try:
order = await update_order_paid_status(order_id, True)
assert order, f"Paid order cannot be found. Order id: {order_id}"
order_status = OrderStatusUpdate(
id=order_id, message="Payment received.", paid=True, shipped=order.shipped
)
merchant = await get_merchant_by_pubkey(merchant_pubkey)
assert merchant, f"Merchant cannot be found for order {order_id}"
dm_content = json.dumps(
order_status.dict(), separators=(",", ":"), ensure_ascii=False
)
dm_event = merchant.build_dm_event(dm_content, order.pubkey)
await publish_nostr_event(dm_event)
except Exception as ex:
logger.warning(ex)
async def subscribe_to_nostr_client(recieve_event_queue: Queue, send_req_queue: Queue):
print("### subscribe_nostrclient_ws")
def on_open(_):
logger.info("Connected to 'nostrclient' websocket")
def on_message(_, message):
print("### on_message", message)
# print("### on_message", message)
recieve_event_queue.put_nowait(message)
# wait for 'nostrclient' extension to initialize
await asyncio.sleep(5)
ws: WebSocketApp = None
while True:
try:
@ -98,80 +68,23 @@ async def subscribe_to_nostr_client(recieve_event_queue: Queue, send_req_queue:
async def wait_for_nostr_events(recieve_event_queue: Queue, send_req_queue: Queue):
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)
since = max(last_order_time, last_dm_time)
in_messages_filter = {"kind": 4, "#p": [p]}
out_messages_filter = {"kind": 4, "authors": [p]}
if since != 0:
in_messages_filter["since"] = since
# out_messages_filter["since"] = since
print("### in_messages_filter", in_messages_filter)
print("### out_messages_filter", out_messages_filter)
await send_req_queue.put(["REQ", f"direct-messages-in:{p}", in_messages_filter])
await send_req_queue.put(
["REQ", f"direct-messages:{p}", {"kind": 4, "#p": [p]}]
["REQ", f"direct-messages-out:{p}", out_messages_filter]
)
while True:
message = await recieve_event_queue.get()
await handle_message(message)
async def handle_message(msg: str):
try:
type, subscription_id, event = json.loads(msg)
_, public_key = subscription_id.split(":")
if type.upper() == "EVENT":
event = NostrEvent(**event)
if event.kind == 4:
await handle_nip04_message(public_key, event)
except Exception as ex:
logger.warning(ex)
async def handle_nip04_message(public_key: str, event: NostrEvent):
merchant = await get_merchant_by_pubkey(public_key)
assert merchant, f"Merchant not found for public key '{public_key}'"
clear_text_msg = merchant.decrypt_message(event.content, event.pubkey)
dm_content = await handle_dirrect_message(event.pubkey, event.id, clear_text_msg)
if dm_content:
dm_event = merchant.build_dm_event(dm_content, event.pubkey)
await publish_nostr_event(dm_event)
async def handle_dirrect_message(
from_pubkey: str, event_id: str, msg: str
) -> Optional[str]:
order, text_msg = order_from_json(msg)
try:
if order:
order["pubkey"] = from_pubkey
order["event_id"] = event_id
return await handle_new_order(PartialOrder(**order))
else:
print("### text_msg", text_msg)
return None
except Exception as ex:
logger.warning(ex)
return None
async def handle_new_order(order: PartialOrder):
### todo: check that event_id not parsed already
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}"
wallet = await get_wallet(wallet_id)
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
market_url = url_for(f"/nostrmarket/api/v1/order", external=True)
async with httpx.AsyncClient() as client:
resp = await client.post(
url=market_url,
headers={
"X-Api-Key": wallet.adminkey,
},
json=order.dict(),
)
resp.raise_for_status()
data = resp.json()
if data:
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
return None
await process_nostr_message(message)

View file

@ -27,8 +27,6 @@
></small
>
</p>
<br />
<br />
<a
class="text-secondary"
target="_blank"

View file

@ -2,7 +2,46 @@
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card v-if="!merchant">
<div v-if="merchant && merchant.id">
<q-card>
<q-card-section>
<div class="row">
<div class="col-2">
<merchant-details
:merchant-id="merchant.id"
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
@show-keys="toggleMerchantKeys"
></merchant-details>
</div>
<div class="col-8"></div>
<div class="col-2">
<shipping-zones
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
class="float-right"
></shipping-zones>
</div>
</div>
</q-card-section>
<q-card-section v-if="showKeys">
<key-pair
:public-key="merchant.public_key"
:private-key="merchant.private_key"
></key-pair>
</q-card-section>
</q-card>
<q-card class="q-mt-lg">
<q-card-section>
<stall-list
:adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey"
:wallet-options="g.user.walletOptions"
></stall-list>
</q-card-section>
</q-card>
</div>
<q-card v-else>
<q-card-section>
<span class="text-h4">Wellcome to Nostr Market!</span><br />
In Nostr Market, merchant and customer communicate via NOSTR relays, so
@ -57,62 +96,31 @@
</div>
</q-card-section>
</q-card>
<div v-else>
<q-card>
<q-card-section>
<div class="row">
<div class="col-8"></div>
<div class="col-2">
<shipping-zones
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
class="float-right"
></shipping-zones>
</div>
<div class="col-2">
<q-btn
@click="showKeys = !showKeys"
icon="vpn_key"
color="primary"
class="float-right"
>
<q-tooltip> Show Public and Private keys </q-tooltip>
</q-btn>
</div>
</div>
</q-card-section>
<q-card-section v-if="showKeys">
<key-pair
:public-key="merchant.public_key"
:private-key="merchant.private_key"
></key-pair>
</q-card-section>
</q-card>
<q-card class="q-mt-lg">
<q-card-section>
<stall-list
:adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey"
:wallet-options="g.user.walletOptions"
></stall-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Nostr Market Extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "nostrmarket/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
<div class="col-12">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Nostr Market Extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "nostrmarket/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<div v-if="merchant && merchant.id" class="col-12">
<direct-messages
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
>
</direct-messages>
</div>
</div>
<div>
<q-dialog v-model="importKeyDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
@ -141,6 +149,23 @@
</div>
</div>
{% endblock%}{% block scripts %} {{ window_vars(user) }}
<style scoped>
.chat-container {
position: relative;
display: grid;
grid-template-rows: 1fr auto;
height: 50vh;
}
.chat-box {
padding: 1rem;
overflow-y: auto;
margin-left: auto;
width: 100%;
}
</style>
<!-- todo: serve locally -->
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
@ -151,6 +176,8 @@
<script src="{{ url_for('nostrmarket_static', path='components/stall-details/stall-details.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/stall-list/stall-list.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/order-list/order-list.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/direct-messages/direct-messages.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/merchant-details/merchant-details.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
{% endblock %}

View file

@ -6,7 +6,6 @@ from fastapi import Depends
from fastapi.exceptions import HTTPException
from loguru import logger
from lnbits.core import create_invoice
from lnbits.decorators import (
WalletTypeInfo,
check_admin,
@ -18,25 +17,29 @@ from lnbits.utils.exchange_rates import currencies
from . import nostrmarket_ext, scheduled_tasks
from .crud import (
create_direct_message,
create_merchant,
create_order,
create_product,
create_stall,
create_zone,
delete_merchant_direct_messages,
delete_merchant_orders,
delete_merchant_products,
delete_merchant_stalls,
delete_merchant_zones,
delete_merchants,
delete_product,
delete_stall,
delete_zone,
get_direct_messages,
get_merchant_for_user,
get_order,
get_order_by_event_id,
get_orders,
get_orders_for_stall,
get_product,
get_products,
get_products_by_ids,
get_stall,
get_stalls,
get_wallet_for_product,
get_zone,
get_zones,
update_order_shipped_status,
@ -45,24 +48,21 @@ from .crud import (
update_zone,
)
from .models import (
DirectMessage,
Merchant,
Nostrable,
Order,
OrderExtra,
OrderStatusUpdate,
PartialDirectMessage,
PartialMerchant,
PartialOrder,
PartialProduct,
PartialStall,
PartialZone,
PaymentOption,
PaymentRequest,
Product,
Stall,
Zone,
)
from .nostr.event import NostrEvent
from .nostr.nostr_client import publish_nostr_event
from .services import sign_and_send_to_nostr
######################################## MERCHANT ########################################
@ -75,6 +75,7 @@ async def api_create_merchant(
try:
merchant = await create_merchant(wallet.wallet.user, data)
return merchant
except Exception as ex:
logger.warning(ex)
@ -100,13 +101,40 @@ async def api_get_merchant(
)
@nostrmarket_ext.delete("/api/v1/merchant/{merchant_id}")
async def api_delete_merchant(
merchant_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
assert merchant.id == merchant_id, "Wrong merchant ID"
await delete_merchant_orders(merchant.id)
await delete_merchant_products(merchant.id)
await delete_merchant_stalls(merchant.id)
await delete_merchant_direct_messages(merchant.id)
await delete_merchant_zones(merchant.id)
await delete_merchants(merchant.id)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get merchant",
)
######################################## ZONES ########################################
@nostrmarket_ext.get("/api/v1/zone")
async def api_get_zones(wallet: WalletTypeInfo = Depends(get_key_type)) -> List[Zone]:
try:
return await get_zones(wallet.wallet.user)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
return await get_zones(merchant.id)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
@ -120,7 +148,9 @@ async def api_create_zone(
data: PartialZone, wallet: WalletTypeInfo = Depends(require_admin_key)
):
try:
zone = await create_zone(wallet.wallet.user, data)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
zone = await create_zone(merchant.id, data)
return zone
except Exception as ex:
logger.warning(ex)
@ -137,7 +167,9 @@ async def api_update_zone(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Zone:
try:
zone = await get_zone(wallet.wallet.user, zone_id)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
zone = await get_zone(merchant.id, zone_id)
if not zone:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
@ -159,7 +191,9 @@ async def api_update_zone(
@nostrmarket_ext.delete("/api/v1/zone/{zone_id}")
async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
try:
zone = await get_zone(wallet.wallet.user, zone_id)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
zone = await get_zone(merchant.id, zone_id)
if not zone:
raise HTTPException(
@ -167,7 +201,7 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
detail="Zone does not exist.",
)
await delete_zone(zone_id)
await delete_zone(wallet.wallet.user, zone_id)
except Exception as ex:
logger.warning(ex)
@ -188,12 +222,14 @@ async def api_create_stall(
try:
data.validate_stall()
stall = await create_stall(wallet.wallet.user, data=data)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await create_stall(merchant.id, data=data)
event = await sign_and_send_to_nostr(wallet.wallet.user, stall)
event = await sign_and_send_to_nostr(merchant, stall)
stall.config.event_id = event.id
await update_stall(wallet.wallet.user, stall)
await update_stall(merchant.id, stall)
return stall
except ValueError as ex:
@ -217,13 +253,16 @@ async def api_update_stall(
try:
data.validate_stall()
stall = await update_stall(wallet.wallet.user, data)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await update_stall(merchant.id, data)
assert stall, "Cannot update stall"
event = await sign_and_send_to_nostr(wallet.wallet.user, stall)
event = await sign_and_send_to_nostr(merchant, stall)
stall.config.event_id = event.id
await update_stall(wallet.wallet.user, stall)
await update_stall(merchant.id, stall)
return stall
except HTTPException as ex:
@ -244,7 +283,9 @@ async def api_update_stall(
@nostrmarket_ext.get("/api/v1/stall/{stall_id}")
async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
try:
stall = await get_stall(wallet.wallet.user, stall_id)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await get_stall(merchant.id, stall_id)
if not stall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
@ -264,7 +305,9 @@ 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)):
try:
stalls = await get_stalls(wallet.wallet.user)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stalls = await get_stalls(merchant.id)
return stalls
except Exception as ex:
logger.warning(ex)
@ -280,7 +323,9 @@ async def api_get_stall_products(
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
products = await get_products(wallet.wallet.user, stall_id)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
products = await get_products(merchant.id, stall_id)
return products
except Exception as ex:
logger.warning(ex)
@ -296,7 +341,9 @@ async def api_get_stall_orders(
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
orders = await get_orders_for_stall(wallet.wallet.user, stall_id)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
orders = await get_orders_for_stall(merchant.id, stall_id)
return orders
except Exception as ex:
logger.warning(ex)
@ -311,19 +358,21 @@ async def api_delete_stall(
stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
try:
stall = await get_stall(wallet.wallet.user, stall_id)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await get_stall(merchant.id, stall_id)
if not stall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Stall does not exist.",
)
await delete_stall(wallet.wallet.user, stall_id)
await delete_stall(merchant.id, stall_id)
event = await sign_and_send_to_nostr(wallet.wallet.user, stall, True)
event = await sign_and_send_to_nostr(merchant, stall, True)
stall.config.event_id = event.id
await update_stall(wallet.wallet.user, stall)
await update_stall(merchant.id, stall)
except HTTPException as ex:
raise ex
@ -345,17 +394,19 @@ async def api_create_product(
) -> Product:
try:
data.validate_product()
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await get_stall(wallet.wallet.user, data.stall_id)
stall = await get_stall(merchant.id, data.stall_id)
assert stall, "Stall missing for product"
data.config.currency = stall.currency
product = await create_product(wallet.wallet.user, data=data)
product = await create_product(merchant.id, data=data)
event = await sign_and_send_to_nostr(wallet.wallet.user, product)
event = await sign_and_send_to_nostr(merchant, product)
product.config.event_id = event.id
await update_product(wallet.wallet.user, product)
await update_product(merchant.id, product)
return product
except ValueError as ex:
@ -382,17 +433,19 @@ async def api_update_product(
raise ValueError("Bad product ID")
product.validate_product()
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await get_stall(wallet.wallet.user, product.stall_id)
stall = await get_stall(merchant.id, product.stall_id)
assert stall, "Stall missing for product"
product.config.currency = stall.currency
product = await update_product(wallet.wallet.user, product)
product = await update_product(merchant.id, product)
event = await sign_and_send_to_nostr(wallet.wallet.user, product)
event = await sign_and_send_to_nostr(merchant, product)
product.config.event_id = event.id
await update_product(wallet.wallet.user, product)
await update_product(merchant.id, product)
return product
except ValueError as ex:
@ -414,7 +467,10 @@ async def api_get_product(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Optional[Product]:
try:
products = await get_product(wallet.wallet.user, product_id)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
products = await get_product(merchant.id, product_id)
return products
except Exception as ex:
logger.warning(ex)
@ -430,15 +486,18 @@ async def api_delete_product(
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
product = await get_product(wallet.wallet.user, product_id)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
product = await get_product(merchant.id, product_id)
if not product:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Product does not exist.",
)
await delete_product(wallet.wallet.user, product_id)
await sign_and_send_to_nostr(wallet.wallet.user, product, True)
await delete_product(merchant.id, product_id)
await sign_and_send_to_nostr(merchant, product, True)
except HTTPException as ex:
raise ex
@ -453,69 +512,15 @@ async def api_delete_product(
######################################## ORDERS ########################################
@nostrmarket_ext.post("/api/v1/order")
async def api_create_order(
data: PartialOrder, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Optional[PaymentRequest]:
try:
# print("### new order: ", json.dumps(data.dict()))
if await get_order(wallet.wallet.user, data.id):
return None
if data.event_id and await get_order_by_event_id(
wallet.wallet.user, data.event_id
):
return None
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Cannot find merchant!"
products = await get_products_by_ids(
wallet.wallet.user, [p.product_id for p in data.items]
)
data.validate_order_items(products)
total_amount = await data.total_sats(products)
wallet_id = await get_wallet_for_product(data.items[0].product_id)
assert wallet_id, "Missing wallet for order `{data.id}`"
payment_hash, invoice = await create_invoice(
wallet_id=wallet_id,
amount=round(total_amount),
memo=f"Order '{data.id}' for pubkey '{data.pubkey}'",
extra={
"tag": "nostrmarket",
"order_id": data.id,
"merchant_pubkey": merchant.public_key,
},
)
order = Order(
**data.dict(),
stall_id=products[0].stall_id,
invoice_id=payment_hash,
total=total_amount,
extra=await OrderExtra.from_products(products),
)
await create_order(wallet.wallet.user, order)
return PaymentRequest(
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)]
)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create order",
)
nostrmarket_ext.get("/api/v1/order/{order_id}")
async def api_get_order(order_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
try:
order = await get_order(wallet.wallet.user, order_id)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
order = await get_order(merchant.id, order_id)
if not order:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
@ -535,7 +540,10 @@ async def api_get_order(order_id: str, wallet: WalletTypeInfo = Depends(get_key_
@nostrmarket_ext.get("/api/v1/order")
async def api_get_orders(wallet: WalletTypeInfo = Depends(get_key_type)):
try:
orders = await get_orders(wallet.wallet.user)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
orders = await get_orders(merchant.id)
return orders
except Exception as ex:
logger.warning(ex)
@ -552,18 +560,19 @@ async def api_update_order_status(
) -> Order:
try:
assert data.shipped != None, "Shipped value is required for order"
order = await update_order_shipped_status(
wallet.wallet.user, data.id, data.shipped
)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
order = await update_order_shipped_status(merchant.id, data.id, data.shipped)
assert order, "Cannot find updated order"
merchant = await get_merchant_for_user(wallet.wallet.user)
merchant = await get_merchant_for_user(merchant.id)
assert merchant, f"Merchant cannot be found for order {data.id}"
data.paid = order.paid
dm_content = json.dumps(data.dict(), separators=(",", ":"), ensure_ascii=False)
dm_event = merchant.build_dm_event(dm_content, order.pubkey)
dm_event = merchant.build_dm_event(dm_content, order.public_key)
await publish_nostr_event(dm_event)
return order
@ -576,6 +585,51 @@ async def api_update_order_status(
)
######################################## DIRECT MESSAGES ########################################
@nostrmarket_ext.get("/api/v1/message/{public_key}")
async def api_get_messages(
public_key: str, wallet: WalletTypeInfo = Depends(get_key_type)
) -> List[DirectMessage]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, f"Merchant cannot be found"
messages = await get_direct_messages(merchant.id, public_key)
return messages
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get direct message",
)
@nostrmarket_ext.post("/api/v1/message")
async def api_create_message(
data: PartialDirectMessage, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> DirectMessage:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, f"Merchant cannot be found"
dm_event = merchant.build_dm_event(data.message, data.public_key)
data.event_id = dm_event.id
data.event_created_at = dm_event.created_at
dm = await create_direct_message(merchant.id, data)
await publish_nostr_event(dm_event)
return dm
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create message",
)
######################################## OTHER ########################################
@ -593,22 +647,3 @@ async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)):
logger.warning(ex)
return {"success": True}
######################################## HELPERS ########################################
async def sign_and_send_to_nostr(
user_id: str, n: Nostrable, delete=False
) -> NostrEvent:
merchant = await get_merchant_for_user(user_id)
assert merchant, "Cannot find merchant!"
event = (
n.to_nostr_delete_event(merchant.public_key)
if delete
else n.to_nostr_event(merchant.public_key)
)
event.sig = merchant.sign_hash(bytes.fromhex(event.id))
await publish_nostr_event(event)
return event