Compare commits
4 commits
22e1a348f2
...
2b14b06fd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b14b06fd8 | ||
|
|
e0bf3ba8ff | ||
|
|
0eede24cad | ||
|
|
3a654857ff |
12 changed files with 3548 additions and 0 deletions
390
src/extensions/marketplace/index.ts
Normal file
390
src/extensions/marketplace/index.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
import {
|
||||||
|
Extension, ExtensionContext, ExtensionDatabase, ExtensionInfo,
|
||||||
|
CreateStallRequest, UpdateStallRequest,
|
||||||
|
CreateProductRequest, UpdateProductRequest,
|
||||||
|
CreateOrderRequest
|
||||||
|
} from './types.js'
|
||||||
|
import { migrations } from './migrations.js'
|
||||||
|
import { StallManager } from './managers/stallManager.js'
|
||||||
|
import { ProductManager } from './managers/productManager.js'
|
||||||
|
import { OrderManager } from './managers/orderManager.js'
|
||||||
|
import { MessageManager } from './managers/messageManager.js'
|
||||||
|
import { parseOrderRequest, parseMessageType, NostrEvent } from './nostr/parser.js'
|
||||||
|
import { EVENT_KINDS } from './nostr/kinds.js'
|
||||||
|
import { validatePubkey } from './utils/validation.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketplace Extension for Lightning.Pub
|
||||||
|
*
|
||||||
|
* Implements NIP-15 compatible marketplace functionality:
|
||||||
|
* - Stall management (kind 30017 events)
|
||||||
|
* - Product listings (kind 30018 events)
|
||||||
|
* - Order processing via encrypted DMs
|
||||||
|
* - Customer relationship management
|
||||||
|
*/
|
||||||
|
export default class MarketplaceExtension implements Extension {
|
||||||
|
readonly info: ExtensionInfo = {
|
||||||
|
id: 'marketplace',
|
||||||
|
name: 'Nostr Marketplace',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'NIP-15 compatible marketplace for selling products via Lightning',
|
||||||
|
author: 'Lightning.Pub',
|
||||||
|
minPubVersion: '1.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
private stallManager!: StallManager
|
||||||
|
private productManager!: ProductManager
|
||||||
|
private orderManager!: OrderManager
|
||||||
|
private messageManager!: MessageManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the extension
|
||||||
|
*/
|
||||||
|
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
|
||||||
|
// Run migrations
|
||||||
|
for (const migration of migrations) {
|
||||||
|
await migration.up(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize managers
|
||||||
|
this.stallManager = new StallManager(db, ctx)
|
||||||
|
this.productManager = new ProductManager(db, ctx)
|
||||||
|
this.orderManager = new OrderManager(db, ctx)
|
||||||
|
this.messageManager = new MessageManager(db, ctx)
|
||||||
|
|
||||||
|
// Register RPC methods
|
||||||
|
this.registerRpcMethods(ctx)
|
||||||
|
|
||||||
|
// Subscribe to payment callbacks
|
||||||
|
ctx.onPaymentReceived(async (payment) => {
|
||||||
|
if (payment.metadata?.extension === 'marketplace' && payment.metadata?.order_id) {
|
||||||
|
await this.orderManager.handlePayment(payment.invoiceId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe to incoming Nostr events
|
||||||
|
ctx.onNostrEvent(async (event, applicationId) => {
|
||||||
|
await this.handleNostrEvent(event, applicationId)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[Marketplace] Extension initialized`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup on shutdown
|
||||||
|
*/
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
console.log(`[Marketplace] Extension shutting down`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all RPC methods with the extension context
|
||||||
|
*/
|
||||||
|
private registerRpcMethods(ctx: ExtensionContext): void {
|
||||||
|
// ===== Stall Methods =====
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.createStall', async (req, appId) => {
|
||||||
|
const stall = await this.stallManager.create(appId, req as CreateStallRequest)
|
||||||
|
return { stall }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getStall', async (req, appId) => {
|
||||||
|
const stall = await this.stallManager.get(req.id, appId)
|
||||||
|
if (!stall) throw new Error('Stall not found')
|
||||||
|
return { stall }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.listStalls', async (_req, appId) => {
|
||||||
|
const stalls = await this.stallManager.list(appId)
|
||||||
|
return { stalls }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.updateStall', async (req, appId) => {
|
||||||
|
const stall = await this.stallManager.update(req.id, appId, req as UpdateStallRequest)
|
||||||
|
if (!stall) throw new Error('Stall not found')
|
||||||
|
return { stall }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.deleteStall', async (req, appId) => {
|
||||||
|
const success = await this.stallManager.delete(req.id, appId)
|
||||||
|
if (!success) throw new Error('Stall not found')
|
||||||
|
return { success }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.publishStall', async (req, appId) => {
|
||||||
|
const stall = await this.stallManager.get(req.id, appId)
|
||||||
|
if (!stall) throw new Error('Stall not found')
|
||||||
|
const eventId = await this.stallManager.publishToNostr(stall)
|
||||||
|
return { event_id: eventId }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Product Methods =====
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.createProduct', async (req, appId) => {
|
||||||
|
const product = await this.productManager.create(
|
||||||
|
appId,
|
||||||
|
req.stall_id,
|
||||||
|
req as CreateProductRequest
|
||||||
|
)
|
||||||
|
return { product }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getProduct', async (req, appId) => {
|
||||||
|
const product = await this.productManager.get(req.id, appId)
|
||||||
|
if (!product) throw new Error('Product not found')
|
||||||
|
return { product }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.listProducts', async (req, appId) => {
|
||||||
|
const products = await this.productManager.list(appId, {
|
||||||
|
stall_id: req.stall_id,
|
||||||
|
category: req.category,
|
||||||
|
active_only: req.active_only,
|
||||||
|
limit: req.limit,
|
||||||
|
offset: req.offset
|
||||||
|
})
|
||||||
|
return { products }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.updateProduct', async (req, appId) => {
|
||||||
|
const product = await this.productManager.update(req.id, appId, req as UpdateProductRequest)
|
||||||
|
if (!product) throw new Error('Product not found')
|
||||||
|
return { product }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.deleteProduct', async (req, appId) => {
|
||||||
|
const success = await this.productManager.delete(req.id, appId)
|
||||||
|
if (!success) throw new Error('Product not found')
|
||||||
|
return { success }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.updateInventory', async (req, appId) => {
|
||||||
|
const newQuantity = await this.productManager.updateQuantity(req.id, appId, req.delta)
|
||||||
|
return { quantity: newQuantity }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.checkStock', async (req, appId) => {
|
||||||
|
const result = await this.productManager.checkStock(req.items, appId)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Order Methods =====
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.createOrder', async (req, appId, userPubkey) => {
|
||||||
|
if (!userPubkey) throw new Error('User pubkey required')
|
||||||
|
const order = await this.orderManager.createFromRpc(
|
||||||
|
appId,
|
||||||
|
userPubkey,
|
||||||
|
req as CreateOrderRequest,
|
||||||
|
req.app_user_id
|
||||||
|
)
|
||||||
|
return { order }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getOrder', async (req, appId) => {
|
||||||
|
const order = await this.orderManager.get(req.id, appId)
|
||||||
|
if (!order) throw new Error('Order not found')
|
||||||
|
return { order }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.listOrders', async (req, appId) => {
|
||||||
|
const orders = await this.orderManager.list(appId, {
|
||||||
|
stall_id: req.stall_id,
|
||||||
|
customer_pubkey: req.customer_pubkey,
|
||||||
|
status: req.status,
|
||||||
|
limit: req.limit,
|
||||||
|
offset: req.offset
|
||||||
|
})
|
||||||
|
return { orders }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.createInvoice', async (req, appId) => {
|
||||||
|
const order = await this.orderManager.createInvoice(req.order_id, appId)
|
||||||
|
return { order }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.updateOrderStatus', async (req, appId) => {
|
||||||
|
const order = await this.orderManager.updateStatus(
|
||||||
|
req.id,
|
||||||
|
appId,
|
||||||
|
req.status,
|
||||||
|
req.message
|
||||||
|
)
|
||||||
|
if (!order) throw new Error('Order not found')
|
||||||
|
return { order }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.sendPaymentRequest', async (req, appId) => {
|
||||||
|
const order = await this.orderManager.get(req.order_id, appId)
|
||||||
|
if (!order) throw new Error('Order not found')
|
||||||
|
|
||||||
|
// Create invoice if not exists
|
||||||
|
if (!order.invoice) {
|
||||||
|
await this.orderManager.createInvoice(req.order_id, appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedOrder = await this.orderManager.get(req.order_id, appId)
|
||||||
|
if (!updatedOrder?.invoice) throw new Error('Failed to create invoice')
|
||||||
|
|
||||||
|
await this.orderManager.sendPaymentRequestDM(updatedOrder)
|
||||||
|
return { order: updatedOrder }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Message/Customer Methods =====
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.listMessages', async (req, appId) => {
|
||||||
|
const messages = await this.messageManager.listMessages(appId, {
|
||||||
|
customer_pubkey: req.customer_pubkey,
|
||||||
|
order_id: req.order_id,
|
||||||
|
incoming_only: req.incoming_only,
|
||||||
|
unread_only: req.unread_only,
|
||||||
|
limit: req.limit,
|
||||||
|
offset: req.offset
|
||||||
|
})
|
||||||
|
return { messages }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getConversation', async (req, appId) => {
|
||||||
|
if (!validatePubkey(req.customer_pubkey)) {
|
||||||
|
throw new Error('Invalid customer pubkey')
|
||||||
|
}
|
||||||
|
const messages = await this.messageManager.getConversation(
|
||||||
|
appId,
|
||||||
|
req.customer_pubkey,
|
||||||
|
req.limit
|
||||||
|
)
|
||||||
|
return { messages }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.sendMessage', async (req, appId) => {
|
||||||
|
if (!validatePubkey(req.customer_pubkey)) {
|
||||||
|
throw new Error('Invalid customer pubkey')
|
||||||
|
}
|
||||||
|
const message = await this.messageManager.sendMessage(
|
||||||
|
appId,
|
||||||
|
req.customer_pubkey,
|
||||||
|
req.message,
|
||||||
|
req.order_id
|
||||||
|
)
|
||||||
|
return { message }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.markMessagesRead', async (req, appId) => {
|
||||||
|
if (!validatePubkey(req.customer_pubkey)) {
|
||||||
|
throw new Error('Invalid customer pubkey')
|
||||||
|
}
|
||||||
|
const count = await this.messageManager.markAsRead(appId, req.customer_pubkey)
|
||||||
|
return { marked_read: count }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getUnreadCount', async (_req, appId) => {
|
||||||
|
const count = await this.messageManager.getUnreadCount(appId)
|
||||||
|
return { unread_count: count }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.listCustomers', async (req, appId) => {
|
||||||
|
const customers = await this.messageManager.listCustomers(appId, {
|
||||||
|
has_orders: req.has_orders,
|
||||||
|
has_unread: req.has_unread,
|
||||||
|
limit: req.limit,
|
||||||
|
offset: req.offset
|
||||||
|
})
|
||||||
|
return { customers }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getCustomer', async (req, appId) => {
|
||||||
|
if (!validatePubkey(req.pubkey)) {
|
||||||
|
throw new Error('Invalid customer pubkey')
|
||||||
|
}
|
||||||
|
const customer = await this.messageManager.getCustomer(req.pubkey, appId)
|
||||||
|
if (!customer) throw new Error('Customer not found')
|
||||||
|
return { customer }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getCustomerStats', async (_req, appId) => {
|
||||||
|
const stats = await this.messageManager.getCustomerStats(appId)
|
||||||
|
return stats
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Bulk Operations =====
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.republishAll', async (req, appId) => {
|
||||||
|
let stallCount = 0
|
||||||
|
let productCount = 0
|
||||||
|
|
||||||
|
if (req.stall_id) {
|
||||||
|
// Republish specific stall and its products
|
||||||
|
const stall = await this.stallManager.get(req.stall_id, appId)
|
||||||
|
if (stall) {
|
||||||
|
await this.stallManager.publishToNostr(stall)
|
||||||
|
stallCount = 1
|
||||||
|
productCount = await this.productManager.republishAllForStall(req.stall_id, appId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Republish all
|
||||||
|
stallCount = await this.stallManager.republishAll(appId)
|
||||||
|
const stalls = await this.stallManager.list(appId)
|
||||||
|
for (const stall of stalls) {
|
||||||
|
productCount += await this.productManager.republishAllForStall(stall.id, appId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stalls_published: stallCount,
|
||||||
|
products_published: productCount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming Nostr events (DMs for orders)
|
||||||
|
*/
|
||||||
|
private async handleNostrEvent(event: NostrEvent, applicationId: string): Promise<void> {
|
||||||
|
// Only handle DMs for now
|
||||||
|
if (event.kind !== EVENT_KINDS.DIRECT_MESSAGE) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decrypt the message content (context handles this)
|
||||||
|
const decrypted = event.content // Assume pre-decrypted by context
|
||||||
|
|
||||||
|
// Store the message
|
||||||
|
await this.messageManager.storeIncoming(
|
||||||
|
applicationId,
|
||||||
|
event.pubkey,
|
||||||
|
decrypted,
|
||||||
|
event.id,
|
||||||
|
event.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse message type
|
||||||
|
const { type, parsed } = parseMessageType(decrypted)
|
||||||
|
|
||||||
|
// Handle order requests
|
||||||
|
if (type === 'order_request') {
|
||||||
|
const orderReq = parseOrderRequest(decrypted)
|
||||||
|
if (orderReq) {
|
||||||
|
const order = await this.orderManager.createFromNostr(
|
||||||
|
applicationId,
|
||||||
|
event.pubkey,
|
||||||
|
orderReq,
|
||||||
|
event.id
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create invoice and send payment request
|
||||||
|
const withInvoice = await this.orderManager.createInvoice(order.id, applicationId)
|
||||||
|
await this.orderManager.sendPaymentRequestDM(withInvoice)
|
||||||
|
|
||||||
|
console.log(`[Marketplace] Created order ${order.id} from Nostr DM`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Marketplace] Error handling Nostr event:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types for external use
|
||||||
|
export * from './types.js'
|
||||||
|
export { EVENT_KINDS, MESSAGE_TYPES } from './nostr/kinds.js'
|
||||||
497
src/extensions/marketplace/managers/messageManager.ts
Normal file
497
src/extensions/marketplace/managers/messageManager.ts
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
import {
|
||||||
|
ExtensionContext, ExtensionDatabase,
|
||||||
|
DirectMessage, Customer, MessageType
|
||||||
|
} from '../types.js'
|
||||||
|
import { generateId } from '../utils/validation.js'
|
||||||
|
import { parseMessageType } from '../nostr/parser.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row for direct message
|
||||||
|
*/
|
||||||
|
interface MessageRow {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
order_id: string | null
|
||||||
|
customer_pubkey: string
|
||||||
|
message_type: string
|
||||||
|
content: string
|
||||||
|
incoming: number
|
||||||
|
nostr_event_id: string
|
||||||
|
nostr_event_created_at: number
|
||||||
|
created_at: number
|
||||||
|
read: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row for customer
|
||||||
|
*/
|
||||||
|
interface CustomerRow {
|
||||||
|
pubkey: string
|
||||||
|
application_id: string
|
||||||
|
name: string | null
|
||||||
|
about: string | null
|
||||||
|
picture: string | null
|
||||||
|
total_orders: number
|
||||||
|
total_spent_sats: number
|
||||||
|
unread_messages: number
|
||||||
|
first_seen_at: number
|
||||||
|
last_seen_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert database row to DirectMessage object
|
||||||
|
*/
|
||||||
|
function rowToMessage(row: MessageRow): DirectMessage {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
order_id: row.order_id || undefined,
|
||||||
|
customer_pubkey: row.customer_pubkey,
|
||||||
|
message_type: row.message_type as MessageType,
|
||||||
|
content: row.content,
|
||||||
|
incoming: row.incoming === 1,
|
||||||
|
nostr_event_id: row.nostr_event_id,
|
||||||
|
nostr_event_created_at: row.nostr_event_created_at,
|
||||||
|
created_at: row.created_at,
|
||||||
|
read: row.read === 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert database row to Customer object
|
||||||
|
*/
|
||||||
|
function rowToCustomer(row: CustomerRow): Customer {
|
||||||
|
return {
|
||||||
|
pubkey: row.pubkey,
|
||||||
|
application_id: row.application_id,
|
||||||
|
name: row.name || undefined,
|
||||||
|
about: row.about || undefined,
|
||||||
|
picture: row.picture || undefined,
|
||||||
|
total_orders: row.total_orders,
|
||||||
|
total_spent_sats: row.total_spent_sats,
|
||||||
|
unread_messages: row.unread_messages,
|
||||||
|
first_seen_at: row.first_seen_at,
|
||||||
|
last_seen_at: row.last_seen_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query options for listing messages
|
||||||
|
*/
|
||||||
|
interface ListMessagesOptions {
|
||||||
|
customer_pubkey?: string
|
||||||
|
order_id?: string
|
||||||
|
incoming_only?: boolean
|
||||||
|
unread_only?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query options for listing customers
|
||||||
|
*/
|
||||||
|
interface ListCustomersOptions {
|
||||||
|
has_orders?: boolean
|
||||||
|
has_unread?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MessageManager - Handles customer DMs and customer management
|
||||||
|
*/
|
||||||
|
export class MessageManager {
|
||||||
|
constructor(
|
||||||
|
private db: ExtensionDatabase,
|
||||||
|
private ctx: ExtensionContext
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ===== Message Methods =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store incoming message from Nostr
|
||||||
|
*/
|
||||||
|
async storeIncoming(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
decryptedContent: string,
|
||||||
|
nostrEventId: string,
|
||||||
|
nostrEventCreatedAt: number,
|
||||||
|
orderId?: string
|
||||||
|
): Promise<DirectMessage> {
|
||||||
|
// Parse message type
|
||||||
|
const { type } = parseMessageType(decryptedContent)
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
const message: DirectMessage = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
order_id: orderId,
|
||||||
|
customer_pubkey: customerPubkey,
|
||||||
|
message_type: type,
|
||||||
|
content: decryptedContent,
|
||||||
|
incoming: true,
|
||||||
|
nostr_event_id: nostrEventId,
|
||||||
|
nostr_event_created_at: nostrEventCreatedAt,
|
||||||
|
created_at: now,
|
||||||
|
read: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate event
|
||||||
|
const existing = await this.db.query(
|
||||||
|
'SELECT id FROM direct_messages WHERE nostr_event_id = ?',
|
||||||
|
[nostrEventId]
|
||||||
|
)
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return message // Already stored, return without error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert message
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO direct_messages (
|
||||||
|
id, application_id, order_id, customer_pubkey, message_type,
|
||||||
|
content, incoming, nostr_event_id, nostr_event_created_at,
|
||||||
|
created_at, read
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
message.id,
|
||||||
|
message.application_id,
|
||||||
|
message.order_id || null,
|
||||||
|
message.customer_pubkey,
|
||||||
|
message.message_type,
|
||||||
|
message.content,
|
||||||
|
1, // incoming = true
|
||||||
|
message.nostr_event_id,
|
||||||
|
message.nostr_event_created_at,
|
||||||
|
message.created_at,
|
||||||
|
0 // read = false
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update customer unread count
|
||||||
|
await this.incrementUnread(applicationId, customerPubkey)
|
||||||
|
|
||||||
|
// Ensure customer exists
|
||||||
|
await this.ensureCustomer(applicationId, customerPubkey)
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store outgoing message (sent by merchant)
|
||||||
|
*/
|
||||||
|
async storeOutgoing(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
content: string,
|
||||||
|
nostrEventId: string,
|
||||||
|
orderId?: string
|
||||||
|
): Promise<DirectMessage> {
|
||||||
|
const { type } = parseMessageType(content)
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
const message: DirectMessage = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
order_id: orderId,
|
||||||
|
customer_pubkey: customerPubkey,
|
||||||
|
message_type: type,
|
||||||
|
content,
|
||||||
|
incoming: false,
|
||||||
|
nostr_event_id: nostrEventId,
|
||||||
|
nostr_event_created_at: now,
|
||||||
|
created_at: now,
|
||||||
|
read: true // Outgoing messages are always "read"
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO direct_messages (
|
||||||
|
id, application_id, order_id, customer_pubkey, message_type,
|
||||||
|
content, incoming, nostr_event_id, nostr_event_created_at,
|
||||||
|
created_at, read
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
message.id,
|
||||||
|
message.application_id,
|
||||||
|
message.order_id || null,
|
||||||
|
message.customer_pubkey,
|
||||||
|
message.message_type,
|
||||||
|
message.content,
|
||||||
|
0, // incoming = false
|
||||||
|
message.nostr_event_id,
|
||||||
|
message.nostr_event_created_at,
|
||||||
|
message.created_at,
|
||||||
|
1 // read = true
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get message by ID
|
||||||
|
*/
|
||||||
|
async getMessage(id: string, applicationId: string): Promise<DirectMessage | null> {
|
||||||
|
const rows = await this.db.query<MessageRow>(
|
||||||
|
'SELECT * FROM direct_messages WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToMessage(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List messages with filters
|
||||||
|
*/
|
||||||
|
async listMessages(
|
||||||
|
applicationId: string,
|
||||||
|
options: ListMessagesOptions = {}
|
||||||
|
): Promise<DirectMessage[]> {
|
||||||
|
let sql = 'SELECT * FROM direct_messages WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (options.customer_pubkey) {
|
||||||
|
sql += ' AND customer_pubkey = ?'
|
||||||
|
params.push(options.customer_pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.order_id) {
|
||||||
|
sql += ' AND order_id = ?'
|
||||||
|
params.push(options.order_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.incoming_only) {
|
||||||
|
sql += ' AND incoming = 1'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.unread_only) {
|
||||||
|
sql += ' AND read = 0'
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY nostr_event_created_at DESC'
|
||||||
|
|
||||||
|
if (options.limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(options.limit)
|
||||||
|
if (options.offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(options.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<MessageRow>(sql, params)
|
||||||
|
return rows.map(rowToMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get conversation thread with a customer
|
||||||
|
*/
|
||||||
|
async getConversation(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
limit: number = 50
|
||||||
|
): Promise<DirectMessage[]> {
|
||||||
|
const rows = await this.db.query<MessageRow>(
|
||||||
|
`SELECT * FROM direct_messages
|
||||||
|
WHERE application_id = ? AND customer_pubkey = ?
|
||||||
|
ORDER BY nostr_event_created_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[applicationId, customerPubkey, limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Return in chronological order
|
||||||
|
return rows.map(rowToMessage).reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark messages as read
|
||||||
|
*/
|
||||||
|
async markAsRead(applicationId: string, customerPubkey: string): Promise<number> {
|
||||||
|
const result = await this.db.execute(
|
||||||
|
`UPDATE direct_messages SET read = 1
|
||||||
|
WHERE application_id = ? AND customer_pubkey = ? AND read = 0`,
|
||||||
|
[applicationId, customerPubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset customer unread count
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE customers SET unread_messages = 0
|
||||||
|
WHERE application_id = ? AND pubkey = ?`,
|
||||||
|
[applicationId, customerPubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.changes || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread message count
|
||||||
|
*/
|
||||||
|
async getUnreadCount(applicationId: string): Promise<number> {
|
||||||
|
const result = await this.db.query<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count FROM direct_messages
|
||||||
|
WHERE application_id = ? AND incoming = 1 AND read = 0`,
|
||||||
|
[applicationId]
|
||||||
|
)
|
||||||
|
return result[0]?.count || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a plain text message to customer
|
||||||
|
*/
|
||||||
|
async sendMessage(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
message: string,
|
||||||
|
orderId?: string
|
||||||
|
): Promise<DirectMessage> {
|
||||||
|
// Send via Nostr
|
||||||
|
const eventId = await this.ctx.sendEncryptedDM(
|
||||||
|
applicationId,
|
||||||
|
customerPubkey,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store outgoing message
|
||||||
|
return this.storeOutgoing(
|
||||||
|
applicationId,
|
||||||
|
customerPubkey,
|
||||||
|
message,
|
||||||
|
eventId,
|
||||||
|
orderId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Customer Methods =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customer by pubkey
|
||||||
|
*/
|
||||||
|
async getCustomer(pubkey: string, applicationId: string): Promise<Customer | null> {
|
||||||
|
const rows = await this.db.query<CustomerRow>(
|
||||||
|
'SELECT * FROM customers WHERE pubkey = ? AND application_id = ?',
|
||||||
|
[pubkey, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToCustomer(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List customers with filters
|
||||||
|
*/
|
||||||
|
async listCustomers(
|
||||||
|
applicationId: string,
|
||||||
|
options: ListCustomersOptions = {}
|
||||||
|
): Promise<Customer[]> {
|
||||||
|
let sql = 'SELECT * FROM customers WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (options.has_orders) {
|
||||||
|
sql += ' AND total_orders > 0'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.has_unread) {
|
||||||
|
sql += ' AND unread_messages > 0'
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY last_seen_at DESC'
|
||||||
|
|
||||||
|
if (options.limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(options.limit)
|
||||||
|
if (options.offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(options.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<CustomerRow>(sql, params)
|
||||||
|
return rows.map(rowToCustomer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update customer profile from Nostr metadata
|
||||||
|
*/
|
||||||
|
async updateCustomerProfile(
|
||||||
|
pubkey: string,
|
||||||
|
applicationId: string,
|
||||||
|
profile: { name?: string; about?: string; picture?: string }
|
||||||
|
): Promise<Customer | null> {
|
||||||
|
await this.ensureCustomer(applicationId, pubkey)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE customers SET
|
||||||
|
name = COALESCE(?, name),
|
||||||
|
about = COALESCE(?, about),
|
||||||
|
picture = COALESCE(?, picture),
|
||||||
|
last_seen_at = ?
|
||||||
|
WHERE pubkey = ? AND application_id = ?`,
|
||||||
|
[
|
||||||
|
profile.name || null,
|
||||||
|
profile.about || null,
|
||||||
|
profile.picture || null,
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
pubkey,
|
||||||
|
applicationId
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.getCustomer(pubkey, applicationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customer statistics summary
|
||||||
|
*/
|
||||||
|
async getCustomerStats(applicationId: string): Promise<{
|
||||||
|
total_customers: number
|
||||||
|
customers_with_orders: number
|
||||||
|
total_unread: number
|
||||||
|
}> {
|
||||||
|
const total = await this.db.query<{ count: number }>(
|
||||||
|
'SELECT COUNT(*) as count FROM customers WHERE application_id = ?',
|
||||||
|
[applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const withOrders = await this.db.query<{ count: number }>(
|
||||||
|
'SELECT COUNT(*) as count FROM customers WHERE application_id = ? AND total_orders > 0',
|
||||||
|
[applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const unread = await this.db.query<{ sum: number }>(
|
||||||
|
'SELECT SUM(unread_messages) as sum FROM customers WHERE application_id = ?',
|
||||||
|
[applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_customers: total[0]?.count || 0,
|
||||||
|
customers_with_orders: withOrders[0]?.count || 0,
|
||||||
|
total_unread: unread[0]?.sum || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Private Helpers =====
|
||||||
|
|
||||||
|
private async ensureCustomer(applicationId: string, pubkey: string): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO customers (pubkey, application_id, first_seen_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(pubkey, application_id) DO UPDATE SET
|
||||||
|
last_seen_at = ?`,
|
||||||
|
[pubkey, applicationId, now, now, now]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async incrementUnread(applicationId: string, pubkey: string): Promise<void> {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE customers SET unread_messages = unread_messages + 1
|
||||||
|
WHERE pubkey = ? AND application_id = ?`,
|
||||||
|
[pubkey, applicationId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
607
src/extensions/marketplace/managers/orderManager.ts
Normal file
607
src/extensions/marketplace/managers/orderManager.ts
Normal file
|
|
@ -0,0 +1,607 @@
|
||||||
|
import {
|
||||||
|
ExtensionContext, ExtensionDatabase,
|
||||||
|
Order, Stall, Product, NIP15OrderRequest,
|
||||||
|
CreateOrderRequest, OrderItem, OrderStatus
|
||||||
|
} from '../types.js'
|
||||||
|
import { generateId, validateOrderItems } from '../utils/validation.js'
|
||||||
|
import { convertToSats, SupportedCurrency } from '../utils/currency.js'
|
||||||
|
import { buildPaymentRequestContent, buildOrderStatusContent } from '../nostr/events.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row for order
|
||||||
|
*/
|
||||||
|
interface OrderRow {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
stall_id: string
|
||||||
|
customer_pubkey: string
|
||||||
|
customer_app_user_id: string | null
|
||||||
|
items: string // JSON
|
||||||
|
shipping_zone_id: string
|
||||||
|
shipping_address: string
|
||||||
|
contact: string // JSON
|
||||||
|
subtotal_sats: number
|
||||||
|
shipping_sats: number
|
||||||
|
total_sats: number
|
||||||
|
original_currency: string
|
||||||
|
exchange_rate: number | null
|
||||||
|
invoice: string | null
|
||||||
|
invoice_id: string | null
|
||||||
|
status: string
|
||||||
|
nostr_event_id: string | null
|
||||||
|
created_at: number
|
||||||
|
paid_at: number | null
|
||||||
|
shipped_at: number | null
|
||||||
|
completed_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert database row to Order object
|
||||||
|
*/
|
||||||
|
function rowToOrder(row: OrderRow): Order {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
stall_id: row.stall_id,
|
||||||
|
customer_pubkey: row.customer_pubkey,
|
||||||
|
customer_app_user_id: row.customer_app_user_id || undefined,
|
||||||
|
items: JSON.parse(row.items),
|
||||||
|
shipping_zone_id: row.shipping_zone_id,
|
||||||
|
shipping_address: row.shipping_address,
|
||||||
|
contact: JSON.parse(row.contact),
|
||||||
|
subtotal_sats: row.subtotal_sats,
|
||||||
|
shipping_sats: row.shipping_sats,
|
||||||
|
total_sats: row.total_sats,
|
||||||
|
original_currency: row.original_currency as SupportedCurrency,
|
||||||
|
exchange_rate: row.exchange_rate || undefined,
|
||||||
|
invoice: row.invoice || undefined,
|
||||||
|
invoice_id: row.invoice_id || undefined,
|
||||||
|
status: row.status as OrderStatus,
|
||||||
|
nostr_event_id: row.nostr_event_id || undefined,
|
||||||
|
created_at: row.created_at,
|
||||||
|
paid_at: row.paid_at || undefined,
|
||||||
|
shipped_at: row.shipped_at || undefined,
|
||||||
|
completed_at: row.completed_at || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query options for listing orders
|
||||||
|
*/
|
||||||
|
interface ListOrdersOptions {
|
||||||
|
stall_id?: string
|
||||||
|
customer_pubkey?: string
|
||||||
|
status?: OrderStatus
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OrderManager - Handles order lifecycle and payment integration
|
||||||
|
*/
|
||||||
|
export class OrderManager {
|
||||||
|
constructor(
|
||||||
|
private db: ExtensionDatabase,
|
||||||
|
private ctx: ExtensionContext
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create order from NIP-15 order request (via Nostr DM)
|
||||||
|
*/
|
||||||
|
async createFromNostr(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
orderReq: NIP15OrderRequest,
|
||||||
|
nostrEventId?: string
|
||||||
|
): Promise<Order> {
|
||||||
|
// Validate items
|
||||||
|
validateOrderItems(orderReq.items)
|
||||||
|
|
||||||
|
// Find stall from first product
|
||||||
|
const firstProduct = await this.getProduct(orderReq.items[0].product_id, applicationId)
|
||||||
|
if (!firstProduct) {
|
||||||
|
throw new Error('Product not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stall = await this.getStall(firstProduct.stall_id, applicationId)
|
||||||
|
if (!stall) {
|
||||||
|
throw new Error('Stall not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const itemsWithDetails = await this.enrichOrderItems(orderReq.items, applicationId)
|
||||||
|
const { subtotal, shippingCost, total, exchangeRate } = await this.calculateTotals(
|
||||||
|
itemsWithDetails,
|
||||||
|
stall,
|
||||||
|
orderReq.shipping_id
|
||||||
|
)
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = orderReq.id || generateId()
|
||||||
|
|
||||||
|
const order: Order = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
stall_id: stall.id,
|
||||||
|
customer_pubkey: customerPubkey,
|
||||||
|
items: itemsWithDetails,
|
||||||
|
shipping_zone_id: orderReq.shipping_id,
|
||||||
|
shipping_address: orderReq.address || '',
|
||||||
|
contact: orderReq.contact || {},
|
||||||
|
subtotal_sats: subtotal,
|
||||||
|
shipping_sats: shippingCost,
|
||||||
|
total_sats: total,
|
||||||
|
original_currency: stall.currency,
|
||||||
|
exchange_rate: exchangeRate,
|
||||||
|
status: 'pending',
|
||||||
|
nostr_event_id: nostrEventId,
|
||||||
|
created_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
await this.insertOrder(order)
|
||||||
|
|
||||||
|
// Reserve inventory
|
||||||
|
await this.reserveInventory(order)
|
||||||
|
|
||||||
|
// Update customer stats
|
||||||
|
await this.updateCustomerStats(applicationId, customerPubkey)
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create order from RPC request (native client)
|
||||||
|
*/
|
||||||
|
async createFromRpc(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
req: CreateOrderRequest,
|
||||||
|
appUserId?: string
|
||||||
|
): Promise<Order> {
|
||||||
|
// Validate items
|
||||||
|
validateOrderItems(req.items)
|
||||||
|
|
||||||
|
// Find stall
|
||||||
|
const stall = await this.getStall(req.stall_id, applicationId)
|
||||||
|
if (!stall) {
|
||||||
|
throw new Error('Stall not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const itemsWithDetails = await this.enrichOrderItems(req.items, applicationId)
|
||||||
|
const { subtotal, shippingCost, total, exchangeRate } = await this.calculateTotals(
|
||||||
|
itemsWithDetails,
|
||||||
|
stall,
|
||||||
|
req.shipping_zone_id
|
||||||
|
)
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
const order: Order = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
stall_id: stall.id,
|
||||||
|
customer_pubkey: customerPubkey,
|
||||||
|
customer_app_user_id: appUserId,
|
||||||
|
items: itemsWithDetails,
|
||||||
|
shipping_zone_id: req.shipping_zone_id,
|
||||||
|
shipping_address: req.shipping_address || '',
|
||||||
|
contact: req.contact || {},
|
||||||
|
subtotal_sats: subtotal,
|
||||||
|
shipping_sats: shippingCost,
|
||||||
|
total_sats: total,
|
||||||
|
original_currency: stall.currency,
|
||||||
|
exchange_rate: exchangeRate,
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
await this.insertOrder(order)
|
||||||
|
|
||||||
|
// Reserve inventory
|
||||||
|
await this.reserveInventory(order)
|
||||||
|
|
||||||
|
// Update customer stats
|
||||||
|
await this.updateCustomerStats(applicationId, customerPubkey)
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order by ID
|
||||||
|
*/
|
||||||
|
async get(id: string, applicationId: string): Promise<Order | null> {
|
||||||
|
const rows = await this.db.query<OrderRow>(
|
||||||
|
'SELECT * FROM orders WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToOrder(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List orders with filters
|
||||||
|
*/
|
||||||
|
async list(applicationId: string, options: ListOrdersOptions = {}): Promise<Order[]> {
|
||||||
|
let sql = 'SELECT * FROM orders WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (options.stall_id) {
|
||||||
|
sql += ' AND stall_id = ?'
|
||||||
|
params.push(options.stall_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.customer_pubkey) {
|
||||||
|
sql += ' AND customer_pubkey = ?'
|
||||||
|
params.push(options.customer_pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.status) {
|
||||||
|
sql += ' AND status = ?'
|
||||||
|
params.push(options.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC'
|
||||||
|
|
||||||
|
if (options.limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(options.limit)
|
||||||
|
if (options.offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(options.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<OrderRow>(sql, params)
|
||||||
|
return rows.map(rowToOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create invoice for an order
|
||||||
|
*/
|
||||||
|
async createInvoice(id: string, applicationId: string): Promise<Order> {
|
||||||
|
const order = await this.get(id, applicationId)
|
||||||
|
if (!order) {
|
||||||
|
throw new Error('Order not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status !== 'pending') {
|
||||||
|
throw new Error(`Order already ${order.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create invoice via Lightning.Pub
|
||||||
|
const invoice = await this.ctx.createInvoice(order.total_sats, {
|
||||||
|
memo: `Order ${order.id}`,
|
||||||
|
expiry: 3600, // 1 hour
|
||||||
|
metadata: {
|
||||||
|
extension: 'marketplace',
|
||||||
|
order_id: order.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update order with invoice
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE orders SET invoice = ?, invoice_id = ? WHERE id = ?`,
|
||||||
|
[invoice.paymentRequest, invoice.id, order.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
order.invoice = invoice.paymentRequest
|
||||||
|
order.invoice_id = invoice.id
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle invoice payment callback
|
||||||
|
*/
|
||||||
|
async handlePayment(invoiceId: string): Promise<Order | null> {
|
||||||
|
// Find order by invoice ID
|
||||||
|
const rows = await this.db.query<OrderRow>(
|
||||||
|
'SELECT * FROM orders WHERE invoice_id = ? AND status = ?',
|
||||||
|
[invoiceId, 'pending']
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
|
||||||
|
const order = rowToOrder(rows[0])
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE orders SET status = ?, paid_at = ? WHERE id = ?`,
|
||||||
|
['paid', now, order.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
order.status = 'paid'
|
||||||
|
order.paid_at = now
|
||||||
|
|
||||||
|
// Update customer stats
|
||||||
|
await this.updateCustomerSpent(order.application_id, order.customer_pubkey, order.total_sats)
|
||||||
|
|
||||||
|
// Send payment confirmation DM
|
||||||
|
await this.sendOrderStatusDM(order, 'Payment received! Your order is being processed.')
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update order status
|
||||||
|
*/
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
applicationId: string,
|
||||||
|
status: OrderStatus,
|
||||||
|
message?: string
|
||||||
|
): Promise<Order | null> {
|
||||||
|
const order = await this.get(id, applicationId)
|
||||||
|
if (!order) return null
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const updates: string[] = ['status = ?']
|
||||||
|
const params: any[] = [status]
|
||||||
|
|
||||||
|
// Set timestamp based on status
|
||||||
|
if (status === 'paid' && !order.paid_at) {
|
||||||
|
updates.push('paid_at = ?')
|
||||||
|
params.push(now)
|
||||||
|
} else if (status === 'shipped' && !order.shipped_at) {
|
||||||
|
updates.push('shipped_at = ?')
|
||||||
|
params.push(now)
|
||||||
|
} else if (status === 'completed' && !order.completed_at) {
|
||||||
|
updates.push('completed_at = ?')
|
||||||
|
params.push(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id, applicationId)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE orders SET ${updates.join(', ')} WHERE id = ? AND application_id = ?`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
const updated = await this.get(id, applicationId)
|
||||||
|
|
||||||
|
// Send status update DM
|
||||||
|
if (updated && message) {
|
||||||
|
await this.sendOrderStatusDM(updated, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancellation - restore inventory
|
||||||
|
if (status === 'cancelled' && order.status === 'pending') {
|
||||||
|
await this.restoreInventory(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send payment request DM to customer
|
||||||
|
*/
|
||||||
|
async sendPaymentRequestDM(order: Order): Promise<void> {
|
||||||
|
if (!order.invoice) {
|
||||||
|
throw new Error('Order has no invoice')
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = buildPaymentRequestContent(order, order.invoice)
|
||||||
|
|
||||||
|
await this.ctx.sendEncryptedDM(
|
||||||
|
order.application_id,
|
||||||
|
order.customer_pubkey,
|
||||||
|
JSON.stringify(content)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send order status update DM to customer
|
||||||
|
*/
|
||||||
|
async sendOrderStatusDM(order: Order, message: string): Promise<void> {
|
||||||
|
const content = buildOrderStatusContent(order, message)
|
||||||
|
|
||||||
|
await this.ctx.sendEncryptedDM(
|
||||||
|
order.application_id,
|
||||||
|
order.customer_pubkey,
|
||||||
|
JSON.stringify(content)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Private Helpers =====
|
||||||
|
|
||||||
|
private async insertOrder(order: Order): Promise<void> {
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO orders (
|
||||||
|
id, application_id, stall_id, customer_pubkey, customer_app_user_id,
|
||||||
|
items, shipping_zone_id, shipping_address, contact,
|
||||||
|
subtotal_sats, shipping_sats, total_sats, original_currency, exchange_rate,
|
||||||
|
invoice, invoice_id, status, nostr_event_id, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
order.id,
|
||||||
|
order.application_id,
|
||||||
|
order.stall_id,
|
||||||
|
order.customer_pubkey,
|
||||||
|
order.customer_app_user_id || null,
|
||||||
|
JSON.stringify(order.items),
|
||||||
|
order.shipping_zone_id,
|
||||||
|
order.shipping_address,
|
||||||
|
JSON.stringify(order.contact),
|
||||||
|
order.subtotal_sats,
|
||||||
|
order.shipping_sats,
|
||||||
|
order.total_sats,
|
||||||
|
order.original_currency,
|
||||||
|
order.exchange_rate || null,
|
||||||
|
order.invoice || null,
|
||||||
|
order.invoice_id || null,
|
||||||
|
order.status,
|
||||||
|
order.nostr_event_id || null,
|
||||||
|
order.created_at
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStall(id: string, applicationId: string): Promise<Stall | null> {
|
||||||
|
const rows = await this.db.query(
|
||||||
|
'SELECT * FROM stalls WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
|
||||||
|
const row = rows[0] as any
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
shipping_zones: JSON.parse(row.shipping_zones)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProduct(id: string, applicationId: string): Promise<Product | null> {
|
||||||
|
const rows = await this.db.query(
|
||||||
|
'SELECT * FROM products WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
|
||||||
|
const row = rows[0] as any
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
images: JSON.parse(row.images),
|
||||||
|
categories: JSON.parse(row.categories),
|
||||||
|
specs: row.specs ? JSON.parse(row.specs) : undefined,
|
||||||
|
active: row.active === 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichOrderItems(
|
||||||
|
items: Array<{ product_id: string; quantity: number }>,
|
||||||
|
applicationId: string
|
||||||
|
): Promise<OrderItem[]> {
|
||||||
|
const enriched: OrderItem[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const product = await this.getProduct(item.product_id, applicationId)
|
||||||
|
if (!product) {
|
||||||
|
throw new Error(`Product ${item.product_id} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product.active) {
|
||||||
|
throw new Error(`Product ${product.name} is not available`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check stock
|
||||||
|
if (product.quantity !== -1 && product.quantity < item.quantity) {
|
||||||
|
throw new Error(`Insufficient stock for ${product.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched.push({
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: product.price
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
}
|
||||||
|
|
||||||
|
private async calculateTotals(
|
||||||
|
items: OrderItem[],
|
||||||
|
stall: Stall,
|
||||||
|
shippingZoneId: string
|
||||||
|
): Promise<{
|
||||||
|
subtotal: number
|
||||||
|
shippingCost: number
|
||||||
|
total: number
|
||||||
|
exchangeRate?: number
|
||||||
|
}> {
|
||||||
|
// Calculate subtotal in original currency
|
||||||
|
const subtotalOriginal = items.reduce(
|
||||||
|
(sum, item) => sum + item.unit_price * item.quantity,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Find shipping zone
|
||||||
|
const shippingZone = stall.shipping_zones.find(z => z.id === shippingZoneId)
|
||||||
|
if (!shippingZone) {
|
||||||
|
throw new Error('Invalid shipping zone')
|
||||||
|
}
|
||||||
|
|
||||||
|
const shippingOriginal = shippingZone.cost
|
||||||
|
|
||||||
|
// Convert to sats if needed
|
||||||
|
let subtotal: number
|
||||||
|
let shippingCost: number
|
||||||
|
let exchangeRate: number | undefined
|
||||||
|
|
||||||
|
if (stall.currency === 'sat') {
|
||||||
|
subtotal = subtotalOriginal
|
||||||
|
shippingCost = shippingOriginal
|
||||||
|
} else {
|
||||||
|
subtotal = await convertToSats(this.db, subtotalOriginal, stall.currency)
|
||||||
|
shippingCost = await convertToSats(this.db, shippingOriginal, stall.currency)
|
||||||
|
exchangeRate = subtotal / subtotalOriginal
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtotal,
|
||||||
|
shippingCost,
|
||||||
|
total: subtotal + shippingCost,
|
||||||
|
exchangeRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reserveInventory(order: Order): Promise<void> {
|
||||||
|
for (const item of order.items) {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE products SET
|
||||||
|
quantity = CASE
|
||||||
|
WHEN quantity = -1 THEN -1
|
||||||
|
ELSE quantity - ?
|
||||||
|
END
|
||||||
|
WHERE id = ? AND quantity != -1`,
|
||||||
|
[item.quantity, item.product_id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreInventory(order: Order): Promise<void> {
|
||||||
|
for (const item of order.items) {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE products SET
|
||||||
|
quantity = CASE
|
||||||
|
WHEN quantity = -1 THEN -1
|
||||||
|
ELSE quantity + ?
|
||||||
|
END
|
||||||
|
WHERE id = ? AND quantity != -1`,
|
||||||
|
[item.quantity, item.product_id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateCustomerStats(applicationId: string, pubkey: string): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO customers (pubkey, application_id, total_orders, first_seen_at, last_seen_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?)
|
||||||
|
ON CONFLICT(pubkey, application_id) DO UPDATE SET
|
||||||
|
total_orders = total_orders + 1,
|
||||||
|
last_seen_at = ?`,
|
||||||
|
[pubkey, applicationId, now, now, now]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateCustomerSpent(
|
||||||
|
applicationId: string,
|
||||||
|
pubkey: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<void> {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE customers SET
|
||||||
|
total_spent_sats = total_spent_sats + ?
|
||||||
|
WHERE pubkey = ? AND application_id = ?`,
|
||||||
|
[amount, pubkey, applicationId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
466
src/extensions/marketplace/managers/productManager.ts
Normal file
466
src/extensions/marketplace/managers/productManager.ts
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
import {
|
||||||
|
ExtensionContext, ExtensionDatabase,
|
||||||
|
Product, Stall, CreateProductRequest, UpdateProductRequest
|
||||||
|
} from '../types.js'
|
||||||
|
import { generateId, validateProduct } from '../utils/validation.js'
|
||||||
|
import { buildProductEvent, buildDeleteEvent } from '../nostr/events.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row for product
|
||||||
|
*/
|
||||||
|
interface ProductRow {
|
||||||
|
id: string
|
||||||
|
stall_id: string
|
||||||
|
application_id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
images: string // JSON array
|
||||||
|
price: number
|
||||||
|
quantity: number
|
||||||
|
categories: string // JSON array
|
||||||
|
shipping_cost: number | null
|
||||||
|
specs: string | null // JSON array
|
||||||
|
active: number
|
||||||
|
nostr_event_id: string | null
|
||||||
|
nostr_event_created_at: number | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert database row to Product object
|
||||||
|
*/
|
||||||
|
function rowToProduct(row: ProductRow): Product {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
stall_id: row.stall_id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
images: JSON.parse(row.images),
|
||||||
|
price: row.price,
|
||||||
|
quantity: row.quantity,
|
||||||
|
categories: JSON.parse(row.categories),
|
||||||
|
shipping_cost: row.shipping_cost ?? undefined,
|
||||||
|
specs: row.specs ? JSON.parse(row.specs) : undefined,
|
||||||
|
active: row.active === 1,
|
||||||
|
nostr_event_id: row.nostr_event_id || undefined,
|
||||||
|
nostr_event_created_at: row.nostr_event_created_at || undefined,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query options for listing products
|
||||||
|
*/
|
||||||
|
interface ListProductsOptions {
|
||||||
|
stall_id?: string
|
||||||
|
category?: string
|
||||||
|
active_only?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProductManager - Handles product CRUD, inventory, and Nostr publishing
|
||||||
|
*/
|
||||||
|
export class ProductManager {
|
||||||
|
constructor(
|
||||||
|
private db: ExtensionDatabase,
|
||||||
|
private ctx: ExtensionContext
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new product
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
applicationId: string,
|
||||||
|
stallId: string,
|
||||||
|
req: CreateProductRequest
|
||||||
|
): Promise<Product> {
|
||||||
|
// Validate request
|
||||||
|
validateProduct(req)
|
||||||
|
|
||||||
|
// Verify stall exists and belongs to application
|
||||||
|
const stallRows = await this.db.query(
|
||||||
|
'SELECT * FROM stalls WHERE id = ? AND application_id = ?',
|
||||||
|
[stallId, applicationId]
|
||||||
|
)
|
||||||
|
if (stallRows.length === 0) {
|
||||||
|
throw new Error('Stall not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
const product: Product = {
|
||||||
|
id,
|
||||||
|
stall_id: stallId,
|
||||||
|
application_id: applicationId,
|
||||||
|
name: req.name.trim(),
|
||||||
|
description: req.description?.trim() || '',
|
||||||
|
images: req.images || [],
|
||||||
|
price: req.price,
|
||||||
|
quantity: req.quantity ?? -1, // -1 = unlimited
|
||||||
|
categories: req.categories || [],
|
||||||
|
shipping_cost: req.shipping_cost,
|
||||||
|
specs: req.specs,
|
||||||
|
active: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO products (
|
||||||
|
id, stall_id, application_id, name, description, images,
|
||||||
|
price, quantity, categories, shipping_cost, specs, active,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
product.id,
|
||||||
|
product.stall_id,
|
||||||
|
product.application_id,
|
||||||
|
product.name,
|
||||||
|
product.description,
|
||||||
|
JSON.stringify(product.images),
|
||||||
|
product.price,
|
||||||
|
product.quantity,
|
||||||
|
JSON.stringify(product.categories),
|
||||||
|
product.shipping_cost ?? null,
|
||||||
|
product.specs ? JSON.stringify(product.specs) : null,
|
||||||
|
product.active ? 1 : 0,
|
||||||
|
product.created_at,
|
||||||
|
product.updated_at
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publish to Nostr if auto-publish enabled
|
||||||
|
if (req.publish_to_nostr !== false) {
|
||||||
|
await this.publishToNostr(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product by ID
|
||||||
|
*/
|
||||||
|
async get(id: string, applicationId: string): Promise<Product | null> {
|
||||||
|
const rows = await this.db.query<ProductRow>(
|
||||||
|
'SELECT * FROM products WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToProduct(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List products with optional filters
|
||||||
|
*/
|
||||||
|
async list(applicationId: string, options: ListProductsOptions = {}): Promise<Product[]> {
|
||||||
|
let sql = 'SELECT * FROM products WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (options.stall_id) {
|
||||||
|
sql += ' AND stall_id = ?'
|
||||||
|
params.push(options.stall_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.active_only !== false) {
|
||||||
|
sql += ' AND active = 1'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.category) {
|
||||||
|
// Search in JSON array
|
||||||
|
sql += ' AND categories LIKE ?'
|
||||||
|
params.push(`%"${options.category}"%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC'
|
||||||
|
|
||||||
|
if (options.limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(options.limit)
|
||||||
|
if (options.offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(options.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<ProductRow>(sql, params)
|
||||||
|
return rows.map(rowToProduct)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a product
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
applicationId: string,
|
||||||
|
req: UpdateProductRequest
|
||||||
|
): Promise<Product | null> {
|
||||||
|
const existing = await this.get(id, applicationId)
|
||||||
|
if (!existing) return null
|
||||||
|
|
||||||
|
// Build updated product
|
||||||
|
const updated: Product = {
|
||||||
|
...existing,
|
||||||
|
name: req.name?.trim() ?? existing.name,
|
||||||
|
description: req.description?.trim() ?? existing.description,
|
||||||
|
images: req.images ?? existing.images,
|
||||||
|
price: req.price ?? existing.price,
|
||||||
|
quantity: req.quantity ?? existing.quantity,
|
||||||
|
categories: req.categories ?? existing.categories,
|
||||||
|
shipping_cost: req.shipping_cost ?? existing.shipping_cost,
|
||||||
|
specs: req.specs ?? existing.specs,
|
||||||
|
active: req.active ?? existing.active,
|
||||||
|
updated_at: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate merged product
|
||||||
|
validateProduct({
|
||||||
|
name: updated.name,
|
||||||
|
price: updated.price,
|
||||||
|
quantity: updated.quantity,
|
||||||
|
images: updated.images,
|
||||||
|
categories: updated.categories
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE products SET
|
||||||
|
name = ?, description = ?, images = ?, price = ?,
|
||||||
|
quantity = ?, categories = ?, shipping_cost = ?,
|
||||||
|
specs = ?, active = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND application_id = ?`,
|
||||||
|
[
|
||||||
|
updated.name,
|
||||||
|
updated.description,
|
||||||
|
JSON.stringify(updated.images),
|
||||||
|
updated.price,
|
||||||
|
updated.quantity,
|
||||||
|
JSON.stringify(updated.categories),
|
||||||
|
updated.shipping_cost ?? null,
|
||||||
|
updated.specs ? JSON.stringify(updated.specs) : null,
|
||||||
|
updated.active ? 1 : 0,
|
||||||
|
updated.updated_at,
|
||||||
|
id,
|
||||||
|
applicationId
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Republish to Nostr
|
||||||
|
if (req.publish_to_nostr !== false && updated.active) {
|
||||||
|
await this.publishToNostr(updated)
|
||||||
|
} else if (!updated.active && existing.nostr_event_id) {
|
||||||
|
// Unpublish if deactivated
|
||||||
|
await this.unpublishFromNostr(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a product
|
||||||
|
*/
|
||||||
|
async delete(id: string, applicationId: string): Promise<boolean> {
|
||||||
|
const product = await this.get(id, applicationId)
|
||||||
|
if (!product) return false
|
||||||
|
|
||||||
|
// Check for pending orders
|
||||||
|
const orders = await this.db.query(
|
||||||
|
`SELECT COUNT(*) as count FROM orders
|
||||||
|
WHERE items LIKE ? AND status IN ('pending', 'paid')`,
|
||||||
|
[`%"${id}"%`]
|
||||||
|
)
|
||||||
|
if ((orders[0] as any).count > 0) {
|
||||||
|
throw new Error('Cannot delete product with pending orders')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpublish from Nostr
|
||||||
|
if (product.nostr_event_id) {
|
||||||
|
await this.unpublishFromNostr(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
await this.db.execute(
|
||||||
|
'DELETE FROM products WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update product quantity (for inventory management)
|
||||||
|
*/
|
||||||
|
async updateQuantity(
|
||||||
|
id: string,
|
||||||
|
applicationId: string,
|
||||||
|
delta: number
|
||||||
|
): Promise<number> {
|
||||||
|
const product = await this.get(id, applicationId)
|
||||||
|
if (!product) {
|
||||||
|
throw new Error('Product not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// -1 means unlimited
|
||||||
|
if (product.quantity === -1) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const newQuantity = Math.max(0, product.quantity + delta)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
'UPDATE products SET quantity = ?, updated_at = ? WHERE id = ?',
|
||||||
|
[newQuantity, Math.floor(Date.now() / 1000), id]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Republish if quantity changed and product is published
|
||||||
|
if (product.nostr_event_id) {
|
||||||
|
const updated = await this.get(id, applicationId)
|
||||||
|
if (updated) await this.publishToNostr(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newQuantity
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if products are in stock
|
||||||
|
*/
|
||||||
|
async checkStock(
|
||||||
|
items: Array<{ product_id: string; quantity: number }>,
|
||||||
|
applicationId: string
|
||||||
|
): Promise<{ available: boolean; unavailable: string[] }> {
|
||||||
|
const unavailable: string[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const product = await this.get(item.product_id, applicationId)
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
unavailable.push(item.product_id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product.active) {
|
||||||
|
unavailable.push(item.product_id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// -1 means unlimited
|
||||||
|
if (product.quantity !== -1 && product.quantity < item.quantity) {
|
||||||
|
unavailable.push(item.product_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: unavailable.length === 0,
|
||||||
|
unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish product to Nostr (kind 30018)
|
||||||
|
*/
|
||||||
|
async publishToNostr(product: Product): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// Get stall for currency info
|
||||||
|
const stallRows = await this.db.query(
|
||||||
|
'SELECT * FROM stalls WHERE id = ?',
|
||||||
|
[product.stall_id]
|
||||||
|
)
|
||||||
|
if (stallRows.length === 0) return null
|
||||||
|
|
||||||
|
const stall = stallRows[0] as any
|
||||||
|
const stallObj: Stall = {
|
||||||
|
...stall,
|
||||||
|
shipping_zones: JSON.parse(stall.shipping_zones)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get application's Nostr pubkey
|
||||||
|
const app = await this.ctx.getApplication(product.application_id)
|
||||||
|
if (!app || !app.nostr_public) {
|
||||||
|
console.warn(`No Nostr pubkey for application ${product.application_id}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the event
|
||||||
|
const event = buildProductEvent(product, stallObj, app.nostr_public)
|
||||||
|
|
||||||
|
// Sign and publish
|
||||||
|
const eventId = await this.ctx.publishNostrEvent(event)
|
||||||
|
|
||||||
|
// Update database with event info
|
||||||
|
if (eventId) {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE products SET
|
||||||
|
nostr_event_id = ?,
|
||||||
|
nostr_event_created_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[eventId, event.created_at, product.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventId
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to publish product to Nostr:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpublish product from Nostr (kind 5 deletion)
|
||||||
|
*/
|
||||||
|
async unpublishFromNostr(product: Product): Promise<boolean> {
|
||||||
|
if (!product.nostr_event_id) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await this.ctx.getApplication(product.application_id)
|
||||||
|
if (!app || !app.nostr_public) return false
|
||||||
|
|
||||||
|
const deleteEvent = buildDeleteEvent(
|
||||||
|
product.nostr_event_id,
|
||||||
|
30018, // PRODUCT kind
|
||||||
|
app.nostr_public,
|
||||||
|
`Product ${product.name} removed`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.ctx.publishNostrEvent(deleteEvent)
|
||||||
|
|
||||||
|
// Clear event info
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE products SET
|
||||||
|
nostr_event_id = NULL,
|
||||||
|
nostr_event_created_at = NULL
|
||||||
|
WHERE id = ?`,
|
||||||
|
[product.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to unpublish product from Nostr:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Republish all products for a stall
|
||||||
|
*/
|
||||||
|
async republishAllForStall(stallId: string, applicationId: string): Promise<number> {
|
||||||
|
const products = await this.list(applicationId, {
|
||||||
|
stall_id: stallId,
|
||||||
|
active_only: true
|
||||||
|
})
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
for (const product of products) {
|
||||||
|
const eventId = await this.publishToNostr(product)
|
||||||
|
if (eventId) count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/extensions/marketplace/managers/stallManager.ts
Normal file
305
src/extensions/marketplace/managers/stallManager.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
import {
|
||||||
|
ExtensionContext, ExtensionDatabase,
|
||||||
|
Stall, CreateStallRequest, UpdateStallRequest
|
||||||
|
} from '../types.js'
|
||||||
|
import { generateId, validateStall } from '../utils/validation.js'
|
||||||
|
import { buildStallEvent, buildDeleteEvent } from '../nostr/events.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row for stall
|
||||||
|
*/
|
||||||
|
interface StallRow {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
currency: string
|
||||||
|
shipping_zones: string // JSON
|
||||||
|
image_url: string | null
|
||||||
|
nostr_event_id: string | null
|
||||||
|
nostr_event_created_at: number | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert database row to Stall object
|
||||||
|
*/
|
||||||
|
function rowToStall(row: StallRow): Stall {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
currency: row.currency as Stall['currency'],
|
||||||
|
shipping_zones: JSON.parse(row.shipping_zones),
|
||||||
|
image_url: row.image_url || undefined,
|
||||||
|
nostr_event_id: row.nostr_event_id || undefined,
|
||||||
|
nostr_event_created_at: row.nostr_event_created_at || undefined,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StallManager - Handles stall CRUD and Nostr publishing
|
||||||
|
*/
|
||||||
|
export class StallManager {
|
||||||
|
constructor(
|
||||||
|
private db: ExtensionDatabase,
|
||||||
|
private ctx: ExtensionContext
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new stall
|
||||||
|
*/
|
||||||
|
async create(applicationId: string, req: CreateStallRequest): Promise<Stall> {
|
||||||
|
// Validate request
|
||||||
|
validateStall(req)
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
// Assign IDs to shipping zones if not provided
|
||||||
|
const shippingZones = req.shipping_zones.map(zone => ({
|
||||||
|
id: zone.id || generateId(),
|
||||||
|
name: zone.name,
|
||||||
|
cost: zone.cost,
|
||||||
|
regions: zone.regions
|
||||||
|
}))
|
||||||
|
|
||||||
|
const stall: Stall = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
name: req.name.trim(),
|
||||||
|
description: req.description?.trim() || '',
|
||||||
|
currency: req.currency,
|
||||||
|
shipping_zones: shippingZones,
|
||||||
|
image_url: req.image_url,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO stalls (
|
||||||
|
id, application_id, name, description, currency,
|
||||||
|
shipping_zones, image_url, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
stall.id,
|
||||||
|
stall.application_id,
|
||||||
|
stall.name,
|
||||||
|
stall.description,
|
||||||
|
stall.currency,
|
||||||
|
JSON.stringify(stall.shipping_zones),
|
||||||
|
stall.image_url || null,
|
||||||
|
stall.created_at,
|
||||||
|
stall.updated_at
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publish to Nostr if auto-publish enabled
|
||||||
|
if (req.publish_to_nostr !== false) {
|
||||||
|
await this.publishToNostr(stall)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stall
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stall by ID
|
||||||
|
*/
|
||||||
|
async get(id: string, applicationId: string): Promise<Stall | null> {
|
||||||
|
const rows = await this.db.query<StallRow>(
|
||||||
|
'SELECT * FROM stalls WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToStall(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all stalls for an application
|
||||||
|
*/
|
||||||
|
async list(applicationId: string): Promise<Stall[]> {
|
||||||
|
const rows = await this.db.query<StallRow>(
|
||||||
|
'SELECT * FROM stalls WHERE application_id = ? ORDER BY created_at DESC',
|
||||||
|
[applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows.map(rowToStall)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a stall
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
applicationId: string,
|
||||||
|
req: UpdateStallRequest
|
||||||
|
): Promise<Stall | null> {
|
||||||
|
const existing = await this.get(id, applicationId)
|
||||||
|
if (!existing) return null
|
||||||
|
|
||||||
|
// Build updated stall
|
||||||
|
const updated: Stall = {
|
||||||
|
...existing,
|
||||||
|
name: req.name?.trim() ?? existing.name,
|
||||||
|
description: req.description?.trim() ?? existing.description,
|
||||||
|
currency: req.currency ?? existing.currency,
|
||||||
|
shipping_zones: req.shipping_zones ?? existing.shipping_zones,
|
||||||
|
image_url: req.image_url ?? existing.image_url,
|
||||||
|
updated_at: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate merged stall
|
||||||
|
validateStall({
|
||||||
|
name: updated.name,
|
||||||
|
currency: updated.currency,
|
||||||
|
shipping_zones: updated.shipping_zones
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE stalls SET
|
||||||
|
name = ?, description = ?, currency = ?,
|
||||||
|
shipping_zones = ?, image_url = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND application_id = ?`,
|
||||||
|
[
|
||||||
|
updated.name,
|
||||||
|
updated.description,
|
||||||
|
updated.currency,
|
||||||
|
JSON.stringify(updated.shipping_zones),
|
||||||
|
updated.image_url || null,
|
||||||
|
updated.updated_at,
|
||||||
|
id,
|
||||||
|
applicationId
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Republish to Nostr (updates parameterized replaceable event)
|
||||||
|
if (req.publish_to_nostr !== false) {
|
||||||
|
await this.publishToNostr(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a stall
|
||||||
|
*/
|
||||||
|
async delete(id: string, applicationId: string): Promise<boolean> {
|
||||||
|
const stall = await this.get(id, applicationId)
|
||||||
|
if (!stall) return false
|
||||||
|
|
||||||
|
// Check for products
|
||||||
|
const products = await this.db.query(
|
||||||
|
'SELECT COUNT(*) as count FROM products WHERE stall_id = ?',
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
if ((products[0] as any).count > 0) {
|
||||||
|
throw new Error('Cannot delete stall with existing products')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish deletion event to Nostr if it was published
|
||||||
|
if (stall.nostr_event_id) {
|
||||||
|
await this.unpublishFromNostr(stall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
await this.db.execute(
|
||||||
|
'DELETE FROM stalls WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish stall to Nostr (kind 30017)
|
||||||
|
*/
|
||||||
|
async publishToNostr(stall: Stall): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// Get application's Nostr pubkey
|
||||||
|
const app = await this.ctx.getApplication(stall.application_id)
|
||||||
|
if (!app || !app.nostr_public) {
|
||||||
|
console.warn(`No Nostr pubkey for application ${stall.application_id}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the event
|
||||||
|
const event = buildStallEvent(stall, app.nostr_public)
|
||||||
|
|
||||||
|
// Sign and publish via context
|
||||||
|
const eventId = await this.ctx.publishNostrEvent(event)
|
||||||
|
|
||||||
|
// Update database with event info
|
||||||
|
if (eventId) {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE stalls SET
|
||||||
|
nostr_event_id = ?,
|
||||||
|
nostr_event_created_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[eventId, event.created_at, stall.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventId
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to publish stall to Nostr:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpublish stall from Nostr (kind 5 deletion)
|
||||||
|
*/
|
||||||
|
async unpublishFromNostr(stall: Stall): Promise<boolean> {
|
||||||
|
if (!stall.nostr_event_id) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await this.ctx.getApplication(stall.application_id)
|
||||||
|
if (!app || !app.nostr_public) return false
|
||||||
|
|
||||||
|
const deleteEvent = buildDeleteEvent(
|
||||||
|
stall.nostr_event_id,
|
||||||
|
30017, // STALL kind
|
||||||
|
app.nostr_public,
|
||||||
|
`Stall ${stall.name} removed`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.ctx.publishNostrEvent(deleteEvent)
|
||||||
|
|
||||||
|
// Clear event info from database
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE stalls SET
|
||||||
|
nostr_event_id = NULL,
|
||||||
|
nostr_event_created_at = NULL
|
||||||
|
WHERE id = ?`,
|
||||||
|
[stall.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to unpublish stall from Nostr:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Republish all stalls for an application
|
||||||
|
*/
|
||||||
|
async republishAll(applicationId: string): Promise<number> {
|
||||||
|
const stalls = await this.list(applicationId)
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (const stall of stalls) {
|
||||||
|
const eventId = await this.publishToNostr(stall)
|
||||||
|
if (eventId) count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/extensions/marketplace/migrations.ts
Normal file
194
src/extensions/marketplace/migrations.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { ExtensionDatabase } from './types.js'
|
||||||
|
|
||||||
|
export interface ExtensionMigration {
|
||||||
|
version: number
|
||||||
|
description: string
|
||||||
|
up(db: ExtensionDatabase): Promise<void>
|
||||||
|
down?(db: ExtensionDatabase): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const migrations: ExtensionMigration[] = [
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
description: 'Create core marketplace tables',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
// Stalls table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stalls (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
currency TEXT NOT NULL DEFAULT 'sat',
|
||||||
|
shipping_zones TEXT NOT NULL DEFAULT '[]',
|
||||||
|
image_url TEXT,
|
||||||
|
nostr_event_id TEXT,
|
||||||
|
nostr_event_created_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stalls_app
|
||||||
|
ON stalls(application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Products table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
stall_id TEXT NOT NULL,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
images TEXT NOT NULL DEFAULT '[]',
|
||||||
|
price INTEGER NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT -1,
|
||||||
|
categories TEXT NOT NULL DEFAULT '[]',
|
||||||
|
shipping_cost INTEGER,
|
||||||
|
specs TEXT DEFAULT '[]',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
nostr_event_id TEXT,
|
||||||
|
nostr_event_created_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (stall_id) REFERENCES stalls(id)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_stall
|
||||||
|
ON products(stall_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_app
|
||||||
|
ON products(application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_active
|
||||||
|
ON products(active, application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Orders table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
stall_id TEXT NOT NULL,
|
||||||
|
customer_pubkey TEXT NOT NULL,
|
||||||
|
customer_app_user_id TEXT,
|
||||||
|
items TEXT NOT NULL,
|
||||||
|
shipping_zone_id TEXT NOT NULL,
|
||||||
|
shipping_address TEXT NOT NULL,
|
||||||
|
contact TEXT NOT NULL,
|
||||||
|
subtotal_sats INTEGER NOT NULL,
|
||||||
|
shipping_sats INTEGER NOT NULL,
|
||||||
|
total_sats INTEGER NOT NULL,
|
||||||
|
original_currency TEXT NOT NULL,
|
||||||
|
exchange_rate REAL,
|
||||||
|
invoice TEXT,
|
||||||
|
invoice_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
nostr_event_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
paid_at INTEGER,
|
||||||
|
shipped_at INTEGER,
|
||||||
|
completed_at INTEGER
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_app
|
||||||
|
ON orders(application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_customer
|
||||||
|
ON orders(customer_pubkey, application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_status
|
||||||
|
ON orders(status, application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Direct messages table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
order_id TEXT,
|
||||||
|
customer_pubkey TEXT NOT NULL,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
incoming INTEGER NOT NULL,
|
||||||
|
nostr_event_id TEXT NOT NULL UNIQUE,
|
||||||
|
nostr_event_created_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
read INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_customer
|
||||||
|
ON direct_messages(customer_pubkey, application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_order
|
||||||
|
ON direct_messages(order_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_unread
|
||||||
|
ON direct_messages(read, application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Customers table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
pubkey TEXT NOT NULL,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
about TEXT,
|
||||||
|
picture TEXT,
|
||||||
|
total_orders INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_spent_sats INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unread_messages INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_seen_at INTEGER NOT NULL,
|
||||||
|
last_seen_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (pubkey, application_id)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (db: ExtensionDatabase) => {
|
||||||
|
await db.execute('DROP TABLE IF EXISTS customers')
|
||||||
|
await db.execute('DROP TABLE IF EXISTS direct_messages')
|
||||||
|
await db.execute('DROP TABLE IF EXISTS orders')
|
||||||
|
await db.execute('DROP TABLE IF EXISTS products')
|
||||||
|
await db.execute('DROP TABLE IF EXISTS stalls')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
description: 'Add exchange rates cache table',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS exchange_rates (
|
||||||
|
currency TEXT PRIMARY KEY,
|
||||||
|
rate_sats INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (db: ExtensionDatabase) => {
|
||||||
|
await db.execute('DROP TABLE IF EXISTS exchange_rates')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
164
src/extensions/marketplace/nostr/events.ts
Normal file
164
src/extensions/marketplace/nostr/events.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { EVENT_KINDS } from './kinds.js'
|
||||||
|
import {
|
||||||
|
Stall, Product, Order,
|
||||||
|
NIP15Stall, NIP15Product, NIP15PaymentRequest, NIP15OrderStatusUpdate
|
||||||
|
} from '../types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsigned Nostr event structure
|
||||||
|
*/
|
||||||
|
export interface UnsignedEvent {
|
||||||
|
kind: number
|
||||||
|
pubkey: string
|
||||||
|
created_at: number
|
||||||
|
tags: string[][]
|
||||||
|
content: string
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build NIP-15 stall event (kind 30017)
|
||||||
|
*/
|
||||||
|
export function buildStallEvent(stall: Stall, pubkey: string): UnsignedEvent {
|
||||||
|
const nip15Stall: NIP15Stall = {
|
||||||
|
id: stall.id,
|
||||||
|
name: stall.name,
|
||||||
|
description: stall.description,
|
||||||
|
currency: stall.currency,
|
||||||
|
shipping: stall.shipping_zones.map(zone => ({
|
||||||
|
id: zone.id,
|
||||||
|
name: zone.name,
|
||||||
|
cost: zone.cost,
|
||||||
|
regions: zone.regions
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: EVENT_KINDS.STALL,
|
||||||
|
pubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['d', stall.id], // Unique identifier for parameterized replaceable
|
||||||
|
],
|
||||||
|
content: JSON.stringify(nip15Stall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build NIP-15 product event (kind 30018)
|
||||||
|
*/
|
||||||
|
export function buildProductEvent(product: Product, stall: Stall, pubkey: string): UnsignedEvent {
|
||||||
|
const nip15Product: NIP15Product = {
|
||||||
|
id: product.id,
|
||||||
|
stall_id: product.stall_id,
|
||||||
|
name: product.name,
|
||||||
|
description: product.description,
|
||||||
|
images: product.images,
|
||||||
|
currency: stall.currency,
|
||||||
|
price: product.price,
|
||||||
|
quantity: product.quantity,
|
||||||
|
specs: product.specs?.map(s => [s.key, s.value] as [string, string])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add shipping costs if different from stall defaults
|
||||||
|
if (product.shipping_cost !== undefined) {
|
||||||
|
nip15Product.shipping = stall.shipping_zones.map(zone => ({
|
||||||
|
id: zone.id,
|
||||||
|
cost: product.shipping_cost!
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['d', product.id], // Unique identifier
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add category tags
|
||||||
|
for (const category of product.categories) {
|
||||||
|
tags.push(['t', category])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: EVENT_KINDS.PRODUCT,
|
||||||
|
pubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
content: JSON.stringify(nip15Product)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build deletion event (kind 5)
|
||||||
|
*/
|
||||||
|
export function buildDeleteEvent(
|
||||||
|
eventId: string,
|
||||||
|
kind: number,
|
||||||
|
pubkey: string,
|
||||||
|
reason?: string
|
||||||
|
): UnsignedEvent {
|
||||||
|
const tags: string[][] = [
|
||||||
|
['e', eventId],
|
||||||
|
['k', String(kind)]
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: EVENT_KINDS.DELETE,
|
||||||
|
pubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
content: reason || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build payment request DM content
|
||||||
|
*/
|
||||||
|
export function buildPaymentRequestContent(
|
||||||
|
order: Order,
|
||||||
|
invoice: string,
|
||||||
|
message?: string
|
||||||
|
): NIP15PaymentRequest {
|
||||||
|
return {
|
||||||
|
id: order.id,
|
||||||
|
type: 1,
|
||||||
|
message: message || `Payment request for order ${order.id}. Total: ${order.total_sats} sats`,
|
||||||
|
payment_options: [
|
||||||
|
{ type: 'ln', link: invoice }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build order status update DM content
|
||||||
|
*/
|
||||||
|
export function buildOrderStatusContent(
|
||||||
|
order: Order,
|
||||||
|
message: string
|
||||||
|
): NIP15OrderStatusUpdate {
|
||||||
|
return {
|
||||||
|
id: order.id,
|
||||||
|
type: 2,
|
||||||
|
message,
|
||||||
|
paid: order.status !== 'pending',
|
||||||
|
shipped: order.status === 'shipped' || order.status === 'completed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build encrypted DM event (kind 4)
|
||||||
|
* Note: Actual encryption happens in the manager using NIP-44
|
||||||
|
*/
|
||||||
|
export function buildDirectMessageEvent(
|
||||||
|
content: string, // Already encrypted
|
||||||
|
fromPubkey: string,
|
||||||
|
toPubkey: string
|
||||||
|
): UnsignedEvent {
|
||||||
|
return {
|
||||||
|
kind: EVENT_KINDS.DIRECT_MESSAGE,
|
||||||
|
pubkey: fromPubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['p', toPubkey]
|
||||||
|
],
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/extensions/marketplace/nostr/kinds.ts
Normal file
31
src/extensions/marketplace/nostr/kinds.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* NIP-15 Event Kinds
|
||||||
|
* https://github.com/nostr-protocol/nips/blob/master/15.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const EVENT_KINDS = {
|
||||||
|
// Standard kinds
|
||||||
|
METADATA: 0, // User profile
|
||||||
|
TEXT_NOTE: 1, // Short text
|
||||||
|
DIRECT_MESSAGE: 4, // Encrypted DM (NIP-04)
|
||||||
|
DELETE: 5, // Event deletion
|
||||||
|
|
||||||
|
// NIP-15 Marketplace kinds
|
||||||
|
STALL: 30017, // Parameterized replaceable: merchant stall
|
||||||
|
PRODUCT: 30018, // Parameterized replaceable: product listing
|
||||||
|
|
||||||
|
// Lightning.Pub RPC kinds (for native clients)
|
||||||
|
RPC_REQUEST: 21001, // Encrypted RPC request
|
||||||
|
RPC_RESPONSE: 21002, // Encrypted RPC response
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type EventKind = typeof EVENT_KINDS[keyof typeof EVENT_KINDS]
|
||||||
|
|
||||||
|
// Marketplace message types (in DM content)
|
||||||
|
export const MESSAGE_TYPES = {
|
||||||
|
ORDER_REQUEST: 0,
|
||||||
|
PAYMENT_REQUEST: 1,
|
||||||
|
ORDER_STATUS: 2,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type MessageTypeNum = typeof MESSAGE_TYPES[keyof typeof MESSAGE_TYPES]
|
||||||
162
src/extensions/marketplace/nostr/parser.ts
Normal file
162
src/extensions/marketplace/nostr/parser.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { EVENT_KINDS, MESSAGE_TYPES } from './kinds.js'
|
||||||
|
import {
|
||||||
|
NIP15Stall, NIP15Product, NIP15OrderRequest,
|
||||||
|
Stall, Product, MessageType
|
||||||
|
} from '../types.js'
|
||||||
|
import { generateId } from '../utils/validation.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nostr event structure
|
||||||
|
*/
|
||||||
|
export interface NostrEvent {
|
||||||
|
id: string
|
||||||
|
pubkey: string
|
||||||
|
created_at: number
|
||||||
|
kind: number
|
||||||
|
tags: string[][]
|
||||||
|
content: string
|
||||||
|
sig?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse NIP-15 stall event
|
||||||
|
*/
|
||||||
|
export function parseStallEvent(event: NostrEvent): Partial<Stall> | null {
|
||||||
|
if (event.kind !== EVENT_KINDS.STALL) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(event.content) as NIP15Stall
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: content.id || dTag?.[1] || generateId(),
|
||||||
|
name: content.name,
|
||||||
|
description: content.description || '',
|
||||||
|
currency: content.currency as any || 'sat',
|
||||||
|
shipping_zones: (content.shipping || []).map(zone => ({
|
||||||
|
id: zone.id || generateId(),
|
||||||
|
name: zone.name || zone.id,
|
||||||
|
cost: zone.cost,
|
||||||
|
regions: zone.regions || []
|
||||||
|
})),
|
||||||
|
nostr_event_id: event.id,
|
||||||
|
nostr_event_created_at: event.created_at
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse NIP-15 product event
|
||||||
|
*/
|
||||||
|
export function parseProductEvent(event: NostrEvent): Partial<Product> | null {
|
||||||
|
if (event.kind !== EVENT_KINDS.PRODUCT) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(event.content) as NIP15Product
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')
|
||||||
|
const categoryTags = event.tags.filter(t => t[0] === 't').map(t => t[1])
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: content.id || dTag?.[1] || generateId(),
|
||||||
|
stall_id: content.stall_id,
|
||||||
|
name: content.name,
|
||||||
|
description: content.description || '',
|
||||||
|
images: content.images || [],
|
||||||
|
price: content.price,
|
||||||
|
quantity: content.quantity ?? -1,
|
||||||
|
categories: categoryTags.length > 0 ? categoryTags : [],
|
||||||
|
specs: content.specs?.map(([key, value]) => ({ key, value })),
|
||||||
|
nostr_event_id: event.id,
|
||||||
|
nostr_event_created_at: event.created_at
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse NIP-15 order request from DM
|
||||||
|
*/
|
||||||
|
export function parseOrderRequest(content: string): NIP15OrderRequest | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (parsed.type !== MESSAGE_TYPES.ORDER_REQUEST) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: parsed.id || generateId(),
|
||||||
|
type: 0,
|
||||||
|
name: parsed.name,
|
||||||
|
address: parsed.address,
|
||||||
|
message: parsed.message,
|
||||||
|
contact: parsed.contact || {},
|
||||||
|
items: parsed.items || [],
|
||||||
|
shipping_id: parsed.shipping_id
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine message type from decrypted content
|
||||||
|
*/
|
||||||
|
export function parseMessageType(content: string): { type: MessageType; parsed: any } {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
|
||||||
|
if (typeof parsed.type === 'number') {
|
||||||
|
switch (parsed.type) {
|
||||||
|
case MESSAGE_TYPES.ORDER_REQUEST:
|
||||||
|
return { type: 'order_request', parsed }
|
||||||
|
case MESSAGE_TYPES.PAYMENT_REQUEST:
|
||||||
|
return { type: 'payment_request', parsed }
|
||||||
|
case MESSAGE_TYPES.ORDER_STATUS:
|
||||||
|
return { type: 'order_status', parsed }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it has items and shipping_id, treat as order request
|
||||||
|
if (parsed.items && parsed.shipping_id) {
|
||||||
|
return { type: 'order_request', parsed: { ...parsed, type: 0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'plain', parsed: content }
|
||||||
|
} catch {
|
||||||
|
return { type: 'plain', parsed: content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract pubkey from p tag
|
||||||
|
*/
|
||||||
|
export function extractRecipientPubkey(event: NostrEvent): string | null {
|
||||||
|
const pTag = event.tags.find(t => t[0] === 'p')
|
||||||
|
return pTag?.[1] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event is a deletion event for a specific kind
|
||||||
|
*/
|
||||||
|
export function isDeletionEvent(event: NostrEvent, targetKind?: number): boolean {
|
||||||
|
if (event.kind !== EVENT_KINDS.DELETE) return false
|
||||||
|
|
||||||
|
if (targetKind !== undefined) {
|
||||||
|
const kTag = event.tags.find(t => t[0] === 'k')
|
||||||
|
return kTag?.[1] === String(targetKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get deleted event IDs from a deletion event
|
||||||
|
*/
|
||||||
|
export function getDeletedEventIds(event: NostrEvent): string[] {
|
||||||
|
if (event.kind !== EVENT_KINDS.DELETE) return []
|
||||||
|
|
||||||
|
return event.tags
|
||||||
|
.filter(t => t[0] === 'e')
|
||||||
|
.map(t => t[1])
|
||||||
|
}
|
||||||
418
src/extensions/marketplace/types.ts
Normal file
418
src/extensions/marketplace/types.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
/**
|
||||||
|
* Marketplace Extension Types
|
||||||
|
* NIP-15 compliant marketplace for Lightning.Pub
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export base extension types
|
||||||
|
export {
|
||||||
|
Extension,
|
||||||
|
ExtensionInfo,
|
||||||
|
ExtensionContext,
|
||||||
|
ExtensionDatabase,
|
||||||
|
ApplicationInfo,
|
||||||
|
NostrEvent,
|
||||||
|
UnsignedNostrEvent,
|
||||||
|
PaymentReceivedData,
|
||||||
|
RpcMethodHandler
|
||||||
|
} from '../types.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Data Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ShippingZone {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
cost: number // In stall's currency
|
||||||
|
regions: string[] // ISO country codes or region names
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stall {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
currency: Currency
|
||||||
|
shipping_zones: ShippingZone[]
|
||||||
|
image_url?: string
|
||||||
|
|
||||||
|
// Nostr sync
|
||||||
|
nostr_event_id?: string
|
||||||
|
nostr_event_created_at?: number
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: string
|
||||||
|
stall_id: string
|
||||||
|
application_id: string
|
||||||
|
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
images: string[]
|
||||||
|
price: number // In stall's currency
|
||||||
|
quantity: number // Available stock (-1 = unlimited)
|
||||||
|
categories: string[]
|
||||||
|
|
||||||
|
// Shipping override (optional, otherwise use stall zones)
|
||||||
|
shipping_cost?: number
|
||||||
|
|
||||||
|
// Product options/variants (future)
|
||||||
|
specs?: ProductSpec[]
|
||||||
|
|
||||||
|
// Behavior
|
||||||
|
active: boolean
|
||||||
|
|
||||||
|
// Nostr sync
|
||||||
|
nostr_event_id?: string
|
||||||
|
nostr_event_created_at?: number
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSpec {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderItem {
|
||||||
|
product_id: string
|
||||||
|
product_name: string // Snapshot at order time
|
||||||
|
quantity: number
|
||||||
|
unit_price: number // In stall currency, at order time
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactInfo {
|
||||||
|
nostr?: string // Nostr pubkey
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderStatus =
|
||||||
|
| 'pending' // Awaiting payment
|
||||||
|
| 'paid' // Payment received
|
||||||
|
| 'processing' // Merchant preparing
|
||||||
|
| 'shipped' // In transit
|
||||||
|
| 'completed' // Delivered
|
||||||
|
| 'cancelled' // Cancelled by merchant or customer
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
stall_id: string
|
||||||
|
|
||||||
|
// Customer
|
||||||
|
customer_pubkey: string // Nostr pubkey
|
||||||
|
customer_app_user_id?: string // If registered user
|
||||||
|
|
||||||
|
// Order details
|
||||||
|
items: OrderItem[]
|
||||||
|
shipping_zone_id: string
|
||||||
|
shipping_address: string
|
||||||
|
contact: ContactInfo
|
||||||
|
|
||||||
|
// Pricing (all in sats)
|
||||||
|
subtotal_sats: number
|
||||||
|
shipping_sats: number
|
||||||
|
total_sats: number
|
||||||
|
|
||||||
|
// Original currency info (for display)
|
||||||
|
original_currency: Currency
|
||||||
|
exchange_rate?: number // Rate at order time
|
||||||
|
|
||||||
|
// Payment
|
||||||
|
invoice?: string // BOLT11 invoice
|
||||||
|
invoice_id?: string // Internal invoice reference
|
||||||
|
|
||||||
|
// Status
|
||||||
|
status: OrderStatus
|
||||||
|
|
||||||
|
// Nostr
|
||||||
|
nostr_event_id?: string // Order request event
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
created_at: number
|
||||||
|
paid_at?: number
|
||||||
|
shipped_at?: number
|
||||||
|
completed_at?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectMessage {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
order_id?: string // Optional link to order
|
||||||
|
|
||||||
|
customer_pubkey: string
|
||||||
|
|
||||||
|
// Message content
|
||||||
|
message_type: MessageType
|
||||||
|
content: string // Decrypted content
|
||||||
|
|
||||||
|
// Direction
|
||||||
|
incoming: boolean // true = from customer, false = to customer
|
||||||
|
|
||||||
|
// Nostr
|
||||||
|
nostr_event_id: string
|
||||||
|
nostr_event_created_at: number
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
created_at: number
|
||||||
|
read: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageType =
|
||||||
|
| 'plain' // Regular text message
|
||||||
|
| 'order_request' // Customer order (type 0)
|
||||||
|
| 'payment_request' // Merchant payment request (type 1)
|
||||||
|
| 'order_status' // Order update (type 2)
|
||||||
|
|
||||||
|
export interface Customer {
|
||||||
|
pubkey: string
|
||||||
|
application_id: string
|
||||||
|
|
||||||
|
// Profile (from kind 0)
|
||||||
|
name?: string
|
||||||
|
about?: string
|
||||||
|
picture?: string
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
total_orders: number
|
||||||
|
total_spent_sats: number
|
||||||
|
unread_messages: number
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
first_seen_at: number
|
||||||
|
last_seen_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Currency Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type Currency = 'sat' | 'btc' | 'usd' | 'eur' | 'gbp' | 'cad' | 'aud' | 'jpy'
|
||||||
|
|
||||||
|
export interface ExchangeRate {
|
||||||
|
currency: Currency
|
||||||
|
rate_sats: number // How many sats per 1 unit of currency
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NIP-15 Event Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NIP15Stall {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
currency: string
|
||||||
|
shipping: NIP15ShippingZone[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15ShippingZone {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
cost: number
|
||||||
|
regions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15Product {
|
||||||
|
id: string
|
||||||
|
stall_id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
images?: string[]
|
||||||
|
currency: string
|
||||||
|
price: number
|
||||||
|
quantity: number
|
||||||
|
specs?: Array<[string, string]>
|
||||||
|
shipping?: NIP15ProductShipping[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15ProductShipping {
|
||||||
|
id: string // Zone ID
|
||||||
|
cost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15OrderRequest {
|
||||||
|
id: string
|
||||||
|
type: 0
|
||||||
|
name?: string
|
||||||
|
address?: string
|
||||||
|
message?: string
|
||||||
|
contact: {
|
||||||
|
nostr?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
items: Array<{
|
||||||
|
product_id: string
|
||||||
|
quantity: number
|
||||||
|
}>
|
||||||
|
shipping_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15PaymentRequest {
|
||||||
|
id: string
|
||||||
|
type: 1
|
||||||
|
message?: string
|
||||||
|
payment_options: Array<{
|
||||||
|
type: 'ln' | 'url' | 'btc'
|
||||||
|
link: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15OrderStatusUpdate {
|
||||||
|
id: string
|
||||||
|
type: 2
|
||||||
|
message: string
|
||||||
|
paid?: boolean
|
||||||
|
shipped?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RPC Request/Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Stall operations
|
||||||
|
export interface CreateStallRequest {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
currency: Currency
|
||||||
|
shipping_zones: Array<Omit<ShippingZone, 'id'> & { id?: string }>
|
||||||
|
image_url?: string
|
||||||
|
publish_to_nostr?: boolean // Default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStallRequest {
|
||||||
|
stall_id: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
currency?: Currency
|
||||||
|
shipping_zones?: ShippingZone[]
|
||||||
|
image_url?: string
|
||||||
|
publish_to_nostr?: boolean // Default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetStallRequest {
|
||||||
|
stall_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListStallsRequest {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product operations
|
||||||
|
export interface CreateProductRequest {
|
||||||
|
stall_id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
images?: string[]
|
||||||
|
price: number
|
||||||
|
quantity?: number // Default: -1 (unlimited)
|
||||||
|
categories?: string[]
|
||||||
|
shipping_cost?: number
|
||||||
|
specs?: ProductSpec[]
|
||||||
|
active?: boolean
|
||||||
|
publish_to_nostr?: boolean // Default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductRequest {
|
||||||
|
product_id: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
images?: string[]
|
||||||
|
price?: number
|
||||||
|
quantity?: number
|
||||||
|
categories?: string[]
|
||||||
|
shipping_cost?: number
|
||||||
|
specs?: ProductSpec[]
|
||||||
|
active?: boolean
|
||||||
|
publish_to_nostr?: boolean // Default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetProductRequest {
|
||||||
|
product_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListProductsRequest {
|
||||||
|
stall_id?: string
|
||||||
|
category?: string
|
||||||
|
active_only?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order operations
|
||||||
|
export interface CreateOrderRequest {
|
||||||
|
stall_id: string
|
||||||
|
items: Array<{ product_id: string; quantity: number }>
|
||||||
|
shipping_zone_id: string
|
||||||
|
shipping_address: string
|
||||||
|
contact: ContactInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetOrderRequest {
|
||||||
|
order_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListOrdersRequest {
|
||||||
|
stall_id?: string
|
||||||
|
status?: OrderStatus
|
||||||
|
customer_pubkey?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOrderStatusRequest {
|
||||||
|
order_id: string
|
||||||
|
status: OrderStatus
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message operations
|
||||||
|
export interface SendMessageRequest {
|
||||||
|
customer_pubkey: string
|
||||||
|
message: string
|
||||||
|
order_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListMessagesRequest {
|
||||||
|
customer_pubkey?: string
|
||||||
|
order_id?: string
|
||||||
|
unread_only?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkMessagesReadRequest {
|
||||||
|
customer_pubkey?: string
|
||||||
|
message_ids?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer operations
|
||||||
|
export interface ListCustomersRequest {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCustomerRequest {
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Extension Context Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MarketplaceContext {
|
||||||
|
application_id: string
|
||||||
|
application_pubkey: string
|
||||||
|
user_id: string
|
||||||
|
is_owner: boolean
|
||||||
|
}
|
||||||
205
src/extensions/marketplace/utils/currency.ts
Normal file
205
src/extensions/marketplace/utils/currency.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { ExtensionDatabase } from '../types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange rate data
|
||||||
|
*/
|
||||||
|
interface ExchangeRateData {
|
||||||
|
currency: string
|
||||||
|
rate_sats: number // How many sats per 1 unit of currency
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache duration: 10 minutes
|
||||||
|
const CACHE_DURATION_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
|
// In-memory cache for rates
|
||||||
|
const rateCache = new Map<string, { rate: number; timestamp: number }>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported fiat currencies
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_CURRENCIES = ['sat', 'btc', 'usd', 'eur', 'gbp', 'cad', 'aud', 'jpy'] as const
|
||||||
|
export type SupportedCurrency = typeof SUPPORTED_CURRENCIES[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get exchange rate from cache or database
|
||||||
|
*/
|
||||||
|
async function getCachedRate(
|
||||||
|
db: ExtensionDatabase,
|
||||||
|
currency: string
|
||||||
|
): Promise<number | null> {
|
||||||
|
// Check memory cache first
|
||||||
|
const cached = rateCache.get(currency)
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_DURATION_MS) {
|
||||||
|
return cached.rate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check database cache
|
||||||
|
const result = await db.query<ExchangeRateData>(
|
||||||
|
'SELECT * FROM exchange_rates WHERE currency = ?',
|
||||||
|
[currency]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
const row = result[0]
|
||||||
|
if (Date.now() - row.updated_at * 1000 < CACHE_DURATION_MS) {
|
||||||
|
rateCache.set(currency, { rate: row.rate_sats, timestamp: row.updated_at * 1000 })
|
||||||
|
return row.rate_sats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save exchange rate to cache and database
|
||||||
|
*/
|
||||||
|
async function saveRate(
|
||||||
|
db: ExtensionDatabase,
|
||||||
|
currency: string,
|
||||||
|
rateSats: number
|
||||||
|
): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
// Save to memory cache
|
||||||
|
rateCache.set(currency, { rate: rateSats, timestamp: now * 1000 })
|
||||||
|
|
||||||
|
// Save to database (upsert)
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO exchange_rates (currency, rate_sats, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(currency) DO UPDATE SET
|
||||||
|
rate_sats = excluded.rate_sats,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
[currency, rateSats, now]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current BTC price from public API
|
||||||
|
* Returns price in USD per BTC
|
||||||
|
*/
|
||||||
|
async function fetchBtcPrice(): Promise<number> {
|
||||||
|
// Try CoinGecko first (free, no API key)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd,eur,gbp,cad,aud,jpy'
|
||||||
|
)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
return data.bitcoin.usd
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fall through to backup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup: use a hardcoded fallback (should be updated periodically)
|
||||||
|
// This is a safety net - in production you'd want multiple API sources
|
||||||
|
console.warn('Failed to fetch exchange rate, using fallback')
|
||||||
|
return 100000 // Fallback BTC price in USD
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get exchange rate: sats per 1 unit of currency
|
||||||
|
*/
|
||||||
|
export async function getExchangeRate(
|
||||||
|
db: ExtensionDatabase,
|
||||||
|
currency: SupportedCurrency
|
||||||
|
): Promise<number> {
|
||||||
|
// Bitcoin denominations are fixed
|
||||||
|
if (currency === 'sat') return 1
|
||||||
|
if (currency === 'btc') return 100_000_000
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
const cached = await getCachedRate(db, currency)
|
||||||
|
if (cached !== null) return cached
|
||||||
|
|
||||||
|
// Fetch fresh rate
|
||||||
|
const btcPriceUsd = await fetchBtcPrice()
|
||||||
|
const satsPerBtc = 100_000_000
|
||||||
|
|
||||||
|
// Calculate rates for all fiat currencies
|
||||||
|
// Using approximate cross-rates (in production, fetch all from API)
|
||||||
|
const usdRates: Record<string, number> = {
|
||||||
|
usd: 1,
|
||||||
|
eur: 0.92,
|
||||||
|
gbp: 0.79,
|
||||||
|
cad: 1.36,
|
||||||
|
aud: 1.53,
|
||||||
|
jpy: 149.5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate sats per 1 unit of each currency
|
||||||
|
for (const [curr, usdRate] of Object.entries(usdRates)) {
|
||||||
|
const priceInCurrency = btcPriceUsd * usdRate
|
||||||
|
const satsPerUnit = Math.round(satsPerBtc / priceInCurrency)
|
||||||
|
await saveRate(db, curr, satsPerUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return requested currency rate
|
||||||
|
const rate = await getCachedRate(db, currency)
|
||||||
|
return rate || 1 // Fallback to 1:1 if something went wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert amount from a currency to sats
|
||||||
|
*/
|
||||||
|
export async function convertToSats(
|
||||||
|
db: ExtensionDatabase,
|
||||||
|
amount: number,
|
||||||
|
currency: SupportedCurrency
|
||||||
|
): Promise<number> {
|
||||||
|
if (currency === 'sat') return Math.round(amount)
|
||||||
|
|
||||||
|
const rate = await getExchangeRate(db, currency)
|
||||||
|
return Math.round(amount * rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert amount from sats to a currency
|
||||||
|
*/
|
||||||
|
export async function convertFromSats(
|
||||||
|
db: ExtensionDatabase,
|
||||||
|
sats: number,
|
||||||
|
currency: SupportedCurrency
|
||||||
|
): Promise<number> {
|
||||||
|
if (currency === 'sat') return sats
|
||||||
|
|
||||||
|
const rate = await getExchangeRate(db, currency)
|
||||||
|
return sats / rate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format amount with currency symbol
|
||||||
|
*/
|
||||||
|
export function formatCurrency(amount: number, currency: SupportedCurrency): string {
|
||||||
|
const symbols: Record<SupportedCurrency, string> = {
|
||||||
|
sat: ' sats',
|
||||||
|
btc: ' BTC',
|
||||||
|
usd: '$',
|
||||||
|
eur: '€',
|
||||||
|
gbp: '£',
|
||||||
|
cad: 'CA$',
|
||||||
|
aud: 'AU$',
|
||||||
|
jpy: '¥'
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbol = symbols[currency]
|
||||||
|
const isPrefix = ['usd', 'gbp', 'cad', 'aud', 'jpy'].includes(currency)
|
||||||
|
|
||||||
|
if (currency === 'btc') {
|
||||||
|
return `${amount.toFixed(8)}${symbol}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currency === 'sat') {
|
||||||
|
return `${amount.toLocaleString()}${symbol}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fiat currencies
|
||||||
|
const formatted = amount.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
return isPrefix ? `${symbol}${formatted}` : `${formatted}${symbol}`
|
||||||
|
}
|
||||||
109
src/extensions/marketplace/utils/validation.ts
Normal file
109
src/extensions/marketplace/utils/validation.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { CreateStallRequest, CreateProductRequest } from '../types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random ID
|
||||||
|
*/
|
||||||
|
export function generateId(): string {
|
||||||
|
return crypto.randomBytes(16).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate stall creation request
|
||||||
|
*/
|
||||||
|
export function validateStall(req: CreateStallRequest): void {
|
||||||
|
if (!req.name || req.name.trim().length === 0) {
|
||||||
|
throw new Error('Stall name is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.name.length > 100) {
|
||||||
|
throw new Error('Stall name must be 100 characters or less')
|
||||||
|
}
|
||||||
|
|
||||||
|
const validCurrencies = ['sat', 'btc', 'usd', 'eur', 'gbp', 'cad', 'aud', 'jpy']
|
||||||
|
if (!validCurrencies.includes(req.currency)) {
|
||||||
|
throw new Error(`Invalid currency. Must be one of: ${validCurrencies.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.shipping_zones || req.shipping_zones.length === 0) {
|
||||||
|
throw new Error('At least one shipping zone is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const zone of req.shipping_zones) {
|
||||||
|
if (!zone.name || zone.name.trim().length === 0) {
|
||||||
|
throw new Error('Shipping zone name is required')
|
||||||
|
}
|
||||||
|
if (zone.cost < 0) {
|
||||||
|
throw new Error('Shipping cost cannot be negative')
|
||||||
|
}
|
||||||
|
if (!zone.regions || zone.regions.length === 0) {
|
||||||
|
throw new Error('Shipping zone must have at least one region')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate product creation request
|
||||||
|
*/
|
||||||
|
export function validateProduct(req: CreateProductRequest): void {
|
||||||
|
if (!req.name || req.name.trim().length === 0) {
|
||||||
|
throw new Error('Product name is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.name.length > 200) {
|
||||||
|
throw new Error('Product name must be 200 characters or less')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.price < 0) {
|
||||||
|
throw new Error('Price cannot be negative')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.quantity < -1) {
|
||||||
|
throw new Error('Quantity must be -1 (unlimited) or >= 0')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.images && req.images.length > 10) {
|
||||||
|
throw new Error('Maximum 10 images allowed')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.categories && req.categories.length > 10) {
|
||||||
|
throw new Error('Maximum 10 categories allowed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Nostr pubkey format (64 char hex)
|
||||||
|
*/
|
||||||
|
export function validatePubkey(pubkey: string): boolean {
|
||||||
|
return /^[0-9a-f]{64}$/i.test(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize string input
|
||||||
|
*/
|
||||||
|
export function sanitizeString(input: string, maxLength: number = 1000): string {
|
||||||
|
if (!input) return ''
|
||||||
|
return input.trim().slice(0, maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate order items
|
||||||
|
*/
|
||||||
|
export function validateOrderItems(items: Array<{ product_id: string; quantity: number }>): void {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
throw new Error('Order must have at least one item')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length > 100) {
|
||||||
|
throw new Error('Maximum 100 items per order')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.product_id) {
|
||||||
|
throw new Error('Product ID is required for each item')
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(item.quantity) || item.quantity < 1) {
|
||||||
|
throw new Error('Quantity must be a positive integer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue