story-research-zapwall/lib/articleMutations.ts
2026-01-10 09:41:57 +01:00

730 lines
22 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 { 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<import('nostr-tools').Event | null> {
// 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<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)
// 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(params: {
draft: ArticleDraft
originalArticleId: string
newCategory: 'sciencefiction' | 'research'
authorPubkey: string
currentVersion?: number
}): Promise<string[][]> {
// 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<ArticleUpdateResult> {
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<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 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<import('nostr-tools').Event> {
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<import('nostr-tools').EventTemplate> {
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<Article> {
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<import('nostr-tools').Event> {
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<string[]> {
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<void> {
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