diff --git a/src/extensions/marketplace/index.ts b/src/extensions/marketplace/index.ts deleted file mode 100644 index 0bd470e3..00000000 --- a/src/extensions/marketplace/index.ts +++ /dev/null @@ -1,390 +0,0 @@ -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 { - // 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 { - 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 { - // 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' diff --git a/src/extensions/marketplace/managers/messageManager.ts b/src/extensions/marketplace/managers/messageManager.ts deleted file mode 100644 index a70d0700..00000000 --- a/src/extensions/marketplace/managers/messageManager.ts +++ /dev/null @@ -1,497 +0,0 @@ -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 { - // 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 { - 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 { - const rows = await this.db.query( - '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 { - 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(sql, params) - return rows.map(rowToMessage) - } - - /** - * Get conversation thread with a customer - */ - async getConversation( - applicationId: string, - customerPubkey: string, - limit: number = 50 - ): Promise { - const rows = await this.db.query( - `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 { - 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 { - 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 { - // 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 { - const rows = await this.db.query( - '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 { - 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(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 { - 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 { - 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 { - await this.db.execute( - `UPDATE customers SET unread_messages = unread_messages + 1 - WHERE pubkey = ? AND application_id = ?`, - [pubkey, applicationId] - ) - } -} diff --git a/src/extensions/marketplace/managers/orderManager.ts b/src/extensions/marketplace/managers/orderManager.ts deleted file mode 100644 index 30d8c8d9..00000000 --- a/src/extensions/marketplace/managers/orderManager.ts +++ /dev/null @@ -1,607 +0,0 @@ -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 { - // 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 { - // 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 { - const rows = await this.db.query( - '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 { - 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(sql, params) - return rows.map(rowToOrder) - } - - /** - * Create invoice for an order - */ - async createInvoice(id: string, applicationId: string): Promise { - 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 { - // Find order by invoice ID - const rows = await this.db.query( - '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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - await this.db.execute( - `UPDATE customers SET - total_spent_sats = total_spent_sats + ? - WHERE pubkey = ? AND application_id = ?`, - [amount, pubkey, applicationId] - ) - } -} diff --git a/src/extensions/marketplace/managers/productManager.ts b/src/extensions/marketplace/managers/productManager.ts deleted file mode 100644 index f5321bb4..00000000 --- a/src/extensions/marketplace/managers/productManager.ts +++ /dev/null @@ -1,466 +0,0 @@ -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 { - // 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 { - const rows = await this.db.query( - '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 { - 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(sql, params) - return rows.map(rowToProduct) - } - - /** - * Update a product - */ - async update( - id: string, - applicationId: string, - req: UpdateProductRequest - ): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 - } -} diff --git a/src/extensions/marketplace/managers/stallManager.ts b/src/extensions/marketplace/managers/stallManager.ts deleted file mode 100644 index a77809c3..00000000 --- a/src/extensions/marketplace/managers/stallManager.ts +++ /dev/null @@ -1,305 +0,0 @@ -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 { - // 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 { - const rows = await this.db.query( - '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 { - const rows = await this.db.query( - '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 { - 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 { - 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 { - 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 { - 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 { - 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 - } -} diff --git a/src/extensions/marketplace/migrations.ts b/src/extensions/marketplace/migrations.ts deleted file mode 100644 index 5c1ff1c7..00000000 --- a/src/extensions/marketplace/migrations.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { ExtensionDatabase } from './types.js' - -export interface ExtensionMigration { - version: number - description: string - up(db: ExtensionDatabase): Promise - down?(db: ExtensionDatabase): Promise -} - -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') - } - } -] diff --git a/src/extensions/marketplace/nostr/events.ts b/src/extensions/marketplace/nostr/events.ts deleted file mode 100644 index b4c476b4..00000000 --- a/src/extensions/marketplace/nostr/events.ts +++ /dev/null @@ -1,164 +0,0 @@ -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 - } -} diff --git a/src/extensions/marketplace/nostr/kinds.ts b/src/extensions/marketplace/nostr/kinds.ts deleted file mode 100644 index 20d26914..00000000 --- a/src/extensions/marketplace/nostr/kinds.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 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] diff --git a/src/extensions/marketplace/nostr/parser.ts b/src/extensions/marketplace/nostr/parser.ts deleted file mode 100644 index f90a1aa3..00000000 --- a/src/extensions/marketplace/nostr/parser.ts +++ /dev/null @@ -1,162 +0,0 @@ -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 | 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 | 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]) -} diff --git a/src/extensions/marketplace/types.ts b/src/extensions/marketplace/types.ts deleted file mode 100644 index 7559d947..00000000 --- a/src/extensions/marketplace/types.ts +++ /dev/null @@ -1,418 +0,0 @@ -/** - * 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 & { 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 -} diff --git a/src/extensions/marketplace/utils/currency.ts b/src/extensions/marketplace/utils/currency.ts deleted file mode 100644 index 7bb2dd14..00000000 --- a/src/extensions/marketplace/utils/currency.ts +++ /dev/null @@ -1,205 +0,0 @@ -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() - -/** - * 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 { - // 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( - '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 { - 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 { - // 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 { - // 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 = { - 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 { - 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 { - 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 = { - 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}` -} diff --git a/src/extensions/marketplace/utils/validation.ts b/src/extensions/marketplace/utils/validation.ts deleted file mode 100644 index 549b59d4..00000000 --- a/src/extensions/marketplace/utils/validation.ts +++ /dev/null @@ -1,109 +0,0 @@ -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') - } - } -}