From 6a087a449d4f89ba227085ab46b05cc9778e1062 Mon Sep 17 00:00:00 2001 From: sigmanor Date: Wed, 2 Sep 2026 20:31:33 +0300 Subject: [PATCH 1/3] feat(repositories): choose between publish next and publish now The send button promoted a repository to the head of the publication queue and nothing more, so a post that has to go out now still waited for the scheduled run. It now opens a dialog with both choices: promote to the head of the queue, or publish immediately to every enabled integration through Content Maestro's new /api/message/publish. The publication status is shown inside the dialog rather than as a toast, because that is the only place the per-integration outcome fits: one row per integration, spinner while the request is in flight, then sent or failed with the error the connector reported. Progress is not faked per integration - the backend is a single synchronous call, and flipping a row to sent on a guess would be a lie about a publication. The integration list comes from the api-configs already loaded by the dashboard, so the dialog costs no extra request. Content Maestro still resolves the enabled set itself, so a run can report an integration this tab's cached list does not know about; the rows are built from both, and an expected integration with no reported outcome is shown as a failure rather than silently as a success. The send button is no longer disabled on the row that is already next: that is precisely the row most likely to need publishing now. Only the "publish next" action inside the dialog is disabled for it. A request is given a 240 s abort as a safety net, not as a cancellation - aborting does not stop the run on the server - so the timeout says the publication may still be running instead of claiming it failed. Also fixes an adjacent lie: promoteRepositoryToNext answers an unconfigured API with an error payload instead of throwing, and the handlers ignored it, so an unconfigured dashboard toasted "Repository will be published next" after doing nothing at all. --- src/api/index.ts | 48 +++ src/components/ui/base/dialog.tsx | 18 +- .../ui/business/publish-repository-dialog.tsx | 297 ++++++++++++++++++ .../ui/business/repository-list.tsx | 6 + .../ui/business/repository-mobile-view.tsx | 44 +-- .../ui/business/repository-table.tsx | 44 +-- .../ui/layout/dashboard-content.tsx | 1 + src/types/repositoryList.ts | 5 + src/utils/message-publish.test.ts | 161 ++++++++++ src/utils/message-publish.ts | 129 ++++++++ 10 files changed, 714 insertions(+), 39 deletions(-) create mode 100644 src/components/ui/business/publish-repository-dialog.tsx create mode 100644 src/utils/message-publish.test.ts create mode 100644 src/utils/message-publish.ts diff --git a/src/api/index.ts b/src/api/index.ts index 1c20d4e..323737c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -36,6 +36,13 @@ export interface RetryMessageResult { succeeded?: string[] | null; failed?: string[] | null; outcomes?: RetryMessageOutcome[] | null; + /** Whether the repository was marked as published and left the queue. */ + posted?: boolean; + /** + * Present only when that marking failed: the post went out but the repository + * stayed in the queue, so the scheduled run will publish it again. + */ + posted_error?: string; } export interface CronJobHistoryResponse { @@ -203,6 +210,47 @@ export const retryMessagePost = async ( return response.json(); }; +/** + * Publishes a repository immediately to every enabled integration, instead of + * waiting for its turn in the publication queue. Content Maestro resolves the + * integrations itself: the dashboard's cached list must not decide what is sent. + * + * The request is synchronous and can take minutes - the Threads connector alone + * is configured with a 90 s timeout - so callers pass a signal and must treat an + * abort as "may still be running", not as "did not publish". + */ +export const publishMessageNow = async ( + url: string, + options?: { signal?: AbortSignal } +): Promise => { + const settings = getApiSettings(); + + if (!isApiConfigured()) { + throw new Error("API not configured. Check the Content Maestro settings."); + } + + const response = await fetch(`${settings.contentMaestro.apiBaseUrl}/api/message/publish`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${settings.contentMaestro.apiBearerToken}`, + }, + body: JSON.stringify({ url }), + signal: options?.signal, + }); + + if (!response.ok) { + if (response.status === 429) { + throw new Error("Rate limit exceeded. Please try again later."); + } + // Content Maestro answers errors as plain text. + const detail = (await response.text()).trim(); + throw new Error(detail || `Failed to publish the repository: ${response.status}`); + } + + return response.json(); +}; + export const updateCronStatus = async (name: string, is_active: boolean): Promise => { const settings = getApiSettings(); const isConfigured = isApiConfigured(); diff --git a/src/components/ui/base/dialog.tsx b/src/components/ui/base/dialog.tsx index 6297877..fab32c0 100644 --- a/src/components/ui/base/dialog.tsx +++ b/src/components/ui/base/dialog.tsx @@ -27,10 +27,19 @@ const DialogOverlay = React.forwardRef< )) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName +interface DialogContentProps + extends React.ComponentPropsWithoutRef { + /** + * Blocks the built-in close button while an operation must not be interrupted - + * closing mid-run would throw away the only place its result is shown. + */ + closeDisabled?: boolean; +} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + DialogContentProps +>(({ className, children, closeDisabled, ...props }, ref) => ( {children} - + Close diff --git a/src/components/ui/business/publish-repository-dialog.tsx b/src/components/ui/business/publish-repository-dialog.tsx new file mode 100644 index 0000000..57f9002 --- /dev/null +++ b/src/components/ui/business/publish-repository-dialog.tsx @@ -0,0 +1,297 @@ +import { useEffect, useRef, useState } from 'react'; +import { AlertTriangle, Check, Clock, Loader2, Send, X } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../base/dialog'; +import { Button } from '../base/button'; +import { IntegrationIcon, getLanguageFlag } from './integration-icons'; +import { publishMessageNow, type RetryMessageResult } from '../../../api/index'; +import { maestroErrorMessage } from '../../../utils/api-error'; +import { + buildPublishRows, + publishedSomething, + summarizePublishResult, + type PublishIntegration, + type PublishRow, +} from '../../../utils/message-publish'; +import type { Repository } from '../../../types'; + +interface PublishRepositoryDialogProps { + /** The repository being published; null keeps the dialog closed. */ + repository: Repository | null; + /** True when this repository already sits at the head of the publication queue. */ + isNext: boolean; + isApiReady: boolean; + /** The enabled integrations a publish-now will reach. */ + integrations: PublishIntegration[]; + onClose: () => void; + /** Promotes to the head of the queue - the caller's existing handler and toast. */ + onPromote: (repo: Repository) => Promise; + /** Called once the publication may have changed anything, so the caller refreshes. */ + onPublished: () => void | Promise; +} + +type Phase = 'idle' | 'promoting' | 'publishing' | 'done'; + +/** + * A safety net rather than a cancellation: aborting the request does not stop the + * run on the server, so the timeout has to sit well above the slowest legitimate + * publication (image generation plus the Threads connector's own 90 s timeout). + */ +const PUBLISH_TIMEOUT_MS = 240_000; + +const toneStyles = { + success: 'bg-success/20 text-success', + partial: 'bg-warning/10 text-warning', + error: 'bg-destructive/10 text-destructive', +} as const; + +export function PublishRepositoryDialog({ + repository, + isNext, + isApiReady, + integrations, + onClose, + onPromote, + onPublished, +}: PublishRepositoryDialogProps) { + const [phase, setPhase] = useState('idle'); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + // Reopening on another row must never show the previous run's rows. + useEffect(() => { + setPhase('idle'); + setResult(null); + setError(null); + }, [repository?.id]); + + useEffect(() => () => abortRef.current?.abort(), []); + + const busy = phase === 'promoting' || phase === 'publishing'; + const rows = buildPublishRows(integrations, phase === 'idle' || phase === 'promoting' ? null : result); + const summary = result ? summarizePublishResult(result) : null; + + const handlePromote = async () => { + if (!repository || busy) return; + + setPhase('promoting'); + try { + await onPromote(repository); + onClose(); + } catch { + // The caller already reported it with a toast; the dialog stays open so the + // action can be retried or the other one picked instead. + } finally { + setPhase('idle'); + } + }; + + const handlePublishNow = async () => { + if (!repository || phase !== 'idle') return; + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), PUBLISH_TIMEOUT_MS); + abortRef.current = controller; + setPhase('publishing'); + setError(null); + let answered = false; + + try { + const publishResult = await publishMessageNow(repository.url, { signal: controller.signal }); + answered = true; + setResult(publishResult); + if (publishedSomething(publishResult)) { + await onPublished(); + } + } catch (err) { + setError( + controller.signal.aborted + ? 'Content Maestro did not answer in time. The publication may still be running — check Cron History before trying again.' + : maestroErrorMessage(err) + ); + // The run may have published before the connection broke, so the list has + // to be refreshed either way. + await onPublished(); + } finally { + window.clearTimeout(timeout); + abortRef.current = null; + // A request that never produced a result leaves the choices in place, with + // the error above them: refusals (a cron run holding the lock, an item + // already published) are the kind of thing that is worth another try, and + // the backend refuses a duplicate on its own. + setPhase(answered ? 'done' : 'idle'); + } + }; + + const renderRow = (row: PublishRow) => { + const flag = getLanguageFlag(row.textLanguage); + + return ( +
  • +
    + + {row.name} + {flag && } + + {row.state === 'pending' && ( + <> + + Sending... + + )} + {row.state === 'success' && ( + <> + + Sent + + )} + {row.state === 'failure' && ( + <> + + Failed + + )} + +
    + {row.state === 'failure' && row.error && ( + {row.error} + )} +
  • + ); + }; + + return ( + { + if (!open && !busy) onClose(); + }} + > + busy && event.preventDefault()} + onPointerDownOutside={event => busy && event.preventDefault()} + > + + Publish repository + + {repository?.url.replace('https://github.com/', '')} + + + +
    + {!isApiReady && ( +

    + Content Maestro API is not configured — check Settings. +

    + )} + + {phase === 'idle' && isApiReady && ( +
    +

    + Publish next moves this + repository to the head of the queue; the scheduled run publishes it. +

    +

    + Publish now sends it + immediately to every enabled integration. +

    +
    + )} + + {summary && ( +

    {summary.text}

    + )} + + {error && ( +

    + + {error} +

    + )} + + {result?.posted_error && ( +

    + Published, but the repository could not be marked as posted — the scheduled run may + publish it again. ({result.posted_error}) +

    + )} + + {rows.length > 0 ? ( +
    +

    + {phase === 'done' ? 'Integrations:' : 'Will publish to:'} +

    +
      {rows.map(renderRow)}
    +
    + ) : isApiReady ? ( +

    + No integration is enabled, so there is nothing to publish to. Enable one on the + Integrations tab. +

    + ) : null} +
    + + + {phase === 'done' ? ( + + ) : ( + <> + + + + + )} + +
    +
    + ); +} diff --git a/src/components/ui/business/repository-list.tsx b/src/components/ui/business/repository-list.tsx index bb8b08d..f7afb1b 100644 --- a/src/components/ui/business/repository-list.tsx +++ b/src/components/ui/business/repository-list.tsx @@ -1,5 +1,6 @@ // React imports // (no state needed since the Posts section is no longer collapsible) +import type { ApiConfig } from '@/api/api-configs'; import type { Repository, RepositorySortBy, RepositorySortOrder } from '@/types'; import { Filter, ChevronDown } from 'lucide-react'; import { filterRepositories, countActiveFilters } from '@/utils/repositoryListUtils'; @@ -17,6 +18,8 @@ interface RepositoryListProps { fetchRepositories: (posted?: boolean, append?: boolean, fetchAll?: boolean, itemsPerPage?: number, sortBy?: RepositorySortBy, sortOrder?: RepositorySortOrder, page?: number, forceFetch?: boolean) => Promise; fetchPreviews: (forceFetch?: boolean) => Promise; nextPostId?: number; + /** Integration configs, so the publish dialog can list the enabled ones. */ + integrations?: ApiConfig[]; totalItems: number; totalPages: number; currentPage: number; @@ -31,6 +34,7 @@ export function RepositoryList({ fetchRepositories, fetchPreviews, nextPostId, + integrations, totalItems, totalPages, currentPage: initialPage, @@ -139,6 +143,7 @@ export function RepositoryList({ itemsPerPage={itemsPerPage} searchTerm={searchTerm} nextPostId={nextPostId} + integrations={integrations} onRepositoryUpdate={handleRepositoryUpdate} onRepositoryArchived={onRepositoryArchived} /> @@ -153,6 +158,7 @@ export function RepositoryList({ itemsPerPage={itemsPerPage} searchTerm={searchTerm} nextPostId={nextPostId} + integrations={integrations} onRepositoryUpdate={handleRepositoryUpdate} onRepositoryArchived={onRepositoryArchived} /> diff --git a/src/components/ui/business/repository-mobile-view.tsx b/src/components/ui/business/repository-mobile-view.tsx index 05808a0..457fc90 100644 --- a/src/components/ui/business/repository-mobile-view.tsx +++ b/src/components/ui/business/repository-mobile-view.tsx @@ -8,6 +8,8 @@ import { Pencil, Check, X, Trash2, AlertCircle, Archive, Send } from 'lucide-rea import { describeArchiveFailure, isStaleArchiveFailure, summarizeArchiveResult } from '@/utils/archiveUtils'; import { toast } from '@/components/ui/common/toast-config'; import { ConfirmDialog } from '@/components/ui/common/confirm-dialog'; +import { PublishRepositoryDialog } from './publish-repository-dialog'; +import { enabledIntegrations } from '@/utils/message-publish'; import { RepositoryLink } from '@/components/ui/common/repository-link'; import { Button } from '../base/button'; import { Label } from '../base/label'; @@ -24,6 +26,7 @@ export function RepositoryMobileView({ itemsPerPage, searchTerm, nextPostId, + integrations, onRepositoryUpdate, onRepositoryArchived }: RepositoryMobileViewProps) { @@ -34,7 +37,7 @@ export function RepositoryMobileView({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [showArchiveConfirm, setShowArchiveConfirm] = useState(null); const [archivingId, setArchivingId] = useState(null); - const [showPromoteConfirm, setShowPromoteConfirm] = useState(null); + const [publishTarget, setPublishTarget] = useState(null); const [promotingId, setPromotingId] = useState(null); const textInputRef = useRef(null); @@ -211,7 +214,12 @@ export function RepositoryMobileView({ try { setPromotingId(repo.id); - await promoteRepositoryToNext({ id: repo.id }); + const result = await promoteRepositoryToNext({ id: repo.id }); + // An unconfigured API answers with an error payload instead of throwing, and + // reporting success for a request that never left the browser is a lie. + if (result.status === 'error') { + throw new Error(result.message); + } toast.success(`Repository will be published next`, { ...toastOptions, @@ -227,6 +235,8 @@ export function RepositoryMobileView({ ...toastOptions, id: `promote-error-${repo.id}` }); + // Rethrown so the publish dialog keeps itself open on a failed promotion. + throw error; } finally { setPromotingId(null); } @@ -355,11 +365,11 @@ export function RepositoryMobileView({ @@ -459,20 +469,18 @@ export function RepositoryMobileView({ onCancel={() => setShowArchiveConfirm(null)} /> - { - if (showPromoteConfirm) { - handlePromoteRepository(showPromoteConfirm); + setPublishTarget(null)} + onPromote={handlePromoteRepository} + onPublished={async () => { + if (onRepositoryUpdate) { + await onRepositoryUpdate(); } - setShowPromoteConfirm(null); }} - onCancel={() => setShowPromoteConfirm(null)} /> ); diff --git a/src/components/ui/business/repository-table.tsx b/src/components/ui/business/repository-table.tsx index 992ba71..2d8cc24 100644 --- a/src/components/ui/business/repository-table.tsx +++ b/src/components/ui/business/repository-table.tsx @@ -7,6 +7,8 @@ import { Pencil, Check, X, Trash2, AlertCircle, Archive, ChevronDown, ChevronUp, import { describeArchiveFailure, isStaleArchiveFailure, summarizeArchiveResult } from '@/utils/archiveUtils'; import { toast } from '../common/toast-config'; import { ConfirmDialog } from '../common/confirm-dialog'; +import { PublishRepositoryDialog } from './publish-repository-dialog'; +import { enabledIntegrations } from '@/utils/message-publish'; import { Table, TableBody, @@ -72,6 +74,7 @@ export function RepositoryTable({ itemsPerPage, searchTerm, nextPostId, + integrations, onRepositoryUpdate, onRepositoryArchived }: RepositoryTableProps) { @@ -82,7 +85,7 @@ export function RepositoryTable({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [showArchiveConfirm, setShowArchiveConfirm] = useState(null); const [archivingId, setArchivingId] = useState(null); - const [showPromoteConfirm, setShowPromoteConfirm] = useState(null); + const [publishTarget, setPublishTarget] = useState(null); const [promotingId, setPromotingId] = useState(null); const textInputRef = useRef(null); @@ -259,7 +262,12 @@ export function RepositoryTable({ try { setPromotingId(repo.id); - await promoteRepositoryToNext({ id: repo.id }); + const result = await promoteRepositoryToNext({ id: repo.id }); + // An unconfigured API answers with an error payload instead of throwing, and + // reporting success for a request that never left the browser is a lie. + if (result.status === 'error') { + throw new Error(result.message); + } toast.success(`Repository will be published next`, { ...toastOptions, @@ -275,6 +283,8 @@ export function RepositoryTable({ ...toastOptions, id: `promote-error-${repo.id}` }); + // Rethrown so the publish dialog keeps itself open on a failed promotion. + throw error; } finally { setPromotingId(null); } @@ -411,16 +421,16 @@ export function RepositoryTable({ - {repo.id === nextPostId ? 'Already next' : 'Publish next'} + Publish )} @@ -546,20 +556,18 @@ export function RepositoryTable({ onCancel={() => setShowArchiveConfirm(null)} /> - { - if (showPromoteConfirm) { - handlePromoteRepository(showPromoteConfirm); + setPublishTarget(null)} + onPromote={handlePromoteRepository} + onPublished={async () => { + if (onRepositoryUpdate) { + await onRepositoryUpdate(); } - setShowPromoteConfirm(null); }} - onCancel={() => setShowPromoteConfirm(null)} /> ); diff --git a/src/components/ui/layout/dashboard-content.tsx b/src/components/ui/layout/dashboard-content.tsx index 83673fa..2df21ce 100644 --- a/src/components/ui/layout/dashboard-content.tsx +++ b/src/components/ui/layout/dashboard-content.tsx @@ -408,6 +408,7 @@ export const DashboardContent = ({ fetchRepositories={fetchRepositories} fetchPreviews={fetchPreviews} nextPostId={nextPost?.id} + integrations={apiConfigs} currentPage={pagination.currentPage} pageSize={pagination.pageSize} totalPages={pagination.totalPages} diff --git a/src/types/repositoryList.ts b/src/types/repositoryList.ts index 4771c91..93959db 100644 --- a/src/types/repositoryList.ts +++ b/src/types/repositoryList.ts @@ -1,3 +1,4 @@ +import type { ApiConfig } from '../api/api-configs'; import type { Repository, RepositorySortBy, RepositorySortOrder, RepositoryStatusFilter } from '../types'; export interface TruncatedTextProps { @@ -30,6 +31,8 @@ export interface RepositoryTableProps { itemsPerPage: number; searchTerm: string; nextPostId?: number; + /** Integration configs, so the publish dialog can list the enabled ones. */ + integrations?: ApiConfig[]; onRepositoryUpdate?: () => void | Promise; onRepositoryArchived?: () => void | Promise; } @@ -42,6 +45,8 @@ export interface RepositoryMobileViewProps { itemsPerPage: number; searchTerm: string; nextPostId?: number; + /** Integration configs, so the publish dialog can list the enabled ones. */ + integrations?: ApiConfig[]; onRepositoryUpdate?: () => void | Promise; onRepositoryArchived?: () => void | Promise; } diff --git a/src/utils/message-publish.test.ts b/src/utils/message-publish.test.ts new file mode 100644 index 0000000..8a478b9 --- /dev/null +++ b/src/utils/message-publish.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import type { ApiConfig } from '@/api/api-configs'; +import type { RetryMessageResult } from '@/api/index'; +import { + buildPublishRows, + enabledIntegrations, + publishedSomething, + summarizePublishResult, +} from './message-publish'; + +const config = (overrides: Partial): ApiConfig => ({ + id: 1, + name: 'threads', + url: 'http://threads-connector:9016/threads/post', + method: 'POST', + auth_type: 'api_key', + token_env_var: 'THREADS_API_KEY', + token_header: 'X-API-Key', + content_type: 'json', + timeout: 90, + success_code: 200, + enabled: true, + response_type: 'json', + text_language: 'en', + socialify_image: false, + default_json_body: '', + updated_at: '2026-08-17T16:00:00Z', + ...overrides, +}); + +const result = (overrides: Partial): RetryMessageResult => ({ + url: 'https://github.com/a/b', + status: 1, + message: 'Manual publish: https://github.com/a/b sent to: threads', + ...overrides, +}); + +const telegram = { name: 'telegram', textLanguage: 'uk' }; +const threads = { name: 'threads', textLanguage: 'en' }; + +describe('enabledIntegrations', () => { + it('keeps only the enabled ones, sorted the way Content Maestro reports them', () => { + expect(enabledIntegrations([ + config({ name: 'threads', text_language: 'en' }), + config({ name: 'bluesky', enabled: false }), + config({ name: 'telegram', text_language: 'uk' }), + ])).toEqual([telegram, threads]); + }); + + it('returns nothing when every integration is disabled', () => { + expect(enabledIntegrations([config({ enabled: false })])).toEqual([]); + }); +}); + +describe('buildPublishRows', () => { + it('shows every expected integration as pending before the run answers', () => { + expect(buildPublishRows([telegram, threads])).toEqual([ + { name: 'telegram', state: 'pending', textLanguage: 'uk' }, + { name: 'threads', state: 'pending', textLanguage: 'en' }, + ]); + }); + + it('resolves the rows from the reported outcomes', () => { + expect(buildPublishRows([telegram, threads], result({ + status: 2, + succeeded: ['telegram'], + failed: ['threads'], + outcomes: [ + { api_name: 'telegram', success: true }, + { api_name: 'threads', success: false, error: 'API request failed with status 500' }, + ], + }))).toEqual([ + { name: 'telegram', state: 'success', error: undefined, textLanguage: 'uk' }, + { name: 'threads', state: 'failure', error: 'API request failed with status 500', textLanguage: 'en' }, + ]); + }); + + // Go marshals empty slices as null, so the outcomes can be missing entirely + // while the name lists still say what happened. + it('falls back to the name lists when no outcomes came back', () => { + expect(buildPublishRows([telegram, threads], result({ + status: 2, + succeeded: ['telegram'], + failed: ['threads'], + outcomes: null, + }))).toEqual([ + { name: 'telegram', state: 'success', error: undefined, textLanguage: 'uk' }, + { name: 'threads', state: 'failure', error: 'unknown error', textLanguage: 'en' }, + ]); + }); + + it('names a failure with no message rather than leaving it blank', () => { + expect(buildPublishRows([threads], result({ + status: 0, + outcomes: [{ api_name: 'threads', success: false, error: ' ' }], + }))[0]).toEqual({ name: 'threads', state: 'failure', error: 'unknown error', textLanguage: 'en' }); + }); + + // An integration enabled in another tab is published to but is missing from + // this dashboard's cached list, so the run is what decides it exists. + it('includes an integration the run reported but the dashboard did not expect', () => { + expect(buildPublishRows([threads], result({ + succeeded: ['threads', 'bluesky'], + outcomes: [ + { api_name: 'threads', success: true }, + { api_name: 'bluesky', success: true }, + ], + }))).toEqual([ + { name: 'threads', state: 'success', error: undefined, textLanguage: 'en' }, + { name: 'bluesky', state: 'success', error: undefined, textLanguage: undefined }, + ]); + }); + + // The opposite case: an integration disabled since the list was cached never + // gets an outcome, and silently showing it as fine would be a lie. + it('treats an expected integration with no reported outcome as a failure', () => { + expect(buildPublishRows([telegram, threads], result({ + succeeded: ['threads'], + outcomes: [{ api_name: 'threads', success: true }], + }))).toEqual([ + { name: 'telegram', state: 'failure', error: 'no result reported', textLanguage: 'uk' }, + { name: 'threads', state: 'success', error: undefined, textLanguage: 'en' }, + ]); + }); +}); + +describe('summarizePublishResult', () => { + it('reports a full success', () => { + const summary = summarizePublishResult(result({ status: 1, succeeded: ['telegram', 'threads'] })); + expect(summary.tone).toBe('success'); + expect(summary.text).toContain('all 2 integrations'); + }); + + it('reports a partial success and points at the retry', () => { + const summary = summarizePublishResult(result({ + status: 2, + succeeded: ['telegram'], + failed: ['threads'], + })); + expect(summary.tone).toBe('partial'); + expect(summary.text).toContain('1 of 2'); + expect(summary.text).toContain('Cron History'); + }); + + it('reports a run that published nothing and says the item stays queued', () => { + const summary = summarizePublishResult(result({ status: 0, succeeded: null, failed: ['threads'] })); + expect(summary.tone).toBe('error'); + expect(summary.text).toContain('stays in the queue'); + }); +}); + +describe('publishedSomething', () => { + it.each([ + ['no result', undefined, false], + ['a null list', result({ succeeded: null }), false], + ['an empty list', result({ succeeded: [] }), false], + ['one integration', result({ succeeded: ['threads'] }), true], + ])('%s', (_name, value, expected) => { + expect(publishedSomething(value as RetryMessageResult | undefined)).toBe(expected); + }); +}); diff --git a/src/utils/message-publish.ts b/src/utils/message-publish.ts new file mode 100644 index 0000000..4ccce4a --- /dev/null +++ b/src/utils/message-publish.ts @@ -0,0 +1,129 @@ +import type { ApiConfig } from '../api/api-configs'; +import type { RetryMessageResult } from '../api/index'; + +export interface PublishIntegration { + name: string; + textLanguage?: string; +} + +export type PublishRowState = 'pending' | 'success' | 'failure'; + +export interface PublishRow { + name: string; + state: PublishRowState; + error?: string; + textLanguage?: string; +} + +export interface PublishSummary { + tone: 'success' | 'partial' | 'error'; + text: string; +} + +/** + * The integrations a publish-now will reach, in the order Content Maestro reports + * them (it sorts by name, because its own configuration is a map). Matching that + * order keeps the list the dialog shows before the run identical to the list of + * results it shows afterwards. + */ +export const enabledIntegrations = (configs: ApiConfig[]): PublishIntegration[] => + configs + .filter(config => config.enabled) + .map(config => ({ name: config.name, textLanguage: config.text_language })) + .sort((a, b) => a.name.localeCompare(b.name)); + +/** + * Merges what the dashboard expected with what the run reported. + * + * Content Maestro is authoritative: it resolves the enabled integrations at + * request time, so a run can report an integration this dashboard's cached list + * does not know about (enabled in another tab) and can omit one the cached list + * still carries (disabled since). Rows are therefore built from both, and an + * expected integration with no reported outcome is a failure rather than a + * silent success. + */ +export const buildPublishRows = ( + expected: PublishIntegration[], + result?: RetryMessageResult | null +): PublishRow[] => { + const languages = new Map(expected.map(integration => [integration.name, integration.textLanguage])); + + if (!result) { + return expected.map(integration => ({ + name: integration.name, + state: 'pending', + textLanguage: integration.textLanguage, + })); + } + + const rows = new Map(); + + const record = (name: string, state: PublishRowState, error?: string) => { + rows.set(name, { name, state, error, textLanguage: languages.get(name) }); + }; + + const outcomes = result.outcomes ?? []; + if (outcomes.length > 0) { + for (const outcome of outcomes) { + if (outcome.success) { + record(outcome.api_name, 'success'); + } else { + record(outcome.api_name, 'failure', outcome.error?.trim() || 'unknown error'); + } + } + } else { + // Go marshals empty slices as null, so a run can come back with the name + // lists filled in and no outcomes at all. + for (const name of result.succeeded ?? []) { + record(name, 'success'); + } + for (const name of result.failed ?? []) { + record(name, 'failure', 'unknown error'); + } + } + + for (const integration of expected) { + if (!rows.has(integration.name)) { + record(integration.name, 'failure', 'no result reported'); + } + } + + // Expected integrations first, in their own order; anything the run added on + // top of them goes after, so the list the user was looking at does not jump. + const ordered = expected + .map(integration => rows.get(integration.name)) + .filter((row): row is PublishRow => row !== undefined); + const extras = [...rows.values()].filter(row => !languages.has(row.name)); + + return [...ordered, ...extras]; +}; + +/** Wording and tone of the banner above the result rows. */ +export const summarizePublishResult = (result: RetryMessageResult): PublishSummary => { + const succeeded = result.succeeded ?? []; + const failed = result.failed ?? []; + + if (succeeded.length === 0) { + return { + tone: 'error', + text: 'Not published: no integration accepted the post. The repository stays in the queue.', + }; + } + + if (failed.length > 0) { + return { + tone: 'partial', + text: `Published to ${succeeded.length} of ${succeeded.length + failed.length} integrations. ` + + 'The repository left the queue, so finish the rest with "Publish again" in Cron History.', + }; + } + + return { + tone: 'success', + text: `Published to ${succeeded.length === 1 ? 'the integration' : `all ${succeeded.length} integrations`}.`, + }; +}; + +/** True when at least one integration accepted the post, so the parent must refresh. */ +export const publishedSomething = (result?: RetryMessageResult | null): boolean => + (result?.succeeded ?? []).length > 0; From 57c148267812b950323c034c7278e4719b507b8f Mon Sep 17 00:00:00 2001 From: sigmanor Date: Wed, 2 Sep 2026 21:02:12 +0300 Subject: [PATCH 2/3] fix(repositories): keep the publish dialog honest about what it did Review of the previous commit found four ways the dialog could mislead or trap the user. One dialog instance serves every row, and nothing stopped a row's send button from retargeting it while a publication was in flight. Repo A's request would then land on a dialog now titled with repo B, showing A's outcome as B's. The send button is now disabled while the dialog is working, the request is aborted when the dialog is retargeted, and the response is dropped unless the dialog still points at the repository it was made for. The summary banner claimed the repository "left the queue" from the success counts alone, so a run whose posted marking failed contradicted the posted_error warning printed right below it - and sent the reader to "Publish again" for a repost the scheduled run was about to do anyway. What it says about the queue now follows `posted`. Dismissal was blocked for any in-flight request, including the promotion, which carries no timeout of its own: a stalled content-alchemist left a dialog that could not be closed at all, where the confirm dialog it replaced could always be cancelled. Only a publication blocks dismissal now, because only its per-integration result exists nowhere else. An empty integration list read as "no integration is enabled" even while the configs were still loading, so opening the dialog during a cold load refused to publish for no reason. The loading flag is threaded through and says so. Also: the send button lost the "already next" wording when it stopped being disabled for that row, leaving screen readers with no way to tell that row apart - restored as part of the label. And promoting a repository the queue has since moved past returned silently, closing the dialog as if it had worked; it now says what happened. --- .../ui/business/publish-repository-dialog.tsx | 82 +++++++++++++++---- .../ui/business/repository-list.tsx | 5 ++ .../ui/business/repository-mobile-view.tsx | 23 +++++- .../ui/business/repository-table.tsx | 25 +++++- .../ui/layout/dashboard-content.tsx | 1 + src/types/repositoryList.ts | 4 + src/utils/message-publish.test.ts | 34 +++++++- src/utils/message-publish.ts | 20 ++++- 8 files changed, 164 insertions(+), 30 deletions(-) diff --git a/src/components/ui/business/publish-repository-dialog.tsx b/src/components/ui/business/publish-repository-dialog.tsx index 57f9002..aad2ee0 100644 --- a/src/components/ui/business/publish-repository-dialog.tsx +++ b/src/components/ui/business/publish-repository-dialog.tsx @@ -29,7 +29,14 @@ interface PublishRepositoryDialogProps { isApiReady: boolean; /** The enabled integrations a publish-now will reach. */ integrations: PublishIntegration[]; + /** True while the integration list is still being fetched. */ + integrationsLoading?: boolean; onClose: () => void; + /** + * Reports whether a request is in flight, so the caller can stop the row + * buttons from retargeting this dialog mid-publication. + */ + onBusyChange?: (busy: boolean) => void; /** Promotes to the head of the queue - the caller's existing handler and toast. */ onPromote: (repo: Repository) => Promise; /** Called once the publication may have changed anything, so the caller refreshes. */ @@ -56,7 +63,9 @@ export function PublishRepositoryDialog({ isNext, isApiReady, integrations, + integrationsLoading = false, onClose, + onBusyChange, onPromote, onPublished, }: PublishRepositoryDialogProps) { @@ -64,9 +73,17 @@ export function PublishRepositoryDialog({ const [result, setResult] = useState(null); const [error, setError] = useState(null); const abortRef = useRef(null); + // The repository the state on screen belongs to. One dialog instance serves + // every row, so a request that outlives its own target must not write its + // outcome into the dialog somebody has since pointed at another repository. + const targetIdRef = useRef(repository?.id ?? null); - // Reopening on another row must never show the previous run's rows. + // Reopening on another row must never show the previous run's rows, and the + // request that filled them has to be dropped rather than left to land later. useEffect(() => { + abortRef.current?.abort(); + abortRef.current = null; + targetIdRef.current = repository?.id ?? null; setPhase('idle'); setResult(null); setError(null); @@ -75,6 +92,10 @@ export function PublishRepositoryDialog({ useEffect(() => () => abortRef.current?.abort(), []); const busy = phase === 'promoting' || phase === 'publishing'; + + useEffect(() => { + onBusyChange?.(busy); + }, [busy, onBusyChange]); const rows = buildPublishRows(integrations, phase === 'idle' || phase === 'promoting' ? null : result); const summary = result ? summarizePublishResult(result) : null; @@ -96,8 +117,13 @@ export function PublishRepositoryDialog({ const handlePublishNow = async () => { if (!repository || phase !== 'idle') return; + const targetId = repository.id; const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), PUBLISH_TIMEOUT_MS); + const timedOut = { value: false }; + const timeout = window.setTimeout(() => { + timedOut.value = true; + controller.abort(); + }, PUBLISH_TIMEOUT_MS); abortRef.current = controller; setPhase('publishing'); setError(null); @@ -106,13 +132,17 @@ export function PublishRepositoryDialog({ try { const publishResult = await publishMessageNow(repository.url, { signal: controller.signal }); answered = true; + if (targetIdRef.current !== targetId) return; setResult(publishResult); if (publishedSomething(publishResult)) { await onPublished(); } } catch (err) { + // Retargeting the dialog aborts this request on purpose; its outcome + // belongs to a repository nobody is looking at any more. + if (targetIdRef.current !== targetId) return; setError( - controller.signal.aborted + timedOut.value ? 'Content Maestro did not answer in time. The publication may still be running — check Cron History before trying again.' : maestroErrorMessage(err) ); @@ -121,12 +151,16 @@ export function PublishRepositoryDialog({ await onPublished(); } finally { window.clearTimeout(timeout); - abortRef.current = null; - // A request that never produced a result leaves the choices in place, with - // the error above them: refusals (a cron run holding the lock, an item - // already published) are the kind of thing that is worth another try, and - // the backend refuses a duplicate on its own. - setPhase(answered ? 'done' : 'idle'); + if (abortRef.current === controller) { + abortRef.current = null; + } + if (targetIdRef.current === targetId) { + // A request that never produced a result leaves the choices in place, with + // the error above them: refusals (a cron run holding the lock, an item + // already published) are the kind of thing that is worth another try, and + // the backend refuses a duplicate on its own. + setPhase(answered ? 'done' : 'idle'); + } } }; @@ -180,14 +214,21 @@ export function PublishRepositoryDialog({ { - if (!open && !busy) onClose(); + if (!open && phase !== 'publishing') onClose(); }} > + {/* + Only a publication in flight blocks dismissal: its per-integration result + exists nowhere else, so closing mid-run would throw it away. Promoting is + not blocked - it has no in-dialog result to lose, and the promote request + carries no timeout, so blocking it would make a stalled backend leave a + dialog that cannot be closed at all. + */} busy && event.preventDefault()} - onPointerDownOutside={event => busy && event.preventDefault()} + closeDisabled={phase === 'publishing'} + onEscapeKeyDown={event => phase === 'publishing' && event.preventDefault()} + onPointerDownOutside={event => phase === 'publishing' && event.preventDefault()} > Publish repository @@ -241,12 +282,19 @@ export function PublishRepositoryDialog({
      {rows.map(renderRow)}
    - ) : isApiReady ? ( + ) : !isApiReady ? null : integrationsLoading ? ( + // An empty list while the configs are still loading is not the same as + // no integration being enabled, and saying so would be wrong. +

    + + Loading integrations... +

    + ) : (

    No integration is enabled, so there is nothing to publish to. Enable one on the Integrations tab.

    - ) : null} + )} @@ -256,7 +304,7 @@ export function PublishRepositoryDialog({ ) : ( <> - @@ -474,7 +487,9 @@ export function RepositoryMobileView({ isNext={publishTarget !== null && publishTarget.id === nextPostId} isApiReady={isApiReady} integrations={enabledIntegrations(integrations ?? [])} + integrationsLoading={integrationsLoading} onClose={() => setPublishTarget(null)} + onBusyChange={setPublishBusy} onPromote={handlePromoteRepository} onPublished={async () => { if (onRepositoryUpdate) { diff --git a/src/components/ui/business/repository-table.tsx b/src/components/ui/business/repository-table.tsx index 2d8cc24..50944a2 100644 --- a/src/components/ui/business/repository-table.tsx +++ b/src/components/ui/business/repository-table.tsx @@ -75,6 +75,7 @@ export function RepositoryTable({ searchTerm, nextPostId, integrations, + integrationsLoading, onRepositoryUpdate, onRepositoryArchived }: RepositoryTableProps) { @@ -86,6 +87,9 @@ export function RepositoryTable({ const [showArchiveConfirm, setShowArchiveConfirm] = useState(null); const [archivingId, setArchivingId] = useState(null); const [publishTarget, setPublishTarget] = useState(null); + // One dialog instance serves every row, so retargeting it while it is working + // would leave a request writing its outcome into somebody else's repository. + const [publishBusy, setPublishBusy] = useState(false); const [promotingId, setPromotingId] = useState(null); const textInputRef = useRef(null); @@ -258,10 +262,19 @@ export function RepositoryTable({ }; const handlePromoteRepository = async (repo: Repository) => { - if (repo.posted || repo.id === nextPostId || promotingId !== null) return; + if (promotingId !== null) return; try { setPromotingId(repo.id); + // nextPostId is a render-time value, so another tab - or the cron - can have + // moved the queue on since this row was drawn. Saying so beats closing the + // dialog as if a promotion that never happened had succeeded. + if (repo.posted) { + throw new Error('Repository is already published'); + } + if (repo.id === nextPostId) { + throw new Error('Repository is already next in the queue'); + } const result = await promoteRepositoryToNext({ id: repo.id }); // An unconfigured API answers with an error payload instead of throwing, and // reporting success for a request that never left the browser is a lie. @@ -422,15 +435,17 @@ export function RepositoryTable({ variant="ghost" size="icon" onClick={() => setPublishTarget(repo)} - disabled={!isApiReady || promotingId !== null} - aria-label="Publish" + disabled={!isApiReady || promotingId !== null || publishBusy} + aria-label={repo.id === nextPostId ? 'Publish (already next in the queue)' : 'Publish'} className="h-8 w-8 text-muted-foreground hover:text-foreground disabled:opacity-50" > - Publish + + {repo.id === nextPostId ? 'Publish (already next in the queue)' : 'Publish'} + )} @@ -561,7 +576,9 @@ export function RepositoryTable({ isNext={publishTarget !== null && publishTarget.id === nextPostId} isApiReady={isApiReady} integrations={enabledIntegrations(integrations ?? [])} + integrationsLoading={integrationsLoading} onClose={() => setPublishTarget(null)} + onBusyChange={setPublishBusy} onPromote={handlePromoteRepository} onPublished={async () => { if (onRepositoryUpdate) { diff --git a/src/components/ui/layout/dashboard-content.tsx b/src/components/ui/layout/dashboard-content.tsx index 2df21ce..36cfa42 100644 --- a/src/components/ui/layout/dashboard-content.tsx +++ b/src/components/ui/layout/dashboard-content.tsx @@ -409,6 +409,7 @@ export const DashboardContent = ({ fetchPreviews={fetchPreviews} nextPostId={nextPost?.id} integrations={apiConfigs} + integrationsLoading={apiConfigsLoading} currentPage={pagination.currentPage} pageSize={pagination.pageSize} totalPages={pagination.totalPages} diff --git a/src/types/repositoryList.ts b/src/types/repositoryList.ts index 93959db..f398005 100644 --- a/src/types/repositoryList.ts +++ b/src/types/repositoryList.ts @@ -33,6 +33,8 @@ export interface RepositoryTableProps { nextPostId?: number; /** Integration configs, so the publish dialog can list the enabled ones. */ integrations?: ApiConfig[]; + /** True while those configs are still being fetched. */ + integrationsLoading?: boolean; onRepositoryUpdate?: () => void | Promise; onRepositoryArchived?: () => void | Promise; } @@ -47,6 +49,8 @@ export interface RepositoryMobileViewProps { nextPostId?: number; /** Integration configs, so the publish dialog can list the enabled ones. */ integrations?: ApiConfig[]; + /** True while those configs are still being fetched. */ + integrationsLoading?: boolean; onRepositoryUpdate?: () => void | Promise; onRepositoryArchived?: () => void | Promise; } diff --git a/src/utils/message-publish.test.ts b/src/utils/message-publish.test.ts index 8a478b9..7b18e37 100644 --- a/src/utils/message-publish.test.ts +++ b/src/utils/message-publish.test.ts @@ -126,7 +126,11 @@ describe('buildPublishRows', () => { describe('summarizePublishResult', () => { it('reports a full success', () => { - const summary = summarizePublishResult(result({ status: 1, succeeded: ['telegram', 'threads'] })); + const summary = summarizePublishResult(result({ + status: 1, + succeeded: ['telegram', 'threads'], + posted: true, + })); expect(summary.tone).toBe('success'); expect(summary.text).toContain('all 2 integrations'); }); @@ -136,12 +140,40 @@ describe('summarizePublishResult', () => { status: 2, succeeded: ['telegram'], failed: ['threads'], + posted: true, })); expect(summary.tone).toBe('partial'); expect(summary.text).toContain('1 of 2'); expect(summary.text).toContain('Cron History'); }); + // Sending someone to "Publish again" for an item that never left the queue + // would have the scheduled run repost it to the integrations that already + // have it, so the queue sentence follows `posted` and not the counts. + it('does not claim the item left the queue when marking it posted failed', () => { + const summary = summarizePublishResult(result({ + status: 2, + succeeded: ['telegram'], + failed: ['threads'], + posted: false, + posted_error: 'context deadline exceeded', + })); + expect(summary.tone).toBe('partial'); + expect(summary.text).toContain('still in the queue'); + expect(summary.text).not.toContain('Cron History'); + }); + + it('downgrades an otherwise complete run that stayed in the queue', () => { + const summary = summarizePublishResult(result({ + status: 1, + succeeded: ['telegram', 'threads'], + posted: false, + posted_error: 'context deadline exceeded', + })); + expect(summary.tone).toBe('partial'); + expect(summary.text).toContain('still in the queue'); + }); + it('reports a run that published nothing and says the item stays queued', () => { const summary = summarizePublishResult(result({ status: 0, succeeded: null, failed: ['threads'] })); expect(summary.tone).toBe('error'); diff --git a/src/utils/message-publish.ts b/src/utils/message-publish.ts index 4ccce4a..7a66d35 100644 --- a/src/utils/message-publish.ts +++ b/src/utils/message-publish.ts @@ -98,7 +98,14 @@ export const buildPublishRows = ( return [...ordered, ...extras]; }; -/** Wording and tone of the banner above the result rows. */ +/** + * Wording and tone of the banner above the result rows. + * + * What it says about the queue follows `posted`, not the success counts: marking + * the repository as published can fail on its own, and telling someone the item + * left the queue when it did not would send them to "Publish again" for a repost + * the scheduled run is about to do anyway. + */ export const summarizePublishResult = (result: RetryMessageResult): PublishSummary => { const succeeded = result.succeeded ?? []; const failed = result.failed ?? []; @@ -114,13 +121,18 @@ export const summarizePublishResult = (result: RetryMessageResult): PublishSumma return { tone: 'partial', text: `Published to ${succeeded.length} of ${succeeded.length + failed.length} integrations. ` - + 'The repository left the queue, so finish the rest with "Publish again" in Cron History.', + + (result.posted + ? 'The repository left the queue, so finish the rest with "Publish again" in Cron History.' + : 'The repository is still in the queue, so the scheduled run will publish it again.'), }; } + const reach = succeeded.length === 1 ? 'the integration' : `all ${succeeded.length} integrations`; + return { - tone: 'success', - text: `Published to ${succeeded.length === 1 ? 'the integration' : `all ${succeeded.length} integrations`}.`, + tone: result.posted === false ? 'partial' : 'success', + text: `Published to ${reach}.` + + (result.posted === false ? ' The repository is still in the queue, so the scheduled run will publish it again.' : ''), }; }; From 5c3a96c0b2490e8f3e019855d3acbc2caf5b751e Mon Sep 17 00:00:00 2001 From: sigmanor Date: Wed, 2 Sep 2026 21:14:45 +0300 Subject: [PATCH 3/3] fix(repositories): stop the integration rows reading as already sending Driving the dialog against a local Content Maestro showed every integration row saying "Sending..." with a spinner before anything had been clicked: the pending state is also the state the list is in before a publication starts. The spinner now belongs to the publishing phase only, so the pre-run list reads as what it is - where the post will go. --- src/components/ui/business/publish-repository-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/business/publish-repository-dialog.tsx b/src/components/ui/business/publish-repository-dialog.tsx index aad2ee0..c65dde0 100644 --- a/src/components/ui/business/publish-repository-dialog.tsx +++ b/src/components/ui/business/publish-repository-dialog.tsx @@ -183,7 +183,7 @@ export function PublishRepositoryDialog({ {row.name} {flag && } - {row.state === 'pending' && ( + {row.state === 'pending' && phase === 'publishing' && ( <> Sending...