feat: per-connection publish governance — auto, 1-admin, majority, consensus
Per the owner's spec: each social connection gets publishGovernance in its fields — unset = manual publish (today), auto = dispatch on approval, admin1/admin_majority/admin_consensus = a publish-phase chatgov poll (subject draft_publish, its own open-poll slot, no collision with review gates). Dispatch holds until the publish poll passes (E3 pre-dispatch check); auto dispatches on approval. Includes migration 033, the PATCH /connections/:id/publish-governance route (chatgov.manage), and 19+31 new tests. Implementation by the branding-panel process (claude/opus), lint and integration fixes here.
This commit is contained in:
parent
f2eb14956e
commit
b89dea5c93
26 changed files with 1637 additions and 31 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Badge, Button } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
|
|
@ -9,7 +9,7 @@ import { api, errorMessage } from "../../../lib/api-client";
|
|||
import { PLATFORMS } from "../../../lib/platforms";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import type { ConnectionSummary, DraftRecord } from "../../../lib/api-types";
|
||||
import type { ConnectionSummary, DraftRecord, PublicationsResponse } from "../../../lib/api-types";
|
||||
|
||||
/**
|
||||
* Calendar = the scheduled slice of the publishing pipeline. There is no
|
||||
|
|
@ -119,6 +119,19 @@ function relativeLabel(iso: string, now: number): { text: string; overdue: boole
|
|||
return { text: delta >= 0 ? `in ${span}` : `${span} overdue`, overdue: delta < 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest dispatch receipt wins: the API orders publications by attempt
|
||||
* number descending, and a retry's outcome supersedes the earlier attempt.
|
||||
*/
|
||||
function latestPublication(response: PublicationsResponse | null | undefined): {
|
||||
state: string;
|
||||
remoteUrl: string | null;
|
||||
} | null {
|
||||
const latest = response?.publications[0];
|
||||
if (latest === undefined) return null;
|
||||
return { state: latest.state, remoteUrl: latest.remoteUrl ?? null };
|
||||
}
|
||||
|
||||
export default function CalendarPage(): React.ReactElement {
|
||||
const { can } = useAuth();
|
||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, SAMPLE, {
|
||||
|
|
@ -134,18 +147,46 @@ export default function CalendarPage(): React.ReactElement {
|
|||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [now, setNow] = useState<number>(Date.now());
|
||||
|
||||
// Dispatch receipts per draft: one GET per scheduled draft (the API has no
|
||||
// bulk endpoint), refreshed with the list. Only drafts that actually have
|
||||
// publications surface a badge.
|
||||
const [publications, setPublications] = useState<Record<string, PublicationsResponse | null>>({});
|
||||
const fetchPublications = useCallback((ids: string[]) => {
|
||||
if (!live || ids.length === 0) return;
|
||||
for (const id of ids) {
|
||||
void api
|
||||
.get<PublicationsResponse>(`/publishing/drafts/${id}/publications`)
|
||||
.then((response) => setPublications((current) => ({ ...current, [id]: response })))
|
||||
.catch(() => setPublications((current) => ({ ...current, [id]: null })));
|
||||
}
|
||||
}, [live]);
|
||||
|
||||
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
|
||||
const groups = useMemo(() => groupByDay(rows), [rows]);
|
||||
|
||||
// The scheduled set as a stable string so the fetch effect below only fires
|
||||
// when it actually changes, and the timer below always reads the latest ids.
|
||||
const draftIds = rows.map((row) => row.id);
|
||||
const draftIdsRef = useRef<string[]>([]);
|
||||
draftIdsRef.current = draftIds;
|
||||
const draftIdsKey = draftIds.join(",");
|
||||
|
||||
// Fetch receipts on load and whenever the scheduled set changes (a new
|
||||
// schedule appears, or one is cancelled).
|
||||
useEffect(() => {
|
||||
fetchPublications(draftIdsKey === "" ? [] : draftIdsKey.split(","));
|
||||
}, [fetchPublications, draftIdsKey]);
|
||||
|
||||
// Same cadence as the streams wall: refresh on a 30s timer.
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
drafts.reload();
|
||||
connections.reload();
|
||||
fetchPublications(draftIdsRef.current);
|
||||
setNow(Date.now());
|
||||
}, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [drafts.reload, connections.reload]);
|
||||
|
||||
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
|
||||
const groups = useMemo(() => groupByDay(rows), [rows]);
|
||||
}, [drafts.reload, connections.reload, fetchPublications]);
|
||||
|
||||
async function cancel(row: ScheduledRow): Promise<void> {
|
||||
if (!live) {
|
||||
|
|
@ -176,13 +217,14 @@ export default function CalendarPage(): React.ReactElement {
|
|||
<span className="ml-auto text-2xs text-text-faint">
|
||||
{rows.length} scheduled · refreshes every 30s
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); setNow(Date.now()); }}>
|
||||
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); fetchPublications(draftIdsRef.current); setNow(Date.now()); }}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-4 text-xs text-text-faint">
|
||||
Publications queued by the publishing pipeline, grouped by your local day. Each row shows the local time with the
|
||||
UTC time beside it; execution state lives in the audit log, not this list.
|
||||
UTC time beside it; drafts with dispatch receipts show their latest outcome, with the remote link when the
|
||||
provider returned one.
|
||||
</p>
|
||||
|
||||
{notice !== null && (
|
||||
|
|
@ -214,6 +256,7 @@ export default function CalendarPage(): React.ReactElement {
|
|||
<ul className="divide-y divide-border-subtle rounded-lg border border-border-subtle bg-surface">
|
||||
{group.rows.map((row) => {
|
||||
const relative = relativeLabel(row.publishAt, now);
|
||||
const latest = latestPublication(publications[row.id]);
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-2 p-4 lg:flex-row lg:items-start lg:gap-4">
|
||||
<div className="flex w-44 shrink-0 flex-col gap-0.5">
|
||||
|
|
@ -225,6 +268,16 @@ export default function CalendarPage(): React.ReactElement {
|
|||
<p className="line-clamp-2 whitespace-pre-wrap break-words text-sm text-text">{row.text}</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs text-text-faint">
|
||||
<Badge tone="accent">scheduled</Badge>
|
||||
{latest !== null && (
|
||||
<Badge tone={latest.state === "completed" ? "live" : latest.state === "failed" ? "danger" : "warn"}>
|
||||
{latest.state.replace(/_/g, " ")}
|
||||
{latest.remoteUrl !== null && (
|
||||
<a href={latest.remoteUrl} target="_blank" rel="noreferrer" className="underline decoration-current/40 underline-offset-2 hover:decoration-current">
|
||||
open ↗
|
||||
</a>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
<span>{row.targetLabel}</span>
|
||||
{!row.fromScheduledAt && (
|
||||
<span title="The queue keeps its own timestamp, but the API only exposes the draft — this is the draft's last update.">
|
||||
|
|
|
|||
|
|
@ -164,3 +164,26 @@ export interface ScheduledPublicationRecord {
|
|||
publishAt: string;
|
||||
state: "pending" | "in_flight" | "done" | "failed";
|
||||
}
|
||||
|
||||
/** One dispatch attempt for a draft, newest first — GET /publishing/drafts/:draftId/publications. */
|
||||
export interface DraftPublication {
|
||||
id: string;
|
||||
connectionId: string;
|
||||
attemptNo: string;
|
||||
dispatchCount: number;
|
||||
state: "ready" | "in_progress" | "completed" | "failed" | "unknown" | "abandoned";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
firstDispatchStartedAt?: string;
|
||||
dispatchStartedAt?: string;
|
||||
providerRetryDeadline?: string;
|
||||
receivedAt?: string;
|
||||
publishedAt?: string;
|
||||
remoteId?: string;
|
||||
remoteUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PublicationsResponse {
|
||||
publications: DraftPublication[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue