397 lines
12 KiB
TypeScript
397 lines
12 KiB
TypeScript
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
|
import { BaseService } from '@/core/base/BaseService'
|
|
import type { Order } from '@/modules/market/stores/market'
|
|
|
|
export interface NostrmarketStall {
|
|
id: string
|
|
name: string
|
|
description?: string
|
|
currency: string
|
|
shipping: Array<{
|
|
id: string
|
|
name: string
|
|
cost: number
|
|
countries: string[]
|
|
}>
|
|
}
|
|
|
|
export interface NostrmarketProduct {
|
|
id: string
|
|
stall_id: string
|
|
name: string
|
|
description?: string
|
|
images: string[]
|
|
categories: string[]
|
|
price: number
|
|
quantity: number
|
|
currency: string
|
|
}
|
|
|
|
// Note: Stall and Product publishing is handled by LNbits API endpoints
|
|
// NostrmarketService now only handles order DMs and status updates
|
|
|
|
export interface NostrmarketOrder {
|
|
id: string
|
|
items: Array<{
|
|
product_id: string
|
|
quantity: number
|
|
}>
|
|
contact: {
|
|
name: string
|
|
email?: string
|
|
phone?: string
|
|
}
|
|
address?: {
|
|
street: string
|
|
city: string
|
|
state: string
|
|
country: string
|
|
postal_code: string
|
|
}
|
|
shipping_id: string
|
|
}
|
|
|
|
export interface NostrmarketPaymentRequest {
|
|
type: 1
|
|
id: string
|
|
message?: string
|
|
payment_options: Array<{
|
|
type: string
|
|
link: string
|
|
}>
|
|
}
|
|
|
|
export interface NostrmarketOrderStatus {
|
|
type: 2
|
|
id: string
|
|
message?: string
|
|
paid?: boolean
|
|
shipped?: boolean
|
|
}
|
|
|
|
export class NostrmarketService extends BaseService {
|
|
// Service metadata
|
|
protected readonly metadata = {
|
|
name: 'NostrmarketService',
|
|
version: '1.0.0',
|
|
dependencies: ['RelayHub', 'AuthService']
|
|
}
|
|
|
|
/**
|
|
* Service-specific initialization (called by BaseService)
|
|
*/
|
|
protected async onInitialize(): Promise<void> {
|
|
this.debug('NostrmarketService initialized')
|
|
// Service doesn't need special initialization
|
|
}
|
|
|
|
/**
|
|
* Check if the service is ready for Nostr operations
|
|
*/
|
|
get isReady(): boolean {
|
|
try {
|
|
this.getAuth()
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert hex string to Uint8Array (browser-compatible)
|
|
*/
|
|
private hexToUint8Array(hex: string): Uint8Array {
|
|
const bytes = new Uint8Array(hex.length / 2)
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
private getAuth() {
|
|
// Use injected AuthService only
|
|
if (!this.authService?.isAuthenticated.value) {
|
|
throw new Error('User not authenticated')
|
|
}
|
|
|
|
const user = this.authService.user.value
|
|
const pubkey = user?.pubkey
|
|
const prvkey = user?.prvkey
|
|
|
|
if (!pubkey || !prvkey) {
|
|
this.debug('Auth check failed:', {
|
|
userExists: !!user,
|
|
hasPubkey: !!pubkey,
|
|
hasPrvkey: !!prvkey
|
|
})
|
|
throw new Error('Nostr keys not available. Please ensure your Nostr identity is configured in your profile.')
|
|
}
|
|
|
|
// Validate that we have proper hex strings
|
|
if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) {
|
|
throw new Error(`Invalid public key format: ${pubkey.substring(0, 10)}...`)
|
|
}
|
|
|
|
if (!/^[0-9a-fA-F]{64}$/.test(prvkey)) {
|
|
throw new Error(`Invalid private key format: ${prvkey.substring(0, 10)}...`)
|
|
}
|
|
|
|
console.log('🔑 Key debug:', {
|
|
pubkey: pubkey.substring(0, 10) + '...',
|
|
prvkey: prvkey.substring(0, 10) + '...',
|
|
pubkeyIsHex: /^[0-9a-fA-F]{64}$/.test(pubkey),
|
|
prvkeyIsHex: /^[0-9a-fA-F]{64}$/.test(prvkey),
|
|
pubkeyLength: pubkey.length,
|
|
prvkeyLength: prvkey.length,
|
|
pubkeyType: typeof pubkey,
|
|
prvkeyType: typeof prvkey,
|
|
pubkeyIsString: typeof pubkey === 'string',
|
|
prvkeyIsString: typeof prvkey === 'string'
|
|
})
|
|
|
|
return {
|
|
pubkey,
|
|
prvkey
|
|
}
|
|
}
|
|
|
|
// Removed publishStall() and publishProduct() methods
|
|
// Stall and product publishing is now handled by LNbits API endpoints
|
|
|
|
/**
|
|
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
|
*/
|
|
async publishOrder(order: Order, merchantPubkey: string): Promise<string> {
|
|
const { prvkey } = this.getAuth()
|
|
|
|
// Convert order to nostrmarket format - exactly matching the specification
|
|
const orderData = {
|
|
type: 0, // DirectMessageType.CUSTOMER_ORDER
|
|
id: order.id,
|
|
items: order.items.map(item => ({
|
|
product_id: item.productId,
|
|
quantity: item.quantity
|
|
})),
|
|
contact: {
|
|
name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown',
|
|
email: order.contactInfo?.email || ''
|
|
// Remove phone field - not in nostrmarket specification
|
|
},
|
|
// Only include address if it's a physical good and address is provided
|
|
...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? {
|
|
address: order.contactInfo.address
|
|
} : {}),
|
|
shipping_id: order.shippingZone?.id || 'online'
|
|
}
|
|
|
|
// Encrypt the message using NIP-04
|
|
console.log('🔐 NIP-04 encryption debug:', {
|
|
prvkeyType: typeof prvkey,
|
|
prvkeyIsString: typeof prvkey === 'string',
|
|
prvkeyLength: prvkey.length,
|
|
prvkeySample: prvkey.substring(0, 10) + '...',
|
|
merchantPubkeyType: typeof merchantPubkey,
|
|
merchantPubkeyLength: merchantPubkey.length,
|
|
orderDataString: JSON.stringify(orderData).substring(0, 50) + '...'
|
|
})
|
|
|
|
let encryptedContent: string
|
|
try {
|
|
encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData))
|
|
console.log('🔐 NIP-04 encryption successful:', {
|
|
encryptedContentLength: encryptedContent.length,
|
|
encryptedContentSample: encryptedContent.substring(0, 50) + '...'
|
|
})
|
|
} catch (error) {
|
|
console.error('🔐 NIP-04 encryption failed:', error)
|
|
throw error
|
|
}
|
|
|
|
const eventTemplate: EventTemplate = {
|
|
kind: 4, // Encrypted DM
|
|
tags: [['p', merchantPubkey]], // Recipient (merchant)
|
|
content: encryptedContent, // Use encrypted content
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
}
|
|
|
|
console.log('🔧 finalizeEvent debug:', {
|
|
prvkeyType: typeof prvkey,
|
|
prvkeyIsString: typeof prvkey === 'string',
|
|
prvkeyLength: prvkey.length,
|
|
prvkeySample: prvkey.substring(0, 10) + '...',
|
|
encodedPrvkeyType: typeof new TextEncoder().encode(prvkey),
|
|
encodedPrvkeyLength: new TextEncoder().encode(prvkey).length,
|
|
eventTemplate
|
|
})
|
|
|
|
// Convert hex string to Uint8Array properly
|
|
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
|
console.log('🔧 prvkeyBytes debug:', {
|
|
prvkeyBytesType: typeof prvkeyBytes,
|
|
prvkeyBytesLength: prvkeyBytes.length,
|
|
prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array
|
|
})
|
|
|
|
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
|
const result = await this.relayHub.publishEvent(event)
|
|
|
|
console.log('Order published to nostrmarket:', {
|
|
orderId: order.id,
|
|
eventId: result,
|
|
merchantPubkey,
|
|
content: orderData,
|
|
encryptedContent: encryptedContent.substring(0, 50) + '...'
|
|
})
|
|
|
|
return result.success.toString()
|
|
}
|
|
|
|
/**
|
|
* Handle incoming payment request from merchant (type 1)
|
|
*/
|
|
async handlePaymentRequest(paymentRequest: NostrmarketPaymentRequest): Promise<void> {
|
|
console.log('Received payment request from merchant:', {
|
|
orderId: paymentRequest.id,
|
|
message: paymentRequest.message,
|
|
paymentOptions: paymentRequest.payment_options
|
|
})
|
|
|
|
// Find the Lightning payment option
|
|
const lightningOption = paymentRequest.payment_options.find(option => option.type === 'ln')
|
|
if (!lightningOption) {
|
|
console.error('No Lightning payment option found in payment request')
|
|
return
|
|
}
|
|
|
|
// Update the order in the store with payment request
|
|
const { useMarketStore } = await import('../stores/market')
|
|
const marketStore = useMarketStore()
|
|
|
|
const order = Object.values(marketStore.orders).find(o =>
|
|
o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id
|
|
)
|
|
|
|
if (order) {
|
|
// Update order with payment request details
|
|
const updatedOrder = {
|
|
...order,
|
|
paymentRequest: lightningOption.link,
|
|
paymentStatus: 'pending' as const,
|
|
status: 'pending' as const, // Ensure status is pending for payment
|
|
updatedAt: Math.floor(Date.now() / 1000),
|
|
items: [...order.items], // Convert readonly to mutable
|
|
shippingZone: order.shippingZone ? {
|
|
...order.shippingZone,
|
|
countries: order.shippingZone.countries ? [...order.shippingZone.countries] : undefined
|
|
} : order.shippingZone
|
|
}
|
|
|
|
// Generate QR code for the payment request
|
|
try {
|
|
const QRCode = await import('qrcode')
|
|
const qrCodeDataUrl = await QRCode.toDataURL(lightningOption.link, {
|
|
width: 256,
|
|
margin: 2,
|
|
color: {
|
|
dark: '#000000',
|
|
light: '#FFFFFF'
|
|
}
|
|
})
|
|
updatedOrder.qrCodeDataUrl = qrCodeDataUrl
|
|
updatedOrder.qrCodeLoading = false
|
|
updatedOrder.qrCodeError = null
|
|
} catch (error) {
|
|
console.error('Failed to generate QR code:', error)
|
|
updatedOrder.qrCodeError = 'Failed to generate QR code'
|
|
updatedOrder.qrCodeLoading = false
|
|
}
|
|
|
|
marketStore.updateOrder(order.id, updatedOrder)
|
|
|
|
console.log('Order updated with payment request:', {
|
|
orderId: paymentRequest.id,
|
|
paymentRequest: lightningOption.link.substring(0, 50) + '...',
|
|
status: updatedOrder.status,
|
|
paymentStatus: updatedOrder.paymentStatus,
|
|
hasQRCode: !!updatedOrder.qrCodeDataUrl
|
|
})
|
|
|
|
// Debug: Check if the order was actually updated in the store
|
|
const verifyOrder = Object.values(marketStore.orders).find(o =>
|
|
o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id
|
|
)
|
|
console.log('Verified order in store after update:', {
|
|
found: !!verifyOrder,
|
|
hasPaymentRequest: !!verifyOrder?.paymentRequest,
|
|
paymentStatus: verifyOrder?.paymentStatus
|
|
})
|
|
} else {
|
|
console.warn('Payment request received for unknown order:', paymentRequest.id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle incoming order status update from merchant (type 2)
|
|
*/
|
|
async handleOrderStatusUpdate(statusUpdate: NostrmarketOrderStatus): Promise<void> {
|
|
console.log('Received order status update from merchant:', {
|
|
orderId: statusUpdate.id,
|
|
message: statusUpdate.message,
|
|
paid: statusUpdate.paid,
|
|
shipped: statusUpdate.shipped
|
|
})
|
|
|
|
const { useMarketStore } = await import('../stores/market')
|
|
const marketStore = useMarketStore()
|
|
|
|
const order = Object.values(marketStore.orders).find(o =>
|
|
o.id === statusUpdate.id || o.originalOrderId === statusUpdate.id
|
|
)
|
|
|
|
if (order) {
|
|
// Create the updated order object with all status updates
|
|
const updatedOrder = {
|
|
...order,
|
|
updatedAt: Math.floor(Date.now() / 1000),
|
|
items: [...order.items], // Convert readonly to mutable
|
|
shippingZone: order.shippingZone ? {
|
|
...order.shippingZone,
|
|
countries: order.shippingZone.countries ? [...order.shippingZone.countries] : undefined
|
|
} : order.shippingZone
|
|
}
|
|
|
|
// Update payment status
|
|
if (statusUpdate.paid !== undefined) {
|
|
updatedOrder.paid = statusUpdate.paid
|
|
updatedOrder.paymentStatus = (statusUpdate.paid ? 'paid' : 'pending') as 'paid' | 'pending' | 'expired'
|
|
updatedOrder.status = statusUpdate.paid ? 'paid' : 'pending'
|
|
updatedOrder.paidAt = statusUpdate.paid ? Math.floor(Date.now() / 1000) : undefined
|
|
}
|
|
|
|
// Update shipping status
|
|
if (statusUpdate.shipped !== undefined) {
|
|
updatedOrder.shipped = statusUpdate.shipped
|
|
if (statusUpdate.shipped && updatedOrder.status === 'paid') {
|
|
updatedOrder.status = 'shipped'
|
|
}
|
|
}
|
|
|
|
// Apply the update - this should trigger persistence
|
|
marketStore.updateOrder(order.id, updatedOrder)
|
|
|
|
console.log('Order status updated and persisted:', {
|
|
orderId: statusUpdate.id,
|
|
paid: updatedOrder.paid,
|
|
shipped: updatedOrder.shipped,
|
|
paymentStatus: updatedOrder.paymentStatus,
|
|
status: updatedOrder.status,
|
|
paidAt: updatedOrder.paidAt
|
|
})
|
|
} else {
|
|
console.warn('Status update received for unknown order:', statusUpdate.id)
|
|
}
|
|
}
|
|
|
|
// Removed publishMerchantCatalog() method
|
|
// Publishing is now handled by LNbits API endpoints
|
|
}
|