- Création lib/platformCommissions.ts : configuration centralisée des commissions - Articles : 800 sats (700 auteur, 100 plateforme) - Avis : 70 sats (49 lecteur, 21 plateforme) - Sponsoring : 0.046 BTC (0.042 auteur, 0.004 plateforme) - Validation des montants à chaque étape : - Publication : vérification du montant avant publication - Paiement : vérification du montant avant acceptation - Erreurs explicites si montant incorrect - Tracking des commissions sur Nostr : - Tags author_amount et platform_commission dans événements - Interface ContentDeliveryTracking étendue - Traçabilité complète pour audit - Logs structurés avec informations de commission - Documentation complète du système Les commissions sont maintenant systématiques, validées et traçables.
291 lines
9.1 KiB
TypeScript
291 lines
9.1 KiB
TypeScript
import { nostrService } from './nostr'
|
|
import type { AlbyInvoice } from '@/types/alby'
|
|
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
|
import type { MediaRef } from '@/types/nostr'
|
|
import {
|
|
storePrivateContent,
|
|
getStoredPrivateContent,
|
|
getStoredInvoice,
|
|
removeStoredPrivateContent,
|
|
} from './articleStorage'
|
|
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
|
|
import { buildPresentationEvent, fetchAuthorPresentationFromPool, sendEncryptedContent } from './articlePublisherHelpers'
|
|
|
|
export interface ArticleDraft {
|
|
title: string
|
|
preview: string
|
|
content: string // Full content that will be sent as private message after payment
|
|
zapAmount: number
|
|
category?: 'science-fiction' | 'scientific-research'
|
|
seriesId?: string
|
|
bannerUrl?: string
|
|
media?: MediaRef[]
|
|
}
|
|
|
|
export interface AuthorPresentationDraft {
|
|
title: string
|
|
preview: string
|
|
content: string
|
|
presentation: string
|
|
contentDescription: string
|
|
mainnetAddress: string
|
|
}
|
|
|
|
export interface PublishedArticle {
|
|
articleId: string
|
|
previewEventId: string
|
|
invoice?: AlbyInvoice // Invoice created by author (required if success)
|
|
success: boolean
|
|
error?: string
|
|
}
|
|
|
|
/**
|
|
* Service for publishing articles on Nostr
|
|
* Handles publishing preview (public note), creating invoice, and storing full content for later private message
|
|
*/
|
|
export class ArticlePublisher {
|
|
private readonly siteTag = process.env.NEXT_PUBLIC_SITE_TAG ?? 'zapwall4science'
|
|
|
|
private buildFailure(error?: string): PublishedArticle {
|
|
const base: PublishedArticle = {
|
|
articleId: '',
|
|
previewEventId: '',
|
|
success: false,
|
|
}
|
|
return error ? { ...base, error } : base
|
|
}
|
|
|
|
private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
|
|
nostrService.setPublicKey(authorPubkey)
|
|
|
|
if (authorPrivateKey) {
|
|
nostrService.setPrivateKey(authorPrivateKey)
|
|
return { success: true }
|
|
}
|
|
|
|
const existingPrivateKey = nostrService.getPrivateKey()
|
|
if (!existingPrivateKey) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
'Private key required for signing. Please connect with a Nostr wallet that provides signing capabilities.',
|
|
}
|
|
}
|
|
|
|
return { success: true }
|
|
}
|
|
|
|
private isValidCategory(category?: ArticleDraft['category']): category is NonNullable<ArticleDraft['category']> {
|
|
return category === 'science-fiction' || category === 'scientific-research'
|
|
}
|
|
|
|
private async publishPreview(
|
|
draft: ArticleDraft,
|
|
invoice: AlbyInvoice,
|
|
presentationId: string,
|
|
extraTags?: string[][]
|
|
): Promise<import('nostr-tools').Event | null> {
|
|
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags)
|
|
const publishedEvent = await nostrService.publishEvent(previewEvent)
|
|
return publishedEvent ?? null
|
|
}
|
|
|
|
private buildArticleExtraTags(draft: ArticleDraft, category: NonNullable<ArticleDraft['category']>): string[][] {
|
|
const extraTags: string[][] = [
|
|
['kind_type', 'article'],
|
|
['site', this.siteTag],
|
|
['category', category],
|
|
]
|
|
if (draft.seriesId) {
|
|
extraTags.push(['series', draft.seriesId])
|
|
}
|
|
if (draft.bannerUrl) {
|
|
extraTags.push(['banner', draft.bannerUrl])
|
|
}
|
|
if (draft.media && draft.media.length > 0) {
|
|
draft.media.forEach((m) => {
|
|
extraTags.push(['media', m.url, m.type])
|
|
})
|
|
}
|
|
return extraTags
|
|
}
|
|
|
|
/**
|
|
* Publish an article preview as a public note (kind:1)
|
|
* Creates a Lightning invoice for the article
|
|
* The full content will be sent as encrypted private message after payment
|
|
*/
|
|
async publishArticle(
|
|
draft: ArticleDraft,
|
|
authorPubkey: string,
|
|
authorPrivateKey?: string
|
|
): Promise<PublishedArticle> {
|
|
try {
|
|
const keySetup = this.prepareAuthorKeys(authorPubkey, authorPrivateKey)
|
|
if (!keySetup.success) {
|
|
return this.buildFailure(keySetup.error)
|
|
}
|
|
|
|
const presentation = await this.getAuthorPresentation(authorPubkey)
|
|
if (!presentation) {
|
|
return this.buildFailure('Vous devez créer un article de présentation avant de publier des articles.')
|
|
}
|
|
|
|
if (!this.isValidCategory(draft.category)) {
|
|
return this.buildFailure('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
|
|
}
|
|
const category = draft.category
|
|
|
|
// Verify zap amount matches expected commission structure
|
|
const expectedAmount = 800 // PLATFORM_COMMISSIONS.article.total
|
|
if (draft.zapAmount !== expectedAmount) {
|
|
return this.buildFailure(
|
|
`Invalid zap amount: ${draft.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`
|
|
)
|
|
}
|
|
|
|
const invoice = await createArticleInvoice(draft)
|
|
const extraTags = this.buildArticleExtraTags(draft, category)
|
|
const publishedEvent = await this.publishPreview(draft, invoice, presentation.id, extraTags)
|
|
if (!publishedEvent) {
|
|
return this.buildFailure('Failed to publish article')
|
|
}
|
|
|
|
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
|
|
|
|
return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true }
|
|
} catch (error) {
|
|
console.error('Error publishing article:', error)
|
|
return this.buildFailure(error instanceof Error ? error.message : 'Unknown error')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update an existing article by publishing a new event that references the original
|
|
*/
|
|
/**
|
|
* Get stored private content for an article
|
|
*/
|
|
getStoredPrivateContent(articleId: string): Promise<{
|
|
content: string
|
|
authorPubkey: string
|
|
invoice?: AlbyInvoice
|
|
} | null> {
|
|
return getStoredPrivateContent(articleId)
|
|
}
|
|
|
|
/**
|
|
* Get stored invoice for an article
|
|
*/
|
|
getStoredInvoice(articleId: string): Promise<AlbyInvoice | null> {
|
|
return getStoredInvoice(articleId)
|
|
}
|
|
|
|
/**
|
|
* Send private content to a user after payment confirmation
|
|
* Returns detailed result with message event ID and verification status
|
|
*/
|
|
async sendPrivateContent(
|
|
articleId: string,
|
|
recipientPubkey: string,
|
|
authorPrivateKey: string
|
|
): Promise<import('./articlePublisherHelpers').SendContentResult> {
|
|
try {
|
|
const stored = await getStoredPrivateContent(articleId)
|
|
if (!stored) {
|
|
const error = 'Private content not found for article'
|
|
console.error(error, { articleId, recipientPubkey })
|
|
return {
|
|
success: false,
|
|
error,
|
|
}
|
|
}
|
|
|
|
const result = await sendEncryptedContent(articleId, recipientPubkey, stored, authorPrivateKey)
|
|
|
|
if (result.success) {
|
|
console.log('Private content sent successfully', {
|
|
articleId,
|
|
recipientPubkey,
|
|
messageEventId: result.messageEventId,
|
|
verified: result.verified,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
} else {
|
|
console.error('Failed to send private content', {
|
|
articleId,
|
|
recipientPubkey,
|
|
error: result.error,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
}
|
|
|
|
return result
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
console.error('Error sending private content', {
|
|
articleId,
|
|
recipientPubkey,
|
|
error: errorMessage,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove stored private content (after successful send or expiry)
|
|
*/
|
|
async removeStoredPrivateContent(articleId: string): Promise<void> {
|
|
await removeStoredPrivateContent(articleId)
|
|
}
|
|
|
|
/**
|
|
* Publish an author presentation article (obligatory for all authors)
|
|
* This article is free and contains the author's presentation, content description, and mainnet address
|
|
*/
|
|
async publishPresentationArticle(
|
|
draft: AuthorPresentationDraft,
|
|
authorPubkey: string,
|
|
authorPrivateKey: string
|
|
): Promise<PublishedArticle> {
|
|
try {
|
|
nostrService.setPublicKey(authorPubkey)
|
|
nostrService.setPrivateKey(authorPrivateKey)
|
|
|
|
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft))
|
|
|
|
if (!publishedEvent) {
|
|
return this.buildFailure('Failed to publish presentation article')
|
|
}
|
|
|
|
return {
|
|
articleId: publishedEvent.id,
|
|
previewEventId: publishedEvent.id,
|
|
success: true,
|
|
}
|
|
} catch (error) {
|
|
console.error('Error publishing presentation article:', error)
|
|
return this.buildFailure(error instanceof Error ? error.message : 'Unknown error')
|
|
}
|
|
}
|
|
|
|
async getAuthorPresentation(pubkey: string): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
|
try {
|
|
const pool = nostrService.getPool()
|
|
if (!pool) {
|
|
return null
|
|
}
|
|
return await fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey)
|
|
} catch (error) {
|
|
console.error('Error getting author presentation:', error)
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
export const articlePublisher = new ArticlePublisher()
|