565 lines
16 KiB
TypeScript
565 lines
16 KiB
TypeScript
import { nostrService } from './nostr'
|
|
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
|
|
import { storePrivateContent, getStoredPrivateContent } from './articleStorage'
|
|
import { buildTags } from './nostrTagSystem'
|
|
import { PLATFORM_SERVICE } from './platformConfig'
|
|
import { generateSeriesHashId, generatePublicationHashId } from './hashIdGenerator'
|
|
import { buildObjectId } from './urlGenerator'
|
|
import type { ArticleDraft, PublishedArticle } from './articlePublisher'
|
|
import type { AlbyInvoice } from '@/types/alby'
|
|
import type { Article, Review, Series } from '@/types/nostr'
|
|
import { writeOrchestrator } from './writeOrchestrator'
|
|
import { finalizeEvent } from 'nostr-tools'
|
|
import { hexToBytes } from 'nostr-tools/utils'
|
|
|
|
export interface ArticleUpdateResult extends PublishedArticle {
|
|
originalArticleId: string
|
|
}
|
|
|
|
function ensureKeys(authorPubkey: string, authorPrivateKey?: string): void {
|
|
nostrService.setPublicKey(authorPubkey)
|
|
if (authorPrivateKey) {
|
|
nostrService.setPrivateKey(authorPrivateKey)
|
|
} else if (!nostrService.getPrivateKey()) {
|
|
throw new Error('Private key required for signing. Connect a wallet that can sign.')
|
|
}
|
|
}
|
|
|
|
function requireCategory(category?: ArticleDraft['category']): asserts category is NonNullable<ArticleDraft['category']> {
|
|
if (category !== 'science-fiction' && category !== 'scientific-research') {
|
|
throw new Error('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
|
|
}
|
|
}
|
|
|
|
async function ensurePresentation(authorPubkey: string): Promise<string> {
|
|
const presentation = await articlePublisher.getAuthorPresentation(authorPubkey)
|
|
if (!presentation) {
|
|
throw new Error('Vous devez créer un article de présentation avant de publier des articles.')
|
|
}
|
|
return presentation.id
|
|
}
|
|
|
|
async function buildParsedArticleFromDraft(
|
|
draft: ArticleDraft,
|
|
invoice: AlbyInvoice,
|
|
authorPubkey: string
|
|
): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
|
const category = draft.category === 'science-fiction' ? 'sciencefiction' : draft.category === 'scientific-research' ? 'research' : 'sciencefiction'
|
|
|
|
const hashId = await generatePublicationHashId({
|
|
pubkey: authorPubkey,
|
|
title: draft.title,
|
|
preview: draft.preview,
|
|
category,
|
|
seriesId: draft.seriesId ?? undefined,
|
|
bannerUrl: draft.bannerUrl ?? undefined,
|
|
zapAmount: draft.zapAmount,
|
|
})
|
|
|
|
const hash = hashId
|
|
const version = 0
|
|
const index = 0
|
|
const id = buildObjectId(hash, index, version)
|
|
|
|
const article: Article = {
|
|
id,
|
|
hash,
|
|
version,
|
|
index,
|
|
pubkey: authorPubkey,
|
|
title: draft.title,
|
|
preview: draft.preview,
|
|
content: '',
|
|
description: draft.preview,
|
|
contentDescription: draft.preview,
|
|
createdAt: Math.floor(Date.now() / 1000),
|
|
zapAmount: draft.zapAmount,
|
|
paid: false,
|
|
thumbnailUrl: draft.bannerUrl ?? '',
|
|
invoice: invoice.invoice,
|
|
paymentHash: invoice.paymentHash ?? undefined,
|
|
category: draft.category,
|
|
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
|
|
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
|
|
...(draft.pages && draft.pages.length > 0 ? { pages: draft.pages } : {}),
|
|
kindType: 'article',
|
|
}
|
|
|
|
return { article, hash, version, index }
|
|
}
|
|
|
|
async function publishPreviewWithInvoice(
|
|
draft: ArticleDraft,
|
|
invoice: AlbyInvoice,
|
|
authorPubkey: string,
|
|
presentationId: string,
|
|
extraTags?: string[][]
|
|
): Promise<import('nostr-tools').Event | null> {
|
|
// Build parsed article object
|
|
const { article, hash, version, index } = await buildParsedArticleFromDraft(draft, invoice, authorPubkey)
|
|
|
|
// Build event template
|
|
const previewEventTemplate = await createPreviewEvent(draft, invoice, authorPubkey, presentationId, extraTags)
|
|
|
|
// Set private key in orchestrator
|
|
const privateKey = nostrService.getPrivateKey()
|
|
if (!privateKey) {
|
|
throw new Error('Private key required for signing')
|
|
}
|
|
writeOrchestrator.setPrivateKey(privateKey)
|
|
|
|
// Finalize event
|
|
const secretKey = hexToBytes(privateKey)
|
|
const event = finalizeEvent(previewEventTemplate, secretKey)
|
|
|
|
// Get active relays
|
|
const { relaySessionManager } = await import('./relaySessionManager')
|
|
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
const { getPrimaryRelay } = await import('./config')
|
|
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
|
|
|
// Publish via writeOrchestrator (parallel network + local write)
|
|
const result = await writeOrchestrator.writeAndPublish(
|
|
{
|
|
objectType: 'publication',
|
|
hash,
|
|
event,
|
|
parsed: article,
|
|
version,
|
|
hidden: false,
|
|
index,
|
|
},
|
|
relays
|
|
)
|
|
|
|
if (!result.success) {
|
|
return null
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
export async function publishSeries(params: {
|
|
title: string
|
|
description: string
|
|
preview?: string
|
|
coverUrl?: string
|
|
category: ArticleDraft['category']
|
|
authorPubkey: string
|
|
authorPrivateKey?: string
|
|
}): Promise<Series> {
|
|
ensureKeys(params.authorPubkey, params.authorPrivateKey)
|
|
const {category} = params
|
|
requireCategory(category)
|
|
|
|
// Map category to new system
|
|
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
|
|
|
// Generate hash ID from series data
|
|
const hashId = await generateSeriesHashId({
|
|
pubkey: params.authorPubkey,
|
|
title: params.title,
|
|
description: params.description,
|
|
category: newCategory,
|
|
coverUrl: params.coverUrl ?? undefined,
|
|
})
|
|
|
|
const hash = hashId
|
|
const version = 0
|
|
const index = 0
|
|
const id = buildObjectId(hash, index, version)
|
|
|
|
// Build parsed Series object
|
|
const parsedSeries: Series = {
|
|
id,
|
|
hash,
|
|
version,
|
|
index,
|
|
pubkey: params.authorPubkey,
|
|
title: params.title,
|
|
description: params.description,
|
|
preview: params.preview ?? params.description.substring(0, 200),
|
|
thumbnailUrl: params.coverUrl ?? '',
|
|
category,
|
|
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
|
kindType: 'series',
|
|
}
|
|
|
|
// Build event template
|
|
const eventTemplate = await buildSeriesEvent(params, category)
|
|
|
|
// Set private key in orchestrator
|
|
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
|
|
if (!privateKey) {
|
|
throw new Error('Private key required for signing')
|
|
}
|
|
writeOrchestrator.setPrivateKey(privateKey)
|
|
|
|
// Finalize event
|
|
const secretKey = hexToBytes(privateKey)
|
|
const event = finalizeEvent(eventTemplate, secretKey)
|
|
|
|
// Get active relays
|
|
const { relaySessionManager } = await import('./relaySessionManager')
|
|
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
const { getPrimaryRelay } = await import('./config')
|
|
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
|
|
|
// Publish via writeOrchestrator (parallel network + local write)
|
|
const result = await writeOrchestrator.writeAndPublish(
|
|
{
|
|
objectType: 'series',
|
|
hash,
|
|
event,
|
|
parsed: parsedSeries,
|
|
version,
|
|
hidden: false,
|
|
index,
|
|
},
|
|
relays
|
|
)
|
|
|
|
if (!result.success) {
|
|
throw new Error('Failed to publish series')
|
|
}
|
|
|
|
return parsedSeries
|
|
}
|
|
|
|
async function buildSeriesEvent(
|
|
params: {
|
|
title: string
|
|
description: string
|
|
preview?: string
|
|
coverUrl?: string
|
|
authorPubkey: string
|
|
},
|
|
category: NonNullable<ArticleDraft['category']>
|
|
): Promise<{
|
|
kind: number
|
|
created_at: number
|
|
content: string
|
|
tags: string[][]
|
|
}> {
|
|
// Map category to new system
|
|
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
|
|
|
// Generate hash ID from series data
|
|
const hashId = await generateSeriesHashId({
|
|
pubkey: params.authorPubkey,
|
|
title: params.title,
|
|
description: params.description,
|
|
category: newCategory,
|
|
coverUrl: params.coverUrl ?? undefined,
|
|
})
|
|
|
|
// Build JSON metadata
|
|
const seriesJson = JSON.stringify({
|
|
type: 'series',
|
|
pubkey: params.authorPubkey,
|
|
title: params.title,
|
|
description: params.description,
|
|
preview: params.preview ?? params.description.substring(0, 200),
|
|
coverUrl: params.coverUrl,
|
|
category: newCategory,
|
|
id: hashId,
|
|
version: 0,
|
|
index: 0,
|
|
})
|
|
|
|
const tags = buildTags({
|
|
type: 'series',
|
|
category: newCategory,
|
|
id: hashId,
|
|
service: PLATFORM_SERVICE,
|
|
version: 0, // New object
|
|
hidden: false,
|
|
paywall: false,
|
|
title: params.title,
|
|
description: params.description,
|
|
preview: params.preview ?? params.description.substring(0, 200),
|
|
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
|
})
|
|
|
|
// Add JSON metadata as a tag
|
|
tags.push(['json', seriesJson])
|
|
|
|
return {
|
|
kind: 1,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
content: params.preview ?? params.description.substring(0, 200),
|
|
tags,
|
|
}
|
|
}
|
|
|
|
export async function publishReview(params: {
|
|
articleId: string
|
|
seriesId: string
|
|
category: ArticleDraft['category']
|
|
authorPubkey: string
|
|
reviewerPubkey: string
|
|
content: string
|
|
title?: string
|
|
text?: string
|
|
authorPrivateKey?: string
|
|
}): Promise<Review> {
|
|
ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
|
|
const {category} = params
|
|
requireCategory(category)
|
|
|
|
// Map category to new system
|
|
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
|
|
|
// Generate hash ID from review data
|
|
const { generateReviewHashId } = await import('./hashIdGenerator')
|
|
const hashId = await generateReviewHashId({
|
|
pubkey: params.reviewerPubkey,
|
|
articleId: params.articleId,
|
|
reviewerPubkey: params.reviewerPubkey,
|
|
content: params.content,
|
|
...(params.title ? { title: params.title } : {}),
|
|
})
|
|
|
|
const hash = hashId
|
|
const version = 0
|
|
const index = 0
|
|
const id = buildObjectId(hash, index, version)
|
|
|
|
// Build parsed Review object
|
|
const parsedReview: Review = {
|
|
id,
|
|
hash,
|
|
version,
|
|
index,
|
|
articleId: params.articleId,
|
|
authorPubkey: params.authorPubkey,
|
|
reviewerPubkey: params.reviewerPubkey,
|
|
content: params.content,
|
|
description: params.content.substring(0, 200),
|
|
createdAt: Math.floor(Date.now() / 1000),
|
|
...(params.title ? { title: params.title } : {}),
|
|
...(params.text ? { text: params.text } : {}),
|
|
kindType: 'review',
|
|
}
|
|
|
|
// Build event template
|
|
const eventTemplate = await buildReviewEvent(params, category)
|
|
|
|
// Set private key in orchestrator
|
|
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
|
|
if (!privateKey) {
|
|
throw new Error('Private key required for signing')
|
|
}
|
|
writeOrchestrator.setPrivateKey(privateKey)
|
|
|
|
// Finalize event
|
|
const secretKey = hexToBytes(privateKey)
|
|
const event = finalizeEvent(eventTemplate, secretKey)
|
|
|
|
// Get active relays
|
|
const { relaySessionManager } = await import('./relaySessionManager')
|
|
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
const { getPrimaryRelay } = await import('./config')
|
|
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
|
|
|
// Publish via writeOrchestrator (parallel network + local write)
|
|
const result = await writeOrchestrator.writeAndPublish(
|
|
{
|
|
objectType: 'review',
|
|
hash,
|
|
event,
|
|
parsed: parsedReview,
|
|
version,
|
|
hidden: false,
|
|
index,
|
|
},
|
|
relays
|
|
)
|
|
|
|
if (!result.success) {
|
|
throw new Error('Failed to publish review')
|
|
}
|
|
|
|
return parsedReview
|
|
}
|
|
|
|
async function buildReviewEvent(
|
|
params: {
|
|
articleId: string
|
|
seriesId: string
|
|
authorPubkey: string
|
|
reviewerPubkey: string
|
|
content: string
|
|
title?: string
|
|
text?: string
|
|
},
|
|
category: NonNullable<ArticleDraft['category']>
|
|
): Promise<{
|
|
kind: number
|
|
created_at: number
|
|
content: string
|
|
tags: string[][]
|
|
}> {
|
|
// Map category to new system
|
|
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
|
|
|
// Generate hash ID from review data
|
|
const { generateReviewHashId } = await import('./hashIdGenerator')
|
|
const hashId = await generateReviewHashId({
|
|
pubkey: params.reviewerPubkey,
|
|
articleId: params.articleId,
|
|
reviewerPubkey: params.reviewerPubkey,
|
|
content: params.content,
|
|
...(params.title ? { title: params.title } : {}),
|
|
})
|
|
|
|
// Build JSON metadata
|
|
const reviewJson = JSON.stringify({
|
|
type: 'review',
|
|
pubkey: params.reviewerPubkey,
|
|
articleId: params.articleId,
|
|
reviewerPubkey: params.reviewerPubkey,
|
|
content: params.content,
|
|
title: params.title,
|
|
category: newCategory,
|
|
id: hashId,
|
|
version: 0,
|
|
index: 0,
|
|
})
|
|
|
|
const tags = buildTags({
|
|
type: 'quote',
|
|
category: newCategory,
|
|
id: hashId,
|
|
service: PLATFORM_SERVICE,
|
|
version: 0, // New object
|
|
hidden: false,
|
|
paywall: false,
|
|
articleId: params.articleId,
|
|
reviewerPubkey: params.reviewerPubkey,
|
|
...(params.title ? { title: params.title } : {}),
|
|
})
|
|
|
|
// Add text tag if provided
|
|
if (params.text) {
|
|
tags.push(['text', params.text])
|
|
}
|
|
|
|
// Add JSON metadata as a tag
|
|
tags.push(['json', reviewJson])
|
|
|
|
return {
|
|
kind: 1,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
content: params.content,
|
|
tags,
|
|
}
|
|
}
|
|
|
|
async function buildUpdateTags(
|
|
draft: ArticleDraft,
|
|
originalArticleId: string,
|
|
newCategory: 'sciencefiction' | 'research',
|
|
authorPubkey: string,
|
|
currentVersion: number = 0
|
|
): Promise<string[][]> {
|
|
// Generate hash ID from publication data
|
|
const hashId = await generatePublicationHashId({
|
|
pubkey: authorPubkey,
|
|
title: draft.title,
|
|
preview: draft.preview,
|
|
category: newCategory,
|
|
seriesId: draft.seriesId ?? undefined,
|
|
bannerUrl: draft.bannerUrl ?? undefined,
|
|
zapAmount: draft.zapAmount,
|
|
})
|
|
|
|
// Increment version for update
|
|
const nextVersion = currentVersion + 1
|
|
|
|
const updateTags = buildTags({
|
|
type: 'publication',
|
|
category: newCategory,
|
|
id: hashId,
|
|
service: PLATFORM_SERVICE,
|
|
version: nextVersion,
|
|
hidden: false,
|
|
paywall: true,
|
|
title: draft.title,
|
|
preview: draft.preview,
|
|
zapAmount: draft.zapAmount,
|
|
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
|
|
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
|
|
})
|
|
updateTags.push(['e', originalArticleId], ['replace', 'article-update'])
|
|
return updateTags
|
|
}
|
|
|
|
async function publishUpdate(
|
|
draft: ArticleDraft,
|
|
authorPubkey: string,
|
|
originalArticleId: string
|
|
): Promise<ArticleUpdateResult> {
|
|
const {category} = draft
|
|
requireCategory(category)
|
|
const presentationId = await ensurePresentation(authorPubkey)
|
|
const invoice = await createArticleInvoice(draft)
|
|
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
|
const updateTags = await buildUpdateTags(draft, originalArticleId, newCategory, authorPubkey)
|
|
|
|
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, authorPubkey, presentationId, updateTags)
|
|
if (!publishedEvent) {
|
|
return updateFailure(originalArticleId, 'Failed to publish article update')
|
|
}
|
|
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
|
|
return {
|
|
articleId: publishedEvent.id,
|
|
previewEventId: publishedEvent.id,
|
|
invoice,
|
|
success: true,
|
|
originalArticleId,
|
|
}
|
|
}
|
|
|
|
export async function publishArticleUpdate(
|
|
originalArticleId: string,
|
|
draft: ArticleDraft,
|
|
authorPubkey: string,
|
|
authorPrivateKey?: string
|
|
): Promise<ArticleUpdateResult> {
|
|
try {
|
|
ensureKeys(authorPubkey, authorPrivateKey)
|
|
return publishUpdate(draft, authorPubkey, originalArticleId)
|
|
} catch (error) {
|
|
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
|
|
}
|
|
}
|
|
|
|
function updateFailure(originalArticleId: string, error?: string): ArticleUpdateResult {
|
|
return {
|
|
articleId: '',
|
|
previewEventId: '',
|
|
success: false,
|
|
originalArticleId,
|
|
...(error ? { error } : {}),
|
|
}
|
|
}
|
|
|
|
export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise<void> {
|
|
ensureKeys(authorPubkey, authorPrivateKey)
|
|
const deleteEvent = {
|
|
kind: 5,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [['e', articleId]] as string[][],
|
|
content: 'deleted',
|
|
} as const
|
|
const published = await nostrService.publishEvent(deleteEvent)
|
|
if (!published) {
|
|
throw new Error('Failed to publish delete event')
|
|
}
|
|
}
|
|
|
|
// Re-export for convenience to avoid circular imports in hooks
|
|
import { articlePublisher } from './articlePublisher'
|
|
export const getStoredContent = getStoredPrivateContent
|