From 772a2131e6e020779e8f04a50cb4b4757b4be3c7 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 13 Feb 2026 12:53:19 -0500 Subject: [PATCH] feat(extensions): add NIP-15 marketplace extension Implements a Nostr-native marketplace extension for Lightning.Pub that is compatible with NIP-15 (Nostr Marketplace). Features: - Stall management (kind 30017 events) - Product listings (kind 30018 events) - Order processing via encrypted Nostr DMs or RPC - Multi-currency support with exchange rate caching - Inventory management with stock tracking - Customer relationship management - Lightning payment integration The extension leverages Lightning.Pub's existing Nostr infrastructure for a more elegant implementation than traditional HTTP-based approaches. Co-Authored-By: Claude Opus 4.5 --- src/extensions/marketplace/index.ts | 390 +++++++++++ .../marketplace/managers/messageManager.ts | 497 ++++++++++++++ .../marketplace/managers/orderManager.ts | 607 ++++++++++++++++++ .../marketplace/managers/productManager.ts | 466 ++++++++++++++ .../marketplace/managers/stallManager.ts | 305 +++++++++ src/extensions/marketplace/migrations.ts | 194 ++++++ src/extensions/marketplace/nostr/events.ts | 164 +++++ src/extensions/marketplace/nostr/kinds.ts | 31 + src/extensions/marketplace/nostr/parser.ts | 162 +++++ src/extensions/marketplace/types.ts | 418 ++++++++++++ src/extensions/marketplace/utils/currency.ts | 205 ++++++ .../marketplace/utils/validation.ts | 109 ++++ 12 files changed, 3548 insertions(+) create mode 100644 src/extensions/marketplace/index.ts create mode 100644 src/extensions/marketplace/managers/messageManager.ts create mode 100644 src/extensions/marketplace/managers/orderManager.ts create mode 100644 src/extensions/marketplace/managers/productManager.ts create mode 100644 src/extensions/marketplace/managers/stallManager.ts create mode 100644 src/extensions/marketplace/migrations.ts create mode 100644 src/extensions/marketplace/nostr/events.ts create mode 100644 src/extensions/marketplace/nostr/kinds.ts create mode 100644 src/extensions/marketplace/nostr/parser.ts create mode 100644 src/extensions/marketplace/types.ts create mode 100644 src/extensions/marketplace/utils/currency.ts create mode 100644 src/extensions/marketplace/utils/validation.ts diff --git a/src/extensions/marketplace/index.ts b/src/extensions/marketplace/index.ts new file mode 100644 index 00000000..0bd470e3 --- /dev/null +++ b/src/extensions/marketplace/index.ts @@ -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 { + // 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 new file mode 100644 index 00000000..a70d0700 --- /dev/null +++ b/src/extensions/marketplace/managers/messageManager.ts @@ -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 { + // 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 new file mode 100644 index 00000000..30d8c8d9 --- /dev/null +++ b/src/extensions/marketplace/managers/orderManager.ts @@ -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 { + // 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 new file mode 100644 index 00000000..f5321bb4 --- /dev/null +++ b/src/extensions/marketplace/managers/productManager.ts @@ -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 { + // 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 new file mode 100644 index 00000000..a77809c3 --- /dev/null +++ b/src/extensions/marketplace/managers/stallManager.ts @@ -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 { + // 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 new file mode 100644 index 00000000..5c1ff1c7 --- /dev/null +++ b/src/extensions/marketplace/migrations.ts @@ -0,0 +1,194 @@ +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 new file mode 100644 index 00000000..b4c476b4 --- /dev/null +++ b/src/extensions/marketplace/nostr/events.ts @@ -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 + } +} diff --git a/src/extensions/marketplace/nostr/kinds.ts b/src/extensions/marketplace/nostr/kinds.ts new file mode 100644 index 00000000..20d26914 --- /dev/null +++ b/src/extensions/marketplace/nostr/kinds.ts @@ -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] diff --git a/src/extensions/marketplace/nostr/parser.ts b/src/extensions/marketplace/nostr/parser.ts new file mode 100644 index 00000000..f90a1aa3 --- /dev/null +++ b/src/extensions/marketplace/nostr/parser.ts @@ -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 | 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 new file mode 100644 index 00000000..7559d947 --- /dev/null +++ b/src/extensions/marketplace/types.ts @@ -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 & { 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 new file mode 100644 index 00000000..7bb2dd14 --- /dev/null +++ b/src/extensions/marketplace/utils/currency.ts @@ -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() + +/** + * 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 new file mode 100644 index 00000000..549b59d4 --- /dev/null +++ b/src/extensions/marketplace/utils/validation.ts @@ -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') + } + } +}