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 { 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 { const { articlePublisher } = await import('./articlePublisher') 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 = mapDraftCategoryToTag(draft.category) 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, ...(invoice.paymentHash ? { paymentHash: invoice.paymentHash } : {}), ...(draft.category ? { 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 } } function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' { if (category === 'scientific-research') { return 'research' } return 'sciencefiction' } interface PublishPreviewWithInvoiceParams { draft: ArticleDraft invoice: AlbyInvoice authorPubkey: string presentationId: string extraTags?: string[][] customArticle?: Article } async function publishPreviewWithInvoice( params: PublishPreviewWithInvoiceParams ): Promise { // Build parsed article object (use custom article if provided, e.g., for updates with version) let article: Article let hash: string let version: number let index: number if (params.customArticle) { ;({ hash, version } = params.customArticle) article = params.customArticle index = params.customArticle.index ?? 0 } else { const built = await buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey) ;({ article, hash, version, index } = built) } // Build event template const previewEventTemplate = await createPreviewEvent({ draft: params.draft, invoice: params.invoice, authorPubkey: params.authorPubkey, authorPresentationId: params.presentationId, ...(params.extraTags ? { extraTags: params.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 { 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 ): 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 { ensureKeys(params.reviewerPubkey, params.authorPrivateKey) const {category} = params requireCategory(category) // 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 ): 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(params: { draft: ArticleDraft originalArticleId: string newCategory: 'sciencefiction' | 'research' authorPubkey: string currentVersion?: number }): Promise { // Generate hash ID from publication data const hashId = await generatePublicationHashId({ pubkey: params.authorPubkey, title: params.draft.title, preview: params.draft.preview, category: params.newCategory, seriesId: params.draft.seriesId ?? undefined, bannerUrl: params.draft.bannerUrl ?? undefined, zapAmount: params.draft.zapAmount, }) // Increment version for update const currentVersion = params.currentVersion ?? 0 const nextVersion = currentVersion + 1 const updateTags = buildTags({ type: 'publication', category: params.newCategory, id: hashId, service: PLATFORM_SERVICE, version: nextVersion, hidden: false, paywall: true, title: params.draft.title, preview: params.draft.preview, zapAmount: params.draft.zapAmount, ...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}), ...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}), }) updateTags.push(['e', params.originalArticleId], ['replace', 'article-update']) return updateTags } async function publishUpdate( draft: ArticleDraft, authorPubkey: string, originalArticleId: string ): Promise { const {category} = draft requireCategory(category) // Get original article from IndexedDB to retrieve current version const { objectCache } = await import('./objectCache') const originalArticle = await objectCache.getById('publication', originalArticleId) as Article | null if (!originalArticle) { return updateFailure(originalArticleId, 'Original article not found in cache') } // Verify user is the author if (originalArticle.pubkey !== authorPubkey) { return updateFailure(originalArticleId, 'Only the author can update this article') } const presentationId = await ensurePresentation(authorPubkey) const invoice = await createArticleInvoice(draft) const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research' // Use current version from original article const currentVersion = originalArticle.version ?? 0 const updateTags = await buildUpdateTags({ draft, originalArticleId, newCategory, authorPubkey, currentVersion, }) // Build parsed article with incremented version const { article } = await buildParsedArticleFromDraft(draft, invoice, authorPubkey) const updatedArticle: Article = { ...article, version: currentVersion + 1, // Increment version for update } const publishedEvent = await publishPreviewWithInvoice({ draft, invoice, authorPubkey, presentationId, extraTags: updateTags, customArticle: updatedArticle, }) if (!publishedEvent) { return updateFailure(originalArticleId, 'Failed to publish article update') } await storePrivateContent({ articleId: publishedEvent.id, content: 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 { 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 { ensureKeys(authorPubkey, authorPrivateKey) const originalEvent = await getOriginalPublicationEventOrThrow(articleId) assertAuthorOwnsEvent({ eventPubkey: originalEvent.pubkey, authorPubkey }) const deleteEventTemplate = await buildDeleteEventTemplateOrThrow({ originalEvent, authorPubkey }) const originalParsed = await parseOriginalArticleOrThrow(originalEvent) const deletePayload = await buildDeletedArticlePayload({ originalParsed, deleteEventTemplate }) const event = await finalizeEventTemplate({ template: deleteEventTemplate, authorPrivateKey }) const relays = await getActiveRelaysOrPrimary() await publishDeletion({ event, relays, payload: deletePayload }) } async function getOriginalPublicationEventOrThrow(articleId: string): Promise { const { objectCache } = await import('./objectCache') const originalEvent = await objectCache.getEventById('publication', articleId) if (!originalEvent) { throw new Error('Article not found in cache') } return originalEvent } function assertAuthorOwnsEvent(params: { eventPubkey: string; authorPubkey: string }): void { if (params.eventPubkey !== params.authorPubkey) { throw new Error('Only the author can delete this article') } } async function buildDeleteEventTemplateOrThrow(params: { originalEvent: import('nostr-tools').Event authorPubkey: string }): Promise { const { buildDeleteEvent } = await import('./objectModification') const template = await buildDeleteEvent(params.originalEvent, params.authorPubkey) if (!template) { throw new Error('Failed to build delete event') } return template } async function parseOriginalArticleOrThrow(originalEvent: import('nostr-tools').Event): Promise
{ const { parseArticleFromEvent } = await import('./nostrEventParsing') const parsed = await parseArticleFromEvent(originalEvent) if (!parsed) { throw new Error('Failed to parse original article') } return parsed } async function buildDeletedArticlePayload(params: { originalParsed: Article deleteEventTemplate: import('nostr-tools').EventTemplate }): Promise<{ hash: string; index: number; version: number; parsed: Article }> { const { extractTagsFromEvent } = await import('./nostrTagSystem') const tags = extractTagsFromEvent(params.deleteEventTemplate) const version = tags.version ?? params.originalParsed.version + 1 const index = params.originalParsed.index ?? 0 const parsed: Article = { ...params.originalParsed, version } return { hash: params.originalParsed.hash, index, version, parsed } } async function finalizeEventTemplate(params: { template: import('nostr-tools').EventTemplate authorPrivateKey: string | undefined }): Promise { const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey() if (!privateKey) { throw new Error('Private key required for signing') } const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator') writeOrchestratorInstance.setPrivateKey(privateKey) const { finalizeEvent: finalizeNostrEvent } = await import('nostr-tools') const { hexToBytes: hexToBytesUtil } = await import('nostr-tools/utils') const secretKey = hexToBytesUtil(privateKey) return finalizeNostrEvent(params.template, secretKey) } async function getActiveRelaysOrPrimary(): Promise { const { relaySessionManager } = await import('./relaySessionManager') const activeRelays = await relaySessionManager.getActiveRelays() if (activeRelays.length > 0) { return activeRelays } const { getPrimaryRelay } = await import('./config') return [await getPrimaryRelay()] } async function publishDeletion(params: { event: import('nostr-tools').Event relays: string[] payload: { hash: string; index: number; version: number; parsed: Article } }): Promise { const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator') const result = await writeOrchestratorInstance.writeAndPublish( { objectType: 'publication', hash: params.payload.hash, event: params.event, parsed: params.payload.parsed, version: params.payload.version, hidden: true, index: params.payload.index, }, params.relays ) if (!result.success) { throw new Error('Failed to publish delete event') } } // Re-export for convenience to avoid circular imports in hooks export { articlePublisher } from './articlePublisher' export const getStoredContent = getStoredPrivateContent