From 057ce5581991c375090a2fb4b04f57108c8c4c40 Mon Sep 17 00:00:00 2001 From: threehappypenguins Date: Mon, 29 Jun 2026 23:43:08 -0300 Subject: [PATCH 01/27] feat(sermon-audio): enable Cross Publish on publish with connection-aware draft UI --- ...ermon-audio-social-connections-get.test.ts | 76 ++++++ __tests__/lib/distribute.test.ts | 19 +- .../sermon-audio-cross-publish.test.ts | 138 +++++++---- .../sermon-audio-social-connections.test.ts | 45 ++++ __tests__/lib/platforms/sermon-audio.test.ts | 233 +++++------------- .../sermon-audio/social-connections/route.ts | 61 +++++ components/drafts/DraftMetadataModal.tsx | 10 +- .../drafts/SermonAudioCrossPublishFields.tsx | 182 +++++++++++++- lib/api/distribute.ts | 22 +- lib/platforms/sermon-audio-cross-publish.ts | 113 +++++---- .../sermon-audio-social-connections.ts | 125 ++++++++++ lib/platforms/sermon-audio.ts | 106 +++----- lib/platforms/types.ts | 2 +- types/index.ts | 6 +- 14 files changed, 753 insertions(+), 385 deletions(-) create mode 100644 __tests__/api/platforms/sermon-audio-social-connections-get.test.ts create mode 100644 __tests__/lib/platforms/sermon-audio-social-connections.test.ts create mode 100644 app/api/platforms/sermon-audio/social-connections/route.ts create mode 100644 lib/platforms/sermon-audio-social-connections.ts diff --git a/__tests__/api/platforms/sermon-audio-social-connections-get.test.ts b/__tests__/api/platforms/sermon-audio-social-connections-get.test.ts new file mode 100644 index 00000000..c5ad2f9c --- /dev/null +++ b/__tests__/api/platforms/sermon-audio-social-connections-get.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetAuthenticatedUserId = vi.fn(); +const mockGetConnectedAccountWithTokens = vi.fn(); + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: (...args: unknown[]) => mockGetAuthenticatedUserId(...args), +})); + +vi.mock('@/lib/repositories/connected-accounts', () => ({ + getConnectedAccountWithTokens: (...args: unknown[]) => mockGetConnectedAccountWithTokens(...args), +})); + +import { GET } from '@/app/api/platforms/sermon-audio/social-connections/route'; + +describe('GET /api/platforms/sermon-audio/social-connections', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthenticatedUserId.mockResolvedValue('user-123'); + mockGetConnectedAccountWithTokens.mockResolvedValue({ + accessToken: 'sa-api-key', + platformUserId: 'crpc', + }); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns Cross Publish connection flags from refresh_social', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + google: { hasOAUTH: false }, + facebook: { hasOAUTH: true, pageName: 'CRPC Facebook' }, + twitter: { hasOAUTH: true, name: 'CRPCHalifax' }, + }), + { status: 200 } + ) + ); + + const res = await GET( + new NextRequest('http://localhost/api/platforms/sermon-audio/social-connections') + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ + youtube: { connected: false }, + facebook: { connected: true, displayName: 'CRPC Facebook' }, + x: { connected: true, displayName: 'CRPCHalifax' }, + }); + + const [url, init] = vi.mocked(global.fetch).mock.calls[0] ?? []; + expect(String(url)).toBe( + 'https://api.sermonaudio.com/v2/node/broadcasters/crpc/refresh_social?cacheLanguage=en&cacheMax=181&cacheDomain=www.sermonaudio.com' + ); + expect(init).toMatchObject({ + method: 'POST', + headers: expect.objectContaining({ 'X-Api-Key': 'sa-api-key' }), + }); + }); + + it('returns 400 when upstream rejects the stored API key', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(new Response('Unauthorized', { status: 401 })); + + const res = await GET( + new NextRequest('http://localhost/api/platforms/sermon-audio/social-connections') + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.statusCode).toBe(400); + expect(body.message).toContain('invalid or revoked'); + }); +}); diff --git a/__tests__/lib/distribute.test.ts b/__tests__/lib/distribute.test.ts index 332e281e..a451e02c 100644 --- a/__tests__/lib/distribute.test.ts +++ b/__tests__/lib/distribute.test.ts @@ -655,7 +655,6 @@ describe('runDistributionInBackground — SermonAudio auto-publish failure', () expect(mockPollSermonAudioProcessing).toHaveBeenCalledWith( expect.objectContaining({ sermonID: 'sermon-123', - customThumbnailUploaded: false, }) ); }); @@ -680,10 +679,8 @@ describe('runDistributionInBackground — SermonAudio auto-publish failure', () ).toBe(false); }); - it('passes customThumbnailUploaded true to poll when thumbnail upload succeeded', async () => { - mockUploadToSermonAudio.mockResolvedValue( - sermonAudioUploadSuccess({ sermonAudioCustomThumbnailUploaded: true }) - ); + it('passes crossPublish settings to publish when configured on the draft', async () => { + mockUploadToSermonAudio.mockResolvedValue(sermonAudioUploadSuccess()); mockPollSermonAudioProcessing.mockResolvedValue(undefined); mockPublishSermonAudio.mockResolvedValue(undefined); @@ -694,7 +691,10 @@ describe('runDistributionInBackground — SermonAudio auto-publish failure', () { ...baseMetadata, autoPublishOnProcessed: true, - thumbnailR2Key: 'draft-thumbnails/u1/thumb.jpg', + crossPublish: { + enabled: true, + facebook: { postLink: true, linkMessage: 'New sermon' }, + }, }, ], ]); @@ -702,10 +702,13 @@ describe('runDistributionInBackground — SermonAudio auto-publish failure', () await runDistributionInBackground('job-1', 'u1', 'temp/uploads/u1/v.mp4', [pu], meta); await vi.waitFor(() => { - expect(mockPollSermonAudioProcessing).toHaveBeenCalledWith( + expect(mockPublishSermonAudio).toHaveBeenCalledWith( expect.objectContaining({ sermonID: 'sermon-123', - customThumbnailUploaded: true, + crossPublish: { + enabled: true, + facebook: { postLink: true, linkMessage: 'New sermon' }, + }, }) ); }); diff --git a/__tests__/lib/platforms/sermon-audio-cross-publish.test.ts b/__tests__/lib/platforms/sermon-audio-cross-publish.test.ts index 0c5218b8..2cbd4f54 100644 --- a/__tests__/lib/platforms/sermon-audio-cross-publish.test.ts +++ b/__tests__/lib/platforms/sermon-audio-cross-publish.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'vitest'; import { - buildSermonAudioSocialSharingCreateFields, + buildSermonAudioSocialSharingSettings, normalizeSermonAudioCrossPublishPlatformSettings, normalizeSermonAudioCrossPublishSettings, - SERMON_AUDIO_CROSS_PUBLISH_VIDEO_CLIP_END_SECONDS, sermonAudioCrossPublishHasActiveSelection, } from '@/lib/platforms/sermon-audio-cross-publish'; @@ -99,6 +98,36 @@ describe('normalizeSermonAudioCrossPublishPlatformSettings', () => { }) ).toBeUndefined(); }); + + it('drops Facebook uploadFullVideo when postLink is not enabled', () => { + expect( + normalizeSermonAudioCrossPublishPlatformSettings('facebook', { + uploadFullVideo: true, + }) + ).toBeUndefined(); + + expect( + normalizeSermonAudioCrossPublishPlatformSettings('facebook', { + postLink: false, + uploadFullVideo: true, + }) + ).toEqual({ postLink: false }); + }); + + it('drops X uploadVideoPreview when postLink is not enabled', () => { + expect( + normalizeSermonAudioCrossPublishPlatformSettings('x', { + uploadVideoPreview: true, + }) + ).toBeUndefined(); + + expect( + normalizeSermonAudioCrossPublishPlatformSettings('x', { + postLink: false, + uploadVideoPreview: true, + }) + ).toEqual({ postLink: false }); + }); }); describe('sermonAudioCrossPublishHasActiveSelection', () => { @@ -138,29 +167,38 @@ describe('sermonAudioCrossPublishHasActiveSelection', () => { ).toBe(false); }); - it('returns true when only X video preview is selected', () => { + it('returns false when only Facebook uploadFullVideo is selected without postLink', () => { + expect( + sermonAudioCrossPublishHasActiveSelection({ + enabled: true, + facebook: { uploadFullVideo: true }, + }) + ).toBe(false); + }); + + it('returns false when only X uploadVideoPreview is selected without postLink', () => { expect( sermonAudioCrossPublishHasActiveSelection({ enabled: true, x: { uploadVideoPreview: true }, }) - ).toBe(true); + ).toBe(false); }); }); -describe('buildSermonAudioSocialSharingCreateFields', () => { +describe('buildSermonAudioSocialSharingSettings', () => { it('returns undefined when Cross Publish is disabled', () => { expect( - buildSermonAudioSocialSharingCreateFields({ + buildSermonAudioSocialSharingSettings({ enabled: false, facebook: { postLink: true }, }) ).toBeUndefined(); }); - it('maps platform-specific Cross Publish settings to socialSharing array format', () => { + it('maps platform-specific Cross Publish settings to socialSharingSettings format', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings( { enabled: true, youtube: { @@ -169,13 +207,13 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { title: 'YouTube Title', description: 'YouTube Description', }, - facebook: { uploadFullVideo: true }, + facebook: { postLink: true, uploadFullVideo: true }, x: { postLink: true, uploadVideoPreview: true, linkMessage: 'New sermon' }, }, { defaultTitle: 'Sunday Sermon', defaultDescription: 'Shared description' } ) ).toEqual({ - socialSharing: [ + platforms: [ { platform: 'google', title: 'YouTube Title', @@ -184,7 +222,8 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { }, { platform: 'facebook', - message: 'Shared description', + message: 'Sunday Sermon', + useVideoClip: true, }, { platform: 'twitter', @@ -192,16 +231,15 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { useVideoClip: true, }, ], - social_sharing_video_clip: { - start: 0, - end: SERMON_AUDIO_CROSS_PUBLISH_VIDEO_CLIP_END_SECONDS, - }, + google: true, + facebook: true, + twitter: true, }); }); it('uses draft defaults for YouTube title and description when fields are empty', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings( { enabled: true, youtube: { uploadFullVideo: true }, @@ -209,7 +247,7 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { { defaultTitle: 'Sunday Sermon', defaultDescription: 'Shared description' } ) ).toEqual({ - socialSharing: [ + platforms: [ { platform: 'google', title: 'Sunday Sermon', @@ -217,12 +255,13 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { privacy: 'public', }, ], + google: true, }); }); it('falls back to sermon title for YouTube message when description defaults are empty', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings( { enabled: true, youtube: { uploadFullVideo: true }, @@ -230,7 +269,7 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { { defaultTitle: 'Sunday Sermon', defaultDescription: '' } ) ).toEqual({ - socialSharing: [ + platforms: [ { platform: 'google', title: 'Sunday Sermon', @@ -238,51 +277,49 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { privacy: 'public', }, ], + google: true, }); }); - it('omits useVideoClip and clip range for link-only Facebook posts', () => { + it('sets useVideoClip false for Facebook link posts', () => { expect( - buildSermonAudioSocialSharingCreateFields({ + buildSermonAudioSocialSharingSettings({ enabled: true, facebook: { postLink: true, linkMessage: 'Listen here' }, }) ).toEqual({ - socialSharing: [{ platform: 'facebook', message: 'Listen here' }], + platforms: [{ platform: 'facebook', message: 'Listen here', useVideoClip: false }], + facebook: true, }); }); - it('uses sermon description for Facebook full-video-only posts', () => { + it('sets useVideoClip true for Facebook link plus video posts', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings( { enabled: true, - facebook: { uploadFullVideo: true }, + facebook: { postLink: true, uploadFullVideo: true, linkMessage: 'Watch now' }, }, { defaultTitle: 'Sunday Sermon', defaultDescription: 'Shared description' } ) ).toEqual({ - socialSharing: [{ platform: 'facebook', message: 'Shared description' }], + platforms: [{ platform: 'facebook', message: 'Watch now', useVideoClip: true }], + facebook: true, }); }); - it('prefers link message over description when Facebook post link is enabled', () => { + it('ignores Facebook uploadFullVideo without postLink', () => { expect( - buildSermonAudioSocialSharingCreateFields( - { - enabled: true, - facebook: { postLink: true, uploadFullVideo: true, linkMessage: 'Watch now' }, - }, - { defaultTitle: 'Sunday Sermon', defaultDescription: 'Shared description' } - ) - ).toEqual({ - socialSharing: [{ platform: 'facebook', message: 'Watch now' }], - }); + buildSermonAudioSocialSharingSettings({ + enabled: true, + facebook: { uploadFullVideo: true }, + }) + ).toBeUndefined(); }); - it('sets useVideoClip only for X video preview', () => { + it('sets useVideoClip only for X link plus video preview', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings( { enabled: true, x: { postLink: true, uploadVideoPreview: true, linkMessage: 'Preview clip' }, @@ -290,22 +327,29 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { { defaultTitle: 'Sunday Sermon' } ) ).toEqual({ - socialSharing: [{ platform: 'twitter', message: 'Preview clip', useVideoClip: true }], - social_sharing_video_clip: { - start: 0, - end: SERMON_AUDIO_CROSS_PUBLISH_VIDEO_CLIP_END_SECONDS, - }, + platforms: [{ platform: 'twitter', message: 'Preview clip', useVideoClip: true }], + twitter: true, }); }); - it('omits useVideoClip for X link-only posts', () => { + it('sets useVideoClip false for X link-only posts', () => { expect( - buildSermonAudioSocialSharingCreateFields({ + buildSermonAudioSocialSharingSettings({ enabled: true, x: { postLink: true, linkMessage: 'Read more' }, }) ).toEqual({ - socialSharing: [{ platform: 'twitter', message: 'Read more' }], + platforms: [{ platform: 'twitter', message: 'Read more', useVideoClip: false }], + twitter: true, }); }); + + it('ignores X uploadVideoPreview without postLink', () => { + expect( + buildSermonAudioSocialSharingSettings({ + enabled: true, + x: { uploadVideoPreview: true }, + }) + ).toBeUndefined(); + }); }); diff --git a/__tests__/lib/platforms/sermon-audio-social-connections.test.ts b/__tests__/lib/platforms/sermon-audio-social-connections.test.ts new file mode 100644 index 00000000..014ddb35 --- /dev/null +++ b/__tests__/lib/platforms/sermon-audio-social-connections.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + parseSermonAudioRefreshSocialResponse, + sermonAudioRefreshSocialUrl, +} from '@/lib/platforms/sermon-audio-social-connections'; + +describe('parseSermonAudioRefreshSocialResponse', () => { + it('maps refresh_social platforms to Cross Publish destination connection flags', () => { + expect( + parseSermonAudioRefreshSocialResponse({ + google: { hasOAUTH: false, name: null }, + facebook: { + hasOAUTH: true, + name: 'Covenant Reformed Presbyterian Church', + pageName: 'Covenant Reformed Presbyterian Church', + }, + twitter: { hasOAUTH: true, name: 'CRPCHalifax' }, + instagram: { hasOAUTH: false, username: null }, + }) + ).toEqual({ + youtube: { connected: false }, + facebook: { + connected: true, + displayName: 'Covenant Reformed Presbyterian Church', + }, + x: { connected: true, displayName: 'CRPCHalifax' }, + }); + }); + + it('returns disconnected defaults for invalid input', () => { + expect(parseSermonAudioRefreshSocialResponse(null)).toEqual({ + youtube: { connected: false }, + facebook: { connected: false }, + x: { connected: false }, + }); + }); +}); + +describe('sermonAudioRefreshSocialUrl', () => { + it('builds the refresh_social URL for a broadcaster id', () => { + expect(sermonAudioRefreshSocialUrl('crpc')).toBe( + 'https://api.sermonaudio.com/v2/node/broadcasters/crpc/refresh_social?cacheLanguage=en&cacheMax=181&cacheDomain=www.sermonaudio.com' + ); + }); +}); diff --git a/__tests__/lib/platforms/sermon-audio.test.ts b/__tests__/lib/platforms/sermon-audio.test.ts index bbaa3c97..6f5f5848 100644 --- a/__tests__/lib/platforms/sermon-audio.test.ts +++ b/__tests__/lib/platforms/sermon-audio.test.ts @@ -176,7 +176,7 @@ describe('uploadToSermonAudio', () => { expect(createBody).toMatchObject({ speakerName: 'Rev. Smith' }); }); - it('includes socialSharing on sermon create when Cross Publish is enabled', async () => { + it('does not include cross-publish fields on sermon create', async () => { const fetchMock = vi.mocked(global.fetch); fetchMock .mockResolvedValueOnce( @@ -204,45 +204,10 @@ describe('uploadToSermonAudio', () => { tokens, }); - const createInit = fetchMock.mock.calls[0]?.[1] as RequestInit; - const createBody = JSON.parse(String(createInit.body)) as Record; - expect(createBody.socialSharing).toEqual([ - { platform: 'google', title: 'YT Title', message: 'YT Desc', privacy: 'public' }, - { platform: 'facebook', message: 'Check this out' }, - { platform: 'twitter', message: 'Sunday Sermon', useVideoClip: true }, - ]); - expect(createBody.social_sharing_video_clip).toEqual({ start: 0, end: 120 }); - }); - - it('does not include socialSharing on sermon create when Cross Publish is disabled', async () => { - const fetchMock = vi.mocked(global.fetch); - fetchMock - .mockResolvedValueOnce( - new Response(JSON.stringify({ sermonID: 'sermon-456' }), { status: 200 }) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ uploadURL: 'https://upload.sermonaudio.com/video' }), { - status: 200, - }) - ) - .mockResolvedValueOnce(new Response('', { status: 200 })); - - await uploadToSermonAudio({ - videoStream: makeVideoStream(), - contentLength: 3, - metadata: { - ...metadata, - crossPublish: { - enabled: false, - facebook: { postLink: true, linkMessage: 'Check this out' }, - }, - }, - tokens, - }); - const createInit = fetchMock.mock.calls[0]?.[1] as RequestInit; const createBody = JSON.parse(String(createInit.body)) as Record; expect(createBody).not.toHaveProperty('socialSharing'); + expect(createBody).not.toHaveProperty('socialSharingSettings'); expect(createBody).not.toHaveProperty('social_sharing_video_clip'); }); @@ -539,19 +504,12 @@ describe('pollSermonAudioProcessing', () => { vi.useRealTimers(); }); - it('resolves when any media.video entry has a videoCodec (custom thumbnail uploaded)', async () => { + it('resolves when hasVideo is true and videoMediaStatus is ready', async () => { vi.mocked(global.fetch).mockResolvedValueOnce( new Response( JSON.stringify({ - media: { - video: [ - { - videoCodec: 'h264', - adaptiveBitrate: false, - thumbnailImageURL: null, - }, - ], - }, + hasVideo: true, + videoMediaStatus: 'ready', }), { status: 200 } ) @@ -561,7 +519,6 @@ describe('pollSermonAudioProcessing', () => { pollSermonAudioProcessing({ sermonID: 'sermon-123', tokens, - customThumbnailUploaded: true, intervalMs: 1000, maxAttempts: 3, }) @@ -577,64 +534,12 @@ describe('pollSermonAudioProcessing', () => { ); }); - it('resolves when any media.video entry has a thumbnailImageURL (no custom thumbnail)', async () => { - vi.mocked(global.fetch).mockResolvedValueOnce( - new Response( - JSON.stringify({ - media: { - video: [ - { - videoCodec: 'h264', - adaptiveBitrate: false, - thumbnailImageURL: 'https://media.sermonaudio.com/thumbnails/662635113143.jpg', - }, - ], - }, - }), - { status: 200 } - ) - ); - - await expect( - pollSermonAudioProcessing({ - sermonID: 'sermon-123', - tokens, - intervalMs: 1000, - maxAttempts: 3, - }) - ).resolves.toBeUndefined(); - }); - - it('resolves when any media.video entry has a successful videoMediaStatus (custom thumbnail uploaded)', async () => { - vi.mocked(global.fetch).mockResolvedValueOnce( - new Response( - JSON.stringify({ - media: { - video: [{ videoCodec: null, videoMediaStatus: 'ready', thumbnailImageURL: null }], - }, - }), - { status: 200 } - ) - ); - - await expect( - pollSermonAudioProcessing({ - sermonID: 'sermon-123', - tokens, - customThumbnailUploaded: true, - intervalMs: 1000, - maxAttempts: 3, - }) - ).resolves.toBeUndefined(); - }); - - it('rejects when any media.video entry reports a failed videoMediaStatus', async () => { + it('rejects when videoMediaStatus reports a failed state', async () => { vi.mocked(global.fetch).mockResolvedValueOnce( new Response( JSON.stringify({ - media: { - video: [{ videoCodec: null, videoMediaStatus: 'failed', thumbnailImageURL: null }], - }, + hasVideo: true, + videoMediaStatus: 'failed', }), { status: 200 } ) @@ -644,7 +549,6 @@ describe('pollSermonAudioProcessing', () => { pollSermonAudioProcessing({ sermonID: 'sermon-123', tokens, - customThumbnailUploaded: true, intervalMs: 1000, maxAttempts: 3, }) @@ -656,28 +560,14 @@ describe('pollSermonAudioProcessing', () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); - it('resolves when the ABR video entry exposes a codec on a progressive rendition (custom thumbnail uploaded)', async () => { + it('rejects when any media.video entry reports a failed videoMediaStatus', async () => { vi.mocked(global.fetch).mockResolvedValueOnce( new Response( JSON.stringify({ - videoMediaStatus: 'ready', + hasVideo: true, + videoMediaStatus: 'processing', media: { - video: [ - { - mediaType: 'mp4', - streamURL: 'https://cloud.sermonaudio.com/media/video/abr/662635113143.m3u8', - thumbnailImageURL: 'https://media.sermonaudio.com/thumbnails/662635113143.jpg', - adaptiveBitrate: true, - videoCodec: null, - }, - { - mediaType: 'mp4', - streamURL: 'https://cloud.sermonaudio.com/media/video/high/662635113143.m3u8', - thumbnailImageURL: 'https://media.sermonaudio.com/thumbnails/662635113143.jpg', - adaptiveBitrate: false, - videoCodec: 'h264', - }, - ], + video: [{ videoMediaStatus: 'failed' }], }, }), { status: 200 } @@ -688,55 +578,21 @@ describe('pollSermonAudioProcessing', () => { pollSermonAudioProcessing({ sermonID: 'sermon-123', tokens, - customThumbnailUploaded: true, intervalMs: 1000, maxAttempts: 3, }) - ).resolves.toBeUndefined(); - }); - - it('keeps polling when codec is ready but thumbnailImageURL is still null (no custom thumbnail)', async () => { - vi.mocked(global.fetch).mockResolvedValue( - new Response( - JSON.stringify({ - videoMediaStatus: 'ready', - media: { - video: [{ videoCodec: 'h264', adaptiveBitrate: false, thumbnailImageURL: null }], - }, - }), - { status: 200 } - ) - ); - - const promise = pollSermonAudioProcessing({ - sermonID: 'sermon-123', - tokens, - intervalMs: 100, - maxAttempts: 2, - }); - - const expectation = expect(promise).rejects.toMatchObject({ - code: 'SERMONAUDIO_PROCESSING_TIMEOUT', + ).rejects.toMatchObject({ + code: 'SERMONAUDIO_PROCESSING_FAILED', + details: 'videoMediaStatus: failed', }); - await vi.runAllTimersAsync(); - await expectation; - expect(global.fetch).toHaveBeenCalledTimes(2); }); - it('keeps polling when a custom thumbnail is present but transcoding is not done yet', async () => { + it('keeps polling when video is not ready yet', async () => { vi.mocked(global.fetch).mockResolvedValue( new Response( JSON.stringify({ - media: { - video: [ - { - videoCodec: null, - videoMediaStatus: 'processing', - adaptiveBitrate: false, - thumbnailImageURL: 'https://media.sermonaudio.com/thumbnails/custom.jpg', - }, - ], - }, + hasVideo: true, + videoMediaStatus: 'processing', }), { status: 200 } ) @@ -745,7 +601,6 @@ describe('pollSermonAudioProcessing', () => { const promise = pollSermonAudioProcessing({ sermonID: 'sermon-123', tokens, - customThumbnailUploaded: true, intervalMs: 100, maxAttempts: 2, }); @@ -758,13 +613,12 @@ describe('pollSermonAudioProcessing', () => { expect(global.fetch).toHaveBeenCalledTimes(2); }); - it('rejects when max attempts are exceeded without a generated thumbnail (no custom thumbnail)', async () => { + it('keeps polling when hasVideo is false', async () => { vi.mocked(global.fetch).mockResolvedValue( new Response( JSON.stringify({ - media: { - video: [{ videoCodec: 'h264', adaptiveBitrate: false, thumbnailImageURL: null }], - }, + hasVideo: false, + videoMediaStatus: 'ready', }), { status: 200 } ) @@ -787,7 +641,9 @@ describe('pollSermonAudioProcessing', () => { it('rejects when the abort signal fires during polling', async () => { vi.mocked(global.fetch).mockResolvedValue( - new Response(JSON.stringify({ media: { video: [] } }), { status: 200 }) + new Response(JSON.stringify({ hasVideo: false, videoMediaStatus: 'processing' }), { + status: 200, + }) ); const controller = new AbortController(); @@ -810,17 +666,14 @@ describe('pollSermonAudioProcessing', () => { describe('publishSermonAudio', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-06-05T12:00:00.000Z')); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); - vi.useRealTimers(); }); - it('PATCHes publishDate only on success (Cross Publish is configured on sermon create)', async () => { + it('PATCHes publishNow when Cross Publish is disabled', async () => { vi.mocked(global.fetch).mockResolvedValueOnce(new Response('', { status: 200 })); await publishSermonAudio({ @@ -833,7 +686,41 @@ describe('publishSermonAudio', () => { expect.objectContaining({ method: 'PATCH', headers: expect.objectContaining({ 'X-Api-Key': 'sa-api-key' }), - body: JSON.stringify({ publishDate: '2026-06-05' }), + body: JSON.stringify({ publishNow: true }), + }) + ); + }); + + it('PATCHes publishNow with socialSharingSettings when Cross Publish is enabled', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(new Response('', { status: 200 })); + + await publishSermonAudio({ + sermonID: 'sermon-123', + tokens, + crossPublish: { + enabled: true, + facebook: { postLink: true, linkMessage: 'Check this out' }, + }, + defaultTitle: 'Sunday Sermon', + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.sermonaudio.com/v2/node/sermons/sermon-123', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ + publishNow: true, + socialSharingSettings: { + platforms: [ + { + platform: 'facebook', + message: 'Check this out', + useVideoClip: false, + }, + ], + facebook: true, + }, + }), }) ); }); diff --git a/app/api/platforms/sermon-audio/social-connections/route.ts b/app/api/platforms/sermon-audio/social-connections/route.ts new file mode 100644 index 00000000..f6eb1807 --- /dev/null +++ b/app/api/platforms/sermon-audio/social-connections/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireSermonAudioConnection } from '@/lib/platforms/sermon-audio-api'; +import { + SermonAudioUpstreamHttpError, + isSermonAudioCredentialsFailure, + sermonAudioUpstreamApiErrorLabel, + sermonAudioUpstreamResponseStatus, +} from '@/lib/platforms/sermon-audio-http'; +import { fetchSermonAudioCrossPublishSocialConnections } from '@/lib/platforms/sermon-audio-social-connections'; +import type { ApiError, ApiResponse } from '@/types'; +import type { SermonAudioCrossPublishSocialConnections } from '@/lib/platforms/sermon-audio-social-connections'; + +/** + * Returns SermonAudio Cross Publish OAuth connection status for the authenticated broadcaster. + * Proxies undocumented `POST /v2/node/broadcasters/{id}/refresh_social`. + * @param req - Incoming GET request. + * @returns JSON connection flags per Cross Publish destination, or a structured error. + */ +export async function GET(req: NextRequest) { + const connection = await requireSermonAudioConnection(req); + if (connection.ok === false) { + return connection.response; + } + + try { + const connections = await fetchSermonAudioCrossPublishSocialConnections( + connection.apiKey, + connection.broadcasterId + ); + const res: ApiResponse = { data: connections }; + return NextResponse.json(res, { status: 200 }); + } catch (err) { + if (err instanceof SermonAudioUpstreamHttpError) { + if (isSermonAudioCredentialsFailure(err.status)) { + const errRes: ApiError = { + error: 'Bad Request', + message: + 'SermonAudio API key is invalid or revoked. Reconnect SermonAudio in account settings.', + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + + const status = sermonAudioUpstreamResponseStatus(err.status); + const errRes: ApiError = { + error: sermonAudioUpstreamApiErrorLabel(status), + message: 'SermonAudio is temporarily unavailable. Try again in a few minutes.', + statusCode: status, + }; + return NextResponse.json(errRes, { status }); + } + + console.error('[GET /api/platforms/sermon-audio/social-connections] Unexpected error:', err); + const errRes: ApiError = { + error: 'Internal Server Error', + message: 'Failed to load SermonAudio social connections', + statusCode: 500, + }; + return NextResponse.json(errRes, { status: 500 }); + } +} diff --git a/components/drafts/DraftMetadataModal.tsx b/components/drafts/DraftMetadataModal.tsx index cf4d9bd7..afc5527d 100644 --- a/components/drafts/DraftMetadataModal.tsx +++ b/components/drafts/DraftMetadataModal.tsx @@ -55,6 +55,7 @@ import { import { UploadHistoryJobDiscard } from '@/components/uploads/UploadHistoryJobDiscard'; import { UploadHistoryPlatformActions } from '@/components/uploads/UploadHistoryPlatformActions'; import { SermonAudioSpeakerCombobox } from '@/components/drafts/SermonAudioSpeakerCombobox'; +import { SermonAudioCrossPublishFields } from '@/components/drafts/SermonAudioCrossPublishFields'; import { SermonAudioSeriesCombobox } from '@/components/drafts/SermonAudioSeriesCombobox'; import { SermonAudioBibleReferencesField } from '@/components/drafts/SermonAudioBibleReferencesField'; import { YouTubePlaylistCombobox } from '@/components/drafts/YouTubePlaylistCombobox'; @@ -4263,8 +4264,15 @@ export function DraftMetadataModal({ + {sermonAudioFields?.autoPublishOnProcessed !== false ? ( + updateSermonAudioFields({ crossPublish: next })} + /> + ) : null} {/* TODO(sermon-audio-schedule): Contact the SermonAudio developer — POST /v2/node/sermons rejects ISO datetimes for publishDate (422: dates must be YYYY-MM-DD only). Schedule for Publication UI hidden until API supports date+time scheduling. */} - {/* TODO(sermon-audio-cross-publish): I will contact the SermonAudio developer about how to get Cross Publish working via the API. Cross Publish UI hidden until then. */} ); diff --git a/components/drafts/SermonAudioCrossPublishFields.tsx b/components/drafts/SermonAudioCrossPublishFields.tsx index b18eb858..06d7e596 100644 --- a/components/drafts/SermonAudioCrossPublishFields.tsx +++ b/components/drafts/SermonAudioCrossPublishFields.tsx @@ -1,10 +1,17 @@ 'use client'; +import { useEffect, useRef, useState } from 'react'; +import { Loader2 } from 'lucide-react'; import { SERMON_AUDIO_CROSS_PUBLISH_DESTINATIONS, SERMON_AUDIO_CROSS_PUBLISH_YOUTUBE_PRIVACY_OPTIONS, } from '@/lib/platforms/sermon-audio-cross-publish'; +import { + SERMONAUDIO_SOCIAL_CONNECTIONS_DASHBOARD_URL, + type SermonAudioCrossPublishSocialConnections, +} from '@/lib/platforms/sermon-audio-social-connections'; import type { + ApiResponse, SermonAudioCrossPublishPlatformSettings, SermonAudioCrossPublishSettings, SermonAudioCrossPublishTarget, @@ -32,6 +39,24 @@ function patchCrossPublishPlatform( return { ...base, [platform]: nextPlatform }; } +function stripDisconnectedCrossPublishPlatforms( + current: SermonAudioCrossPublishSettings | undefined, + connections: SermonAudioCrossPublishSocialConnections +): SermonAudioCrossPublishSettings | undefined { + if (!current) return current; + + let changed = false; + const next: SermonAudioCrossPublishSettings = { ...current }; + + for (const destination of SERMON_AUDIO_CROSS_PUBLISH_DESTINATIONS) { + if (connections[destination.id].connected || !next[destination.id]) continue; + delete next[destination.id]; + changed = true; + } + + return changed ? next : current; +} + interface CrossPublishToggleRowProps { /** Stable id for the switch input. */ id: string; @@ -39,6 +64,8 @@ interface CrossPublishToggleRowProps { label: string; /** Whether the switch is on. */ checked: boolean; + /** When true, the switch cannot be toggled (SermonAudio dashboard constraint). */ + disabled?: boolean; /** Called when the switch value changes. */ onCheckedChange: (checked: boolean) => void; } @@ -52,12 +79,16 @@ function CrossPublishToggleRow({ id, label, checked, + disabled = false, onCheckedChange, }: CrossPublishToggleRowProps) { return (