diff --git a/crud.py b/crud.py index 996bfc3..f4d85c5 100644 --- a/crud.py +++ b/crud.py @@ -197,7 +197,7 @@ async def create_product(user_id: str, data: PartialProduct) -> Product: await db.execute( f""" - INSERT INTO nostrmarket.products (user_id, id, stall_id, name, category_list, description, images, price, quantity) + INSERT INTO nostrmarket.products (user_id, id, stall_id, name, images, price, quantity, category_list, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( @@ -205,11 +205,11 @@ async def create_product(user_id: str, data: PartialProduct) -> Product: product_id, data.stall_id, data.name, - json.dumps(data.categories), - data.description, data.image, data.price, data.quantity, + json.dumps(data.categories), + json.dumps(data.config.dict()), ), ) product = await get_product(user_id, product_id) @@ -222,18 +222,18 @@ async def update_product(user_id: str, product: Product) -> Product: await db.execute( f""" - UPDATE nostrmarket.products set name = ?, category_list = ?, description = ?, images = ?, price = ?, quantity = ? + UPDATE nostrmarket.products set name = ?, description = ?, images = ?, price = ?, quantity = ?, category_list = ?, meta = ? WHERE user_id = ? AND id = ? """, ( product.name, - json.dumps(product.categories), - product.description, product.image, product.price, product.quantity, user_id, product.id, + json.dumps(product.categories), + json.dumps(product.config), ), ) updated_product = await get_product(user_id, product.id) diff --git a/migrations.py b/migrations.py index 2e0a158..696e584 100644 --- a/migrations.py +++ b/migrations.py @@ -37,17 +37,17 @@ async def m001_initial(db): Initial products table. """ await db.execute( - f""" + """ CREATE TABLE nostrmarket.products ( user_id TEXT NOT NULL, id TEXT PRIMARY KEY, stall_id TEXT NOT NULL, name TEXT NOT NULL, - category_list TEXT DEFAULT '[]', - description TEXT, images TEXT DEFAULT '[]', price REAL NOT NULL, - quantity INTEGER NOT NULL + quantity INTEGER NOT NULL, + category_list TEXT DEFAULT '[]', + meta TEXT NOT NULL DEFAULT '{}' ); """ ) diff --git a/models.py b/models.py index 412bbfc..0becfe3 100644 --- a/models.py +++ b/models.py @@ -1,5 +1,6 @@ import json import time +from abc import abstractmethod from sqlite3 import Row from typing import List, Optional @@ -8,6 +9,18 @@ from pydantic import BaseModel from .helpers import sign_message_hash from .nostr.event import NostrEvent +######################################## NOSTR ######################################## + + +class Nostrable: + @abstractmethod + def to_nostr_event(self, pubkey: str) -> NostrEvent: + pass + + @abstractmethod + def to_nostr_delete_event(self, pubkey: str) -> NostrEvent: + pass + ######################################## MERCHANT ######################################## class MerchantConfig(BaseModel): @@ -90,7 +103,7 @@ class Stall(PartialStall): event = NostrEvent( pubkey=pubkey, created_at=round(time.time()), - kind=30005, + kind=30017, tags=[["d", self.id]], content=json.dumps(content, separators=(",", ":"), ensure_ascii=False), ) @@ -121,14 +134,19 @@ class Stall(PartialStall): ######################################## STALLS ######################################## +class ProductConfig(BaseModel): + event_id: Optional[str] + description: Optional[str] + + class PartialProduct(BaseModel): stall_id: str name: str categories: List[str] = [] - description: Optional[str] image: Optional[str] price: float quantity: int + config: ProductConfig = ProductConfig() def validate_product(self): if self.image: @@ -150,14 +168,14 @@ class PartialProduct(BaseModel): ) -class Product(PartialProduct): +class Product(PartialProduct, Nostrable): id: str def to_nostr_event(self, pubkey: str) -> NostrEvent: content = { "stall_id": self.stall_id, "name": self.name, - "description": self.description, + "description": self.config.description, "image": self.image, "price": self.price, "quantity": self.quantity, @@ -167,7 +185,7 @@ class Product(PartialProduct): event = NostrEvent( pubkey=pubkey, created_at=round(time.time()), - kind=30005, + kind=30018, tags=[["d", self.id]] + categories, content=json.dumps(content, separators=(",", ":"), ensure_ascii=False), ) @@ -175,8 +193,21 @@ class Product(PartialProduct): return event + def to_nostr_delete_event(self, pubkey: str) -> NostrEvent: + delete_event = NostrEvent( + pubkey=pubkey, + created_at=round(time.time()), + kind=5, + tags=[["e", self.config.event_id]], + content=f"Product '{self.name}' deleted", + ) + delete_event.id = delete_event.event_id + + return delete_event + @classmethod def from_row(cls, row: Row) -> "Product": product = cls(**dict(row)) + product.config = ProductConfig(**json.loads(row["meta"])) product.categories = json.loads(row["category_list"]) return product diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index 82610ca..9246aad 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -161,7 +161,7 @@ - {{props.row.description}} + {{props.row.config.description}} diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index e62caaf..b8ef9c8 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -190,7 +190,7 @@ async function stallDetails(path) { try { const {data} = await LNbits.api.request( 'GET', - '/nostrmarket/api/v1/product/' + this.stall.id, + '/nostrmarket/api/v1/stall/product/' + this.stall.id, this.inkey ) this.products = data @@ -205,12 +205,14 @@ async function stallDetails(path) { stall_id: this.stall.id, id: this.productDialog.data.id, name: this.productDialog.data.name, - description: this.productDialog.data.description, - categories: this.productDialog.data.categories, image: this.productDialog.data.image, price: this.productDialog.data.price, - quantity: this.productDialog.data.quantity + quantity: this.productDialog.data.quantity, + categories: this.productDialog.data.categories, + config: { + description: this.productDialog.data.description + } } this.productDialog.showDialog = false if (this.productDialog.data.id) { diff --git a/views_api.py b/views_api.py index 3de3f97..63965d9 100644 --- a/views_api.py +++ b/views_api.py @@ -12,6 +12,7 @@ from lnbits.decorators import ( require_admin_key, require_invoice_key, ) +from lnbits.extensions.nostrmarket.nostr.event import NostrEvent from lnbits.utils.exchange_rates import currencies from . import nostrmarket_ext @@ -24,6 +25,7 @@ from .crud import ( delete_stall, delete_zone, get_merchant_for_user, + get_product, get_products, get_stall, get_stalls, @@ -35,6 +37,7 @@ from .crud import ( ) from .models import ( Merchant, + Nostrable, PartialMerchant, PartialProduct, PartialStall, @@ -266,6 +269,22 @@ 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, + wallet: WalletTypeInfo = Depends(require_invoice_key), +): + try: + products = await get_products(wallet.wallet.user, stall_id) + return products + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot get stall products", + ) + + @nostrmarket_ext.delete("/api/v1/stall/{stall_id}") async def api_delete_stall( stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) @@ -310,6 +329,11 @@ async def api_create_product( data.validate_product() product = await create_product(wallet.wallet.user, data=data) + event = await sign_and_send_to_nostr(wallet.wallet.user, product) + + product.config.event_id = event.id + await update_product(wallet.wallet.user, product) + return product except ValueError as ex: raise HTTPException( @@ -334,6 +358,11 @@ async def api_update_product( product.validate_product() product = await update_product(wallet.wallet.user, product) + event = await sign_and_send_to_nostr(wallet.wallet.user, product) + + product.config.event_id = event.id + await update_product(wallet.wallet.user, product) + return product except ValueError as ex: raise HTTPException( @@ -348,13 +377,13 @@ async def api_update_product( ) -@nostrmarket_ext.get("/api/v1/product/{stall_id}") +@nostrmarket_ext.get("/api/v1/product/{product_id}") async def api_get_product( - stall_id: str, + product_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key), -): +) -> Optional[Product]: try: - products = await get_products(wallet.wallet.user, stall_id) + products = await get_product(wallet.wallet.user, product_id) return products except Exception as ex: logger.warning(ex) @@ -370,7 +399,18 @@ async def api_delete_product( wallet: WalletTypeInfo = Depends(require_admin_key), ): try: + product = await get_product(wallet.wallet.user, 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) + + except HTTPException as ex: + raise ex except Exception as ex: logger.warning(ex) raise HTTPException( @@ -385,3 +425,23 @@ async def api_delete_product( @nostrmarket_ext.get("/api/v1/currencies") async def api_list_currencies_available(): return list(currencies.keys()) + + +######################################## 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