From c161b4902b54c19c3fb5877f99c3ee8c17572046 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 14 Apr 2026 08:33:37 +0200 Subject: [PATCH] fix: allow retrying failed ama publishes --- cms/schemaTypes/ama.ts | 12 +-- server/api/publish-ama.post.ts | 160 +++++++++++++++++++++++++-------- test/unit/bundle.spec.ts | 2 +- 3 files changed, 129 insertions(+), 45 deletions(-) diff --git a/cms/schemaTypes/ama.ts b/cms/schemaTypes/ama.ts index 774f95aa..a4997f3e 100644 --- a/cms/schemaTypes/ama.ts +++ b/cms/schemaTypes/ama.ts @@ -58,10 +58,11 @@ export default defineType({ } const statusEmoji: Record = { - draft: '📝', - ready: '🚀', - published: '✅', - failed: '❌', + 'draft': '📝', + 'ready': '🚀', + 'partially-published': '⚠️', + 'published': '✅', + 'failed': '❌', } const hasDraft = posts && posts.length > 0 @@ -166,6 +167,7 @@ export default defineType({ list: [ { title: 'Draft', value: 'draft' }, { title: 'Ready to publish', value: 'ready' }, + { title: 'Partially published', value: 'partially-published' }, { title: 'Published', value: 'published' }, { title: 'Failed', value: 'failed' }, ], @@ -225,7 +227,7 @@ export default defineType({ }), ], readOnly: true, - hidden: ({ document }) => document?.publishStatus !== 'published', + hidden: ({ document }) => document?.publishStatus !== 'published' && document?.publishStatus !== 'partially-published', }), defineField({ name: 'platforms', diff --git a/server/api/publish-ama.post.ts b/server/api/publish-ama.post.ts index 09e844be..c9c11d9a 100644 --- a/server/api/publish-ama.post.ts +++ b/server/api/publish-ama.post.ts @@ -24,6 +24,20 @@ interface AMADocument { mastodon?: boolean youtubeShorts?: boolean } + publishedLinks?: { + bluesky?: string + linkedin?: string + mastodon?: string + youtubeShorts?: string + } + publishLog?: Array<{ + _type: string + _key: string + timestamp: string + platform: string + status: string + message: string + }> image?: { url: string dimensions: { @@ -151,6 +165,8 @@ export default defineEventHandler(async event => { "dimensions": metadata.dimensions }, video, + publishedLinks, + publishLog, lastWebhookEvent }`, { id: body._id }, @@ -178,17 +194,20 @@ export default defineEventHandler(async event => { throw new Error('No posts found for publishing') } - // Publish to platforms + // Publish to platforms, skipping any that already have a published link const results = await publishToPlatforms(event, document) - const publishedLinks: Record = {} + // Merge new published links with any existing ones (preserves prior successes on retry) + const publishedLinks: Record = { ...document.publishedLinks } results.forEach(result => { if (result.success && result.url) { publishedLinks[camelCase(result.platform)] = result.url } }) - if (document.video) { + // Only clean up video if YouTube Shorts actually succeeded + const youtubeResult = results.find(r => r.platform === 'youtube-shorts') + if (document.video && youtubeResult?.success) { try { await sanity.client.patch(document._id).unset(['video']).commit() await sanity.client.delete(document.video.asset._ref) @@ -198,7 +217,8 @@ export default defineEventHandler(async event => { } } - const publishLog = results.map(result => ({ + // Append new log entries to existing publish log (preserves history across retries) + const newLogEntries = results.map(result => ({ _type: 'object', _key: randomUUID(), timestamp: new Date().toISOString(), @@ -206,8 +226,14 @@ export default defineEventHandler(async event => { status: result.success ? 'success' : 'error', message: result.success ? `Published: ${result.url}` : result.error, })) + const publishLog = [...(document.publishLog || []), ...newLogEntries] + + // Determine final status based on all enabled platforms vs all published links + const enabledPlatforms = getEnabledPlatforms(document) + const allPublished = enabledPlatforms.every(p => publishedLinks[p]) + const somePublished = enabledPlatforms.some(p => publishedLinks[p]) + const finalStatus = allPublished ? 'published' : somePublished ? 'partially-published' : 'failed' - const finalStatus = results.some(r => r.success) ? 'published' : 'failed' await sanity.client .patch(document._id) .set({ @@ -227,16 +253,17 @@ export default defineEventHandler(async event => { .patch(body._id) .set({ publishStatus: 'failed', - publishLog: [{ - _type: 'object', - _key: randomUUID(), - timestamp: new Date().toISOString(), - platform: 'system', - status: 'error', - message: String(error), - }], lastWebhookEvent: { timestamp, signature }, }) + .setIfMissing({ publishLog: [] }) + .append('publishLog', [{ + _type: 'object', + _key: randomUUID(), + timestamp: new Date().toISOString(), + platform: 'system', + status: 'error', + message: String(error), + }]) .commit() throw createError({ @@ -790,66 +817,121 @@ async function processManualThreads (posts: Array<{ content: PortableTextBlock[] return threads } +function getEnabledPlatforms (document: AMADocument): string[] { + const platforms: string[] = [] + if (document.platforms?.bluesky !== false) platforms.push('bluesky') + if (document.platforms?.mastodon !== false) platforms.push('mastodon') + if (document.platforms?.linkedin !== false) platforms.push('linkedin') + if (document.platforms?.youtubeShorts === true) platforms.push('youtubeShorts') + return platforms +} + async function publishToPlatforms (event: H3Event, document: AMADocument): Promise { const results: PublishResult[] = [] + const alreadyPublished = document.publishedLinks || {} // Publish to Bluesky with manual threading if (document.platforms?.bluesky !== false) { - try { - const blueskyThreads = await processManualThreads(document.posts) - const blueskyResult = await publishToBlueskyThreads(event, blueskyThreads, document.image, document.content) - results.push({ platform: 'bluesky', success: true, url: blueskyResult.url }) + if (alreadyPublished.bluesky) { + console.log('Skipping Bluesky: already published at', alreadyPublished.bluesky) } - catch (error) { - results.push({ platform: 'bluesky', success: false, error: String(error) }) + else { + try { + const blueskyThreads = await processManualThreads(document.posts) + const blueskyResult = await publishToBlueskyThreads(event, blueskyThreads, document.image, document.content) + results.push({ platform: 'bluesky', success: true, url: blueskyResult.url }) + } + catch (error) { + results.push({ platform: 'bluesky', success: false, error: String(error) }) + } } } // Publish to other platforms without threading if (document.platforms?.mastodon !== false) { - try { - const mastodonText = resolveTextForPlatform(document.posts.flatMap(p => p.content), 'mastodon') - const mastodonFullText = `${mastodonText}\n\nhttps://roe.dev/ama\n\n#ama` - const mastodonResult = await publishToMastodon(event, mastodonFullText, document.image, document.content) - results.push({ platform: 'mastodon', success: true, url: mastodonResult.url }) + if (alreadyPublished.mastodon) { + console.log('Skipping Mastodon: already published at', alreadyPublished.mastodon) } - catch (error) { - results.push({ platform: 'mastodon', success: false, error: String(error) }) + else { + try { + const mastodonText = resolveTextForPlatform(document.posts.flatMap(p => p.content), 'mastodon') + const mastodonFullText = `${mastodonText}\n\nhttps://roe.dev/ama\n\n#ama` + const mastodonResult = await publishToMastodon(event, mastodonFullText, document.image, document.content) + results.push({ platform: 'mastodon', success: true, url: mastodonResult.url }) + } + catch (error) { + results.push({ platform: 'mastodon', success: false, error: String(error) }) + } } } if (document.platforms?.linkedin !== false) { - try { - const linkedinText = resolveTextForPlatform(document.posts.flatMap(p => p.content), 'linkedin') - const linkedinFullText = `${linkedinText}\n\nroe.dev/ama\n\n#ama` - const linkedinResult = await publishToLinkedIn(event, linkedinFullText, document.image, document.content) - results.push({ platform: 'linkedin', success: true, url: linkedinResult.url }) + if (alreadyPublished.linkedin) { + console.log('Skipping LinkedIn: already published at', alreadyPublished.linkedin) } - catch (error) { - results.push({ platform: 'linkedin', success: false, error: String(error) }) + else { + try { + const linkedinText = resolveTextForPlatform(document.posts.flatMap(p => p.content), 'linkedin') + const linkedinFullText = `${linkedinText}\n\nroe.dev/ama\n\n#ama` + const linkedinResult = await publishToLinkedIn(event, linkedinFullText, document.image, document.content) + results.push({ platform: 'linkedin', success: true, url: linkedinResult.url }) + } + catch (error) { + results.push({ platform: 'linkedin', success: false, error: String(error) }) + } } } // Publish to YouTube Shorts if (document.platforms?.youtubeShorts === true) { - try { - const youtubeResult = await publishToYouTubeShorts(event, document) - results.push({ platform: 'youtube-shorts', success: true, url: youtubeResult.url }) + if (alreadyPublished.youtubeShorts) { + console.log('Skipping YouTube Shorts: already published at', alreadyPublished.youtubeShorts) } - catch (error) { - results.push({ platform: 'youtube-shorts', success: false, error: String(error) }) + else { + try { + const youtubeResult = await publishToYouTubeShorts(event, document) + results.push({ platform: 'youtube-shorts', success: true, url: youtubeResult.url }) + } + catch (error) { + results.push({ platform: 'youtube-shorts', success: false, error: String(error) }) + } } } return results } +async function resolveBlueskyPds (handle: string): Promise { + // Resolve handle -> DID + const resolved = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`) + if (!resolved.ok) { + throw new Error(`Failed to resolve Bluesky handle ${handle}: ${resolved.status}`) + } + const { did } = await resolved.json() as { did: string } + + // Resolve DID -> PDS endpoint from DID document + const didDoc = await fetch(`https://plc.directory/${encodeURIComponent(did)}`) + if (!didDoc.ok) { + throw new Error(`Failed to resolve DID document for ${did}: ${didDoc.status}`) + } + const doc = await didDoc.json() as { service?: Array<{ id: string, type: string, serviceEndpoint: string }> } + const pds = doc.service?.find(s => s.id === '#atproto_pds')?.serviceEndpoint + if (!pds) { + throw new Error(`No PDS endpoint found in DID document for ${did}`) + } + return pds +} + async function publishToBlueskyThreads (event: H3Event, threads: Array<{ text: string, facets: AppBskyRichtextFacet.Main[] }>, image: AMADocument['image'], altText: string): Promise<{ url: string }> { const config = useRuntimeConfig(event) const { identifier } = config.social.networks.bluesky const password = config.bluesky.accessToken - const agent = new AtpAgent({ service: 'https://bsky.social' }) + // Resolve the account's PDS dynamically (supports custom PDS without extra config) + const pdsUrl = await resolveBlueskyPds(identifier) + console.log(`Resolved PDS for ${identifier}: ${pdsUrl}`) + + const agent = new AtpAgent({ service: pdsUrl }) await agent.login({ identifier, password }) const postUrls: string[] = [] diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts index b3599ac0..eb3aaa3c 100644 --- a/test/unit/bundle.spec.ts +++ b/test/unit/bundle.spec.ts @@ -78,7 +78,7 @@ describe('project sizes', () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) expect .soft(roundToKilobytes(stats.server.totalBytes)) - .toMatchInlineSnapshot(`"2350k"`) + .toMatchInlineSnapshot(`"2354k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) expect -- 2.54.0