From 12eeef1022b42e4b6b0ad67dcf32390eab48ea0c Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Tue, 26 May 2026 20:05:02 +0200
Subject: [PATCH 1/5] feat(admin): quick draft dashboard widget
Closes #235.
Add a QuickDraftCard client component to the (authenticated) dashboard
that captures a title + content and POSTs to /api/v1/posts with
status=draft. Wraps content lines into core/paragraph blocks before
submission, shows an inline confirmation chip on success, and surfaces
the API error message on failure.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
.../_components/QuickDraftCard.test.tsx | 109 ++++++++
.../_components/QuickDraftCard.tsx | 233 ++++++++++++++++++
apps/admin/src/app/(authenticated)/page.tsx | 4 +
3 files changed, 346 insertions(+)
create mode 100644 apps/admin/src/app/(authenticated)/_components/QuickDraftCard.test.tsx
create mode 100644 apps/admin/src/app/(authenticated)/_components/QuickDraftCard.tsx
diff --git a/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.test.tsx b/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.test.tsx
new file mode 100644
index 00000000..fd052ed0
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.test.tsx
@@ -0,0 +1,109 @@
+/**
+ * Tests for the dashboard QuickDraftCard widget. We pin three flows:
+ *
+ * 1. Successful POST clears the form and shows the inline success chip.
+ * 2. Server-side error surfaces in the danger chip with the API message.
+ * 3. Submitting an empty form refuses the request and shows a hint.
+ *
+ * The fetch wrapper (`apps/admin/src/lib/api-client`) is mocked so the
+ * tests don't go anywhere near the network.
+ */
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+const postMock = vi.fn();
+
+vi.mock('@/lib/api-client', async () => {
+ const actual = await vi.importActual(
+ '@/lib/api-client',
+ );
+ return {
+ ...actual,
+ api: {
+ get: vi.fn(),
+ post: (...args: unknown[]) => postMock(...args),
+ put: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ },
+ };
+});
+
+import { ApiError } from '@/lib/api-client';
+import { QuickDraftCard } from './QuickDraftCard';
+
+describe('QuickDraftCard', () => {
+ beforeEach(() => {
+ postMock.mockReset();
+ });
+
+ it('posts a draft with the expected payload and clears the form', async () => {
+ postMock.mockResolvedValueOnce({ id: 'p_123' });
+
+ render( );
+
+ fireEvent.change(screen.getByTestId('quick-draft-title'), {
+ target: { value: 'Brew journal' },
+ });
+ fireEvent.change(screen.getByTestId('quick-draft-content'), {
+ target: { value: 'Today I tried a 1:16 ratio.\n\nNext: try 1:17.' },
+ });
+ fireEvent.submit(screen.getByTestId('quick-draft-form'));
+
+ await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
+ expect(postMock).toHaveBeenCalledWith('/api/v1/posts', {
+ status: 'draft',
+ title: 'Brew journal',
+ content_blocks: {
+ version: 1,
+ blocks: [
+ { type: 'core/paragraph', attributes: { content: 'Today I tried a 1:16 ratio.' } },
+ { type: 'core/paragraph', attributes: { content: 'Next: try 1:17.' } },
+ ],
+ },
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId('quick-draft-status').textContent).toMatch(
+ /Draft saved/,
+ ),
+ );
+ expect(
+ (screen.getByTestId('quick-draft-title') as HTMLInputElement).value,
+ ).toBe('');
+ expect(
+ (screen.getByTestId('quick-draft-content') as HTMLTextAreaElement).value,
+ ).toBe('');
+ });
+
+ it('surfaces the server error message on failure', async () => {
+ postMock.mockRejectedValueOnce(
+ new ApiError(422, 'Unprocessable Entity', {
+ error: { code: 'invalid', message: 'Title too short.' },
+ }),
+ );
+
+ render( );
+
+ fireEvent.change(screen.getByTestId('quick-draft-title'), {
+ target: { value: 'X' },
+ });
+ fireEvent.submit(screen.getByTestId('quick-draft-form'));
+
+ await waitFor(() =>
+ expect(screen.getByTestId('quick-draft-status').textContent).toMatch(
+ /Title too short/,
+ ),
+ );
+ });
+
+ it('refuses an empty form before hitting the API', () => {
+ render( );
+ fireEvent.submit(screen.getByTestId('quick-draft-form'));
+
+ expect(postMock).not.toHaveBeenCalled();
+ expect(screen.getByTestId('quick-draft-status').textContent).toMatch(
+ /Add a title or some content/,
+ );
+ });
+});
diff --git a/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.tsx b/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.tsx
new file mode 100644
index 00000000..e826f826
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.tsx
@@ -0,0 +1,233 @@
+/**
+ * QuickDraftCard — dashboard widget for capturing a fast post draft
+ * without leaving the pulse view.
+ *
+ * Drops into the "Site pulse" landing page next to the activity rail.
+ * The form is intentionally minimal — title + content textarea — and
+ * POSTs to `/api/v1/posts` with `status="draft"`. On success the inputs
+ * clear and an inline confirmation chip slides in for ~4s; on failure a
+ * red chip surfaces the server's error message. We use inline
+ * confirmation instead of the global toaster because the dashboard
+ * doesn't mount one and the chip stays anchored to the originating
+ * form, which is easier to scan than a corner toast.
+ *
+ * Why a client component? The dashboard page is rendered on the
+ * server; the form needs local state and a fetch handler, so it lives
+ * in its own `'use client'` island and the dashboard composes it in.
+ */
+'use client';
+
+import { useEffect, useRef, useState, type ReactElement, type FormEvent } from 'react';
+import { Loader2, PenLine, Send } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { api, ApiError } from '@/lib/api-client';
+
+type Status =
+ | { kind: 'idle' }
+ | { kind: 'submitting' }
+ | { kind: 'success'; title: string }
+ | { kind: 'error'; message: string };
+
+/**
+ * Wrap the post body in the minimal block tree the renderer expects.
+ * One paragraph block per non-empty line keeps the markdown round-trip
+ * faithful without dragging the full markdown parser into this widget.
+ */
+function toContentBlocks(text: string): unknown {
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
+ if (lines.length === 0) {
+ return { version: 1, blocks: [] };
+ }
+ return {
+ version: 1,
+ blocks: lines.map((content) => ({
+ type: 'core/paragraph',
+ attributes: { content },
+ })),
+ };
+}
+
+export function QuickDraftCard(): ReactElement {
+ const [title, setTitle] = useState('');
+ const [content, setContent] = useState('');
+ const [status, setStatus] = useState({ kind: 'idle' });
+ const dismissTimer = useRef | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (dismissTimer.current) clearTimeout(dismissTimer.current);
+ };
+ }, []);
+
+ // Auto-dismiss success / error chips after 4s so the next interaction
+ // doesn't start under a stale confirmation.
+ useEffect(() => {
+ if (status.kind === 'success' || status.kind === 'error') {
+ if (dismissTimer.current) clearTimeout(dismissTimer.current);
+ dismissTimer.current = setTimeout(
+ () => setStatus({ kind: 'idle' }),
+ 4000,
+ );
+ }
+ }, [status]);
+
+ async function handleSubmit(event: FormEvent): Promise {
+ event.preventDefault();
+ const trimmedTitle = title.trim();
+ const trimmedContent = content.trim();
+ if (trimmedTitle === '' && trimmedContent === '') {
+ setStatus({
+ kind: 'error',
+ message: 'Add a title or some content before saving.',
+ });
+ return;
+ }
+
+ setStatus({ kind: 'submitting' });
+ try {
+ const draftStatus = 'draft';
+ await api.post('/api/v1/posts', {
+ status: draftStatus,
+ title: trimmedTitle || 'Untitled draft',
+ content_blocks: toContentBlocks(trimmedContent),
+ });
+ setStatus({ kind: 'success', title: trimmedTitle || 'Untitled draft' });
+ setTitle('');
+ setContent('');
+ } catch (err) {
+ const message =
+ err instanceof ApiError
+ ? extractApiErrorMessage(err)
+ : err instanceof Error
+ ? err.message
+ : 'Could not save the draft.';
+ setStatus({ kind: 'error', message });
+ }
+ }
+
+ const isBusy = status.kind === 'submitting';
+
+ return (
+
+
+
+
+
+
+ Quick draft .
+
+
+
+ Capture an idea before it slips. Saves as a draft you can finish
+ later from the posts list.
+
+
+
+
+ );
+}
+
+/**
+ * Pull a useful message out of an ApiError payload. The REST handlers
+ * return `{ error: { code, message } }` for the standard shape; we
+ * fall back to the HTTP status text if the payload doesn't match.
+ */
+function extractApiErrorMessage(err: ApiError): string {
+ const payload = err.payload;
+ if (payload && typeof payload === 'object') {
+ const errField = (payload as { error?: unknown }).error;
+ if (errField && typeof errField === 'object') {
+ const msg = (errField as { message?: unknown }).message;
+ if (typeof msg === 'string' && msg.length > 0) return msg;
+ }
+ const direct = (payload as { message?: unknown }).message;
+ if (typeof direct === 'string' && direct.length > 0) return direct;
+ }
+ return err.statusText || 'Could not save the draft.';
+}
diff --git a/apps/admin/src/app/(authenticated)/page.tsx b/apps/admin/src/app/(authenticated)/page.tsx
index 6b758d3b..22c2ad3c 100644
--- a/apps/admin/src/app/(authenticated)/page.tsx
+++ b/apps/admin/src/app/(authenticated)/page.tsx
@@ -33,6 +33,7 @@ import {
LineChartSurface,
Sparkline,
} from '@/components/ui/brand-chart';
+import { QuickDraftCard } from './_components/QuickDraftCard';
export const dynamic = 'force-dynamic';
@@ -319,6 +320,8 @@ export default function DashboardPage(): ReactElement {
+
+
Recent activity
@@ -364,6 +367,7 @@ export default function DashboardPage(): ReactElement {
+
);
From 175d2a89661e460ee48ff653fe52f62c0f6cdeea Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Tue, 26 May 2026 20:13:15 +0200
Subject: [PATCH 2/5] feat(admin): revisions browser + restore endpoint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #127.
Add apps/api/internal/admin/posts with two endpoints:
GET /api/v1/admin/posts/{id}/revisions
POST /api/v1/admin/posts/{id}/revisions/{rev}/restore
The restore handler materializes the revision via the existing
revisions.Store.Materialize, writes the JSON back to the post via a
PostUpdater seam (the binary wires this to rest/posts.Store), then
records a fresh 'manual' audit revision tagged 'Restored from revision
X' per docs/01-core-cms.md §4.4. Routes are policy-gated by edit_posts.
Front-end ships apps/admin/src/app/(authenticated)/posts/[id]/revisions
— timeline list with kind chips (autosave/manual/publish), two-step
Restore (click → confirm → POST), Latest badge, and inline success /
error chips that survive a list refetch.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
.../posts/[id]/revisions/page.test.tsx | 222 +++++++++
.../posts/[id]/revisions/page.tsx | 427 ++++++++++++++++++
apps/api/cmd/server/main.go | 37 ++
apps/api/internal/admin/posts/doc.go | 16 +
apps/api/internal/admin/posts/handler.go | 326 +++++++++++++
apps/api/internal/admin/posts/handler_test.go | 264 +++++++++++
6 files changed, 1292 insertions(+)
create mode 100644 apps/admin/src/app/(authenticated)/posts/[id]/revisions/page.test.tsx
create mode 100644 apps/admin/src/app/(authenticated)/posts/[id]/revisions/page.tsx
create mode 100644 apps/api/internal/admin/posts/doc.go
create mode 100644 apps/api/internal/admin/posts/handler.go
create mode 100644 apps/api/internal/admin/posts/handler_test.go
diff --git a/apps/admin/src/app/(authenticated)/posts/[id]/revisions/page.test.tsx b/apps/admin/src/app/(authenticated)/posts/[id]/revisions/page.test.tsx
new file mode 100644
index 00000000..650aaeac
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/posts/[id]/revisions/page.test.tsx
@@ -0,0 +1,222 @@
+/**
+ * Tests for the post revisions page. Pins the contract:
+ *
+ * 1. Loading state surfaces while the list fetch is in flight.
+ * 2. The fetched revisions render with kind chips, short id, and the
+ * latest row carrying the "Latest" badge + disabled Restore.
+ * 3. Restore is a two-step gesture: click → confirm → POST, with the
+ * pending row disabled while the request is in flight.
+ * 4. Restore failure surfaces in the danger chip without losing list
+ * state.
+ *
+ * The fetch wrapper is mocked so the tests don't hit the network.
+ */
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+const getMock = vi.fn();
+const postMock = vi.fn();
+
+vi.mock('@/lib/api-client', async () => {
+ const actual = await vi.importActual(
+ '@/lib/api-client',
+ );
+ return {
+ ...actual,
+ api: {
+ get: (...args: unknown[]) => getMock(...args),
+ post: (...args: unknown[]) => postMock(...args),
+ put: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ },
+ };
+});
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ id: 'post-abc' }),
+ usePathname: () => '/posts/post-abc/revisions',
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ refresh: vi.fn(),
+ }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+import { ApiError } from '@/lib/api-client';
+import RevisionsPage from './page';
+
+const SAMPLE_REVISIONS = [
+ {
+ id: 'rev-newest-00000001',
+ post_id: 'post-abc',
+ author_id: 'user-mara-00000001',
+ kind: 'manual' as const,
+ created_at: '2026-05-25T10:00:00Z',
+ title: 'Brew journal v3',
+ is_snapshot: true,
+ },
+ {
+ id: 'rev-middle-00000001',
+ post_id: 'post-abc',
+ author_id: 'user-mara-00000001',
+ kind: 'autosave' as const,
+ created_at: '2026-05-25T09:50:00Z',
+ title: 'Brew journal v2',
+ is_snapshot: false,
+ },
+ {
+ id: 'rev-oldest-00000001',
+ post_id: 'post-abc',
+ author_id: 'user-mara-00000001',
+ kind: 'publish' as const,
+ created_at: '2026-05-25T09:00:00Z',
+ title: 'Brew journal v1',
+ is_snapshot: true,
+ },
+];
+
+describe('Post revisions page', () => {
+ beforeEach(() => {
+ getMock.mockReset();
+ postMock.mockReset();
+ });
+
+ it('shows the loading state while the list fetch is pending', () => {
+ let resolve: (value: { data: unknown[] }) => void = () => {};
+ getMock.mockReturnValueOnce(
+ new Promise<{ data: unknown[] }>((res) => {
+ resolve = res;
+ }),
+ );
+
+ render( );
+ expect(screen.getByTestId('revisions-loading')).toBeInTheDocument();
+ resolve({ data: [] });
+ });
+
+ it('renders the fetched revisions and disables Restore on the latest', async () => {
+ getMock.mockResolvedValueOnce({ data: SAMPLE_REVISIONS });
+
+ render( );
+
+ await waitFor(() =>
+ expect(
+ screen.getByTestId('revision-row-rev-newest-00000001'),
+ ).toBeInTheDocument(),
+ );
+
+ // Three rows.
+ expect(
+ screen.getByTestId('revision-row-rev-middle-00000001'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('revision-row-rev-oldest-00000001'),
+ ).toBeInTheDocument();
+
+ // Latest row's Restore button is disabled.
+ const latestRestore = screen.getByTestId(
+ 'revision-restore-rev-newest-00000001',
+ );
+ expect(latestRestore).toBeDisabled();
+
+ // Older rows have enabled Restore.
+ expect(
+ screen.getByTestId('revision-restore-rev-middle-00000001'),
+ ).not.toBeDisabled();
+ });
+
+ it('restore is a two-step gesture and POSTs to the right endpoint', async () => {
+ getMock.mockResolvedValueOnce({ data: SAMPLE_REVISIONS });
+ postMock.mockResolvedValueOnce({ restored_from: 'rev-oldest-00000001' });
+ // The page reloads the list after a successful restore.
+ getMock.mockResolvedValueOnce({ data: SAMPLE_REVISIONS });
+
+ render( );
+
+ await waitFor(() =>
+ expect(
+ screen.getByTestId('revision-row-rev-oldest-00000001'),
+ ).toBeInTheDocument(),
+ );
+
+ fireEvent.click(
+ screen.getByTestId('revision-restore-rev-oldest-00000001'),
+ );
+ // Confirm UI appears.
+ const confirm = screen.getByTestId(
+ 'revision-restore-confirm-rev-oldest-00000001',
+ );
+ fireEvent.click(confirm);
+
+ await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
+ expect(postMock).toHaveBeenCalledWith(
+ '/api/v1/admin/posts/post-abc/revisions/rev-oldest-00000001/restore',
+ );
+
+ // Success chip lands.
+ await waitFor(() =>
+ expect(screen.getByTestId('restore-status')).toHaveTextContent(
+ /Restored revision/,
+ ),
+ );
+ });
+
+ it('surfaces the server error on restore failure', async () => {
+ getMock.mockResolvedValueOnce({ data: SAMPLE_REVISIONS });
+ postMock.mockRejectedValueOnce(
+ new ApiError(403, 'Forbidden', {
+ error: { code: 'forbidden', message: 'Editor role required.' },
+ }),
+ );
+
+ render( );
+
+ await waitFor(() =>
+ expect(
+ screen.getByTestId('revision-row-rev-oldest-00000001'),
+ ).toBeInTheDocument(),
+ );
+
+ fireEvent.click(
+ screen.getByTestId('revision-restore-rev-oldest-00000001'),
+ );
+ fireEvent.click(
+ screen.getByTestId('revision-restore-confirm-rev-oldest-00000001'),
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId('restore-status')).toHaveTextContent(
+ /Editor role required/,
+ ),
+ );
+ });
+
+ it('renders the empty state when the list comes back blank', async () => {
+ getMock.mockResolvedValueOnce({ data: [] });
+
+ render( );
+
+ await waitFor(() =>
+ expect(screen.getByTestId('revisions-empty')).toBeInTheDocument(),
+ );
+ });
+
+ it('renders the error state when the list fetch fails', async () => {
+ getMock.mockRejectedValueOnce(
+ new ApiError(500, 'Internal Server Error', {
+ error: { code: 'internal_error', message: 'DB unavailable.' },
+ }),
+ );
+
+ render( );
+
+ await waitFor(() =>
+ expect(screen.getByTestId('revisions-error')).toHaveTextContent(
+ /DB unavailable/,
+ ),
+ );
+ });
+});
diff --git a/apps/admin/src/app/(authenticated)/posts/[id]/revisions/page.tsx b/apps/admin/src/app/(authenticated)/posts/[id]/revisions/page.tsx
new file mode 100644
index 00000000..2a75a192
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/posts/[id]/revisions/page.tsx
@@ -0,0 +1,427 @@
+/**
+ * Revisions browser — list every stored revision for a post and let an
+ * editor roll the post back to any of them.
+ *
+ * The page is a thin client component. On mount it pulls
+ * `/api/v1/admin/posts/{id}/revisions` and renders the list as a
+ * vertical timeline: each row shows the kind chip (autosave / manual /
+ * publish), the relative timestamp, the author id, and a Restore
+ * button. Clicking Restore opens a confirmation dialog (a deliberate
+ * second click — the rollback is destructive) and on confirm POSTs to
+ * `/api/v1/admin/posts/{id}/revisions/{rev}/restore`. The list reloads
+ * on success and the new "manual" revision the API writes for the
+ * restore appears at the top of the timeline.
+ *
+ * Restore is gated by the same edit_posts capability the API checks; a
+ * 403 surfaces as the inline error chip, not a hidden button — UI-only
+ * hiding would mislead operators about what they can actually do.
+ */
+'use client';
+
+import {
+ useCallback,
+ useEffect,
+ useState,
+ type ReactElement,
+} from 'react';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import {
+ AlertTriangle,
+ ChevronLeft,
+ Clock,
+ History,
+ Loader2,
+ RotateCcw,
+ User as UserIcon,
+} from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Headline } from '@/components/ui/headline';
+import { api, ApiError } from '@/lib/api-client';
+
+/** Shape returned by GET /api/v1/admin/posts/{id}/revisions. */
+interface RevisionView {
+ id: string;
+ post_id: string;
+ author_id?: string;
+ kind: 'autosave' | 'manual' | 'publish';
+ created_at: string;
+ title?: string;
+ excerpt?: string;
+ comment?: string;
+ is_snapshot: boolean;
+ is_permanent?: boolean;
+}
+
+interface ListResponse {
+ data: RevisionView[];
+}
+
+type LoadStatus =
+ | { kind: 'loading' }
+ | { kind: 'loaded'; revisions: RevisionView[] }
+ | { kind: 'error'; message: string };
+
+type RestoreStatus =
+ | { kind: 'idle' }
+ | { kind: 'confirming'; revisionId: string }
+ | { kind: 'submitting'; revisionId: string }
+ | { kind: 'success'; revisionId: string }
+ | { kind: 'error'; message: string };
+
+export default function RevisionsPage(): ReactElement {
+ const params = useParams<{ id: string }>();
+ const postId = params?.id ?? '';
+
+ const [load, setLoad] = useState({ kind: 'loading' });
+ const [restore, setRestore] = useState({ kind: 'idle' });
+
+ const fetchRevisions = useCallback(async (): Promise => {
+ setLoad({ kind: 'loading' });
+ try {
+ const resp = await api.get(
+ `/api/v1/admin/posts/${postId}/revisions`,
+ );
+ setLoad({ kind: 'loaded', revisions: resp.data });
+ } catch (err) {
+ setLoad({
+ kind: 'error',
+ message: extractMessage(err, 'Could not load revisions.'),
+ });
+ }
+ }, [postId]);
+
+ useEffect(() => {
+ if (postId === '') return;
+ void fetchRevisions();
+ }, [postId, fetchRevisions]);
+
+ const onRestore = useCallback(
+ async (revisionId: string): Promise => {
+ setRestore({ kind: 'submitting', revisionId });
+ try {
+ await api.post(
+ `/api/v1/admin/posts/${postId}/revisions/${revisionId}/restore`,
+ );
+ setRestore({ kind: 'success', revisionId });
+ await fetchRevisions();
+ } catch (err) {
+ setRestore({
+ kind: 'error',
+ message: extractMessage(err, 'Restore failed.'),
+ });
+ }
+ },
+ [postId, fetchRevisions],
+ );
+
+ return (
+
+ {/* Crumb + page head */}
+
+
+
+ Back to post
+
+
+
+
+ Post history .
+
+
+ Every save lands in the revision log. Restore rolls the post
+ back to that version and writes a fresh entry recording the
+ rollback.{' '}
+ #{postId}
+
+
+
+
+
+ {/* Restore status — sticky chip above the list */}
+ {restore.kind === 'success' ? (
+
+
+ Restored revision · {shortId(restore.revisionId)}
+
+ ) : null}
+ {restore.kind === 'error' ? (
+
+ ) : null}
+
+ {/* List body */}
+
+ {load.kind === 'loading' ?
: null}
+ {load.kind === 'error' ?
: null}
+ {load.kind === 'loaded' && load.revisions.length === 0 ? (
+
+ ) : null}
+ {load.kind === 'loaded' && load.revisions.length > 0 ? (
+
+ {load.revisions.map((rev, idx) => (
+
+ setRestore({ kind: 'confirming', revisionId: rev.id })
+ }
+ onCancel={() => setRestore({ kind: 'idle' })}
+ onRestore={() => void onRestore(rev.id)}
+ />
+ ))}
+
+ ) : null}
+
+
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* Subcomponents */
+/* -------------------------------------------------------------------------- */
+
+interface RevisionRowProps {
+ revision: RevisionView;
+ isLast: boolean;
+ isLatest: boolean;
+ restoreState: RestoreStatus;
+ onConfirm: () => void;
+ onCancel: () => void;
+ onRestore: () => void;
+}
+
+function RevisionRow({
+ revision,
+ isLast,
+ isLatest,
+ restoreState,
+ onConfirm,
+ onCancel,
+ onRestore,
+}: RevisionRowProps): ReactElement {
+ const isConfirming =
+ restoreState.kind === 'confirming' &&
+ restoreState.revisionId === revision.id;
+ const isSubmitting =
+ restoreState.kind === 'submitting' &&
+ restoreState.revisionId === revision.id;
+
+ return (
+
+
+
+
+
+
+
+
+ {isLatest ? (
+
+ Latest
+
+ ) : null}
+ {revision.is_permanent ? (
+ Permanent
+ ) : null}
+ {!revision.is_snapshot ? (
+ Delta
+ ) : null}
+
+ {shortId(revision.id)}
+
+
+
+
+ {revision.title ? (
+ {revision.title}
+ ) : (
+ (untitled)
+ )}
+ {revision.comment ? (
+ — {revision.comment}
+ ) : null}
+
+
+
+
+
+
+ {formatTimestamp(revision.created_at)}
+
+
+
+
+ {revision.author_id ? shortId(revision.author_id) : 'system'}
+
+
+
+
+
+ {isConfirming ? (
+
+
+ Cancel
+
+
+ Confirm restore
+
+
+ ) : (
+
+ {isSubmitting ? (
+
+ ) : (
+
+ )}
+ Restore
+
+ )}
+
+
+ );
+}
+
+function KindBadge({
+ kind,
+}: {
+ kind: RevisionView['kind'];
+}): ReactElement {
+ switch (kind) {
+ case 'publish':
+ return Publish ;
+ case 'manual':
+ return Manual ;
+ case 'autosave':
+ return Autosave ;
+ }
+}
+
+function LoadingRow(): ReactElement {
+ return (
+
+
+ Loading revisions…
+
+ );
+}
+
+function ErrorRow({ message }: { message: string }): ReactElement {
+ return (
+
+ );
+}
+
+function EmptyRow(): ReactElement {
+ return (
+
+
+
No revisions yet — saves will start landing here.
+
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* Helpers */
+/* -------------------------------------------------------------------------- */
+
+/** Pull a useful message out of an error. ApiError carries the
+ * server's structured payload; anything else falls back to .message. */
+function extractMessage(err: unknown, fallback: string): string {
+ if (err instanceof ApiError) {
+ const payload = err.payload as { error?: { message?: string } } | undefined;
+ const apiMsg = payload?.error?.message;
+ if (typeof apiMsg === 'string' && apiMsg.length > 0) return apiMsg;
+ return err.statusText || fallback;
+ }
+ if (err instanceof Error && err.message) return err.message;
+ return fallback;
+}
+
+/** Last 8 chars of a UUID — enough to disambiguate in the UI without
+ * carrying the full 36 chars on every row. */
+function shortId(id: string): string {
+ if (id.length <= 8) return id;
+ return id.slice(-8);
+}
+
+/** ISO timestamp → "Mar 4, 2026 · 14:32" in the operator's locale. */
+function formatTimestamp(iso: string): string {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ return d.toLocaleString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go
index 2471fe34..fb526f3a 100644
--- a/apps/api/cmd/server/main.go
+++ b/apps/api/cmd/server/main.go
@@ -32,6 +32,7 @@ import (
admincomments "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/comments"
adminmedia "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/media"
+ adminposts "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/posts"
adminthemes "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/themes"
restimg "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/img"
"github.com/Singleton-Solution/GoNext/apps/api/internal/admin/customizer"
@@ -73,6 +74,7 @@ import (
"github.com/Singleton-Solution/GoNext/packages/go/ratelimit"
redisclient "github.com/Singleton-Solution/GoNext/packages/go/redis"
"github.com/Singleton-Solution/GoNext/packages/go/redirects"
+ "github.com/Singleton-Solution/GoNext/packages/go/revisions"
pkgsearch "github.com/Singleton-Solution/GoNext/packages/go/search"
"github.com/Singleton-Solution/GoNext/packages/go/session"
"github.com/Singleton-Solution/GoNext/packages/go/shutdown"
@@ -978,6 +980,41 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se
}
}
+ // Admin revisions browser (issue #127). Mounts the list +
+ // restore endpoints under /api/v1/admin/posts/{id}/revisions[/...].
+ // The revisions store falls back to the in-memory implementation
+ // when the pool is nil so the dev loop keeps working without a
+ // database. The PostUpdater adapter wraps the production posts
+ // store with the load-version / update-content two-step the
+ // restore handler needs; the rest/posts.Store CAS guarantees a
+ // parallel writer doesn't quietly overwrite the rollback.
+ var revisionsStore revisions.Store
+ if pool != nil {
+ revisionsStore = revisions.NewPostgresStore(pool)
+ } else {
+ revisionsStore = revisions.NewMemoryStore()
+ logger.Warn("admin/posts: pool nil; using in-memory revisions store")
+ }
+ adminPostsUpdater := adminposts.PostUpdaterFunc(func(ctx context.Context, postID string, raw json.RawMessage) error {
+ current, err := postsStore.Get(ctx, restposts.PostTypePost, postID)
+ if err != nil {
+ return err
+ }
+ _, err = postsStore.Update(ctx, restposts.PostTypePost, postID, current.Version,
+ restposts.UpdateInput{ContentBlocks: raw})
+ return err
+ })
+ if err := adminposts.Mount(mux, "/api/v1/admin/posts", adminposts.Deps{
+ Revisions: revisionsStore,
+ Posts: adminPostsUpdater,
+ Policy: postsPolicy,
+ Logger: logger,
+ }); err != nil {
+ logger.Warn("admin/posts: failed to mount", slog.Any("err", err))
+ } else {
+ logger.Info("admin/posts: routes mounted", slog.String("base", "/api/v1/admin/posts"))
+ }
+
// Daily TTL sweep for post_autosaves (migration 000016 spec'd a
// 7-day TTL). The cron registry is wired here so a future worker
// boot can pick it up; the matching taskspec.Default
diff --git a/apps/api/internal/admin/posts/doc.go b/apps/api/internal/admin/posts/doc.go
new file mode 100644
index 00000000..0742ca0d
--- /dev/null
+++ b/apps/api/internal/admin/posts/doc.go
@@ -0,0 +1,16 @@
+// Package posts is the admin-side REST surface for post operations
+// that don't belong on the public /api/v1/posts mount.
+//
+// Today it ships the revision browse + restore endpoints (issue #127):
+// list a post's stored revisions and roll the post back to one of
+// them. The restore writes a fresh "manual" revision (carrying the
+// "Restored from revision X" comment, per docs/01-core-cms.md §4.4) so
+// the audit trail stays linear — restores never overwrite history,
+// they extend it.
+//
+// The mount path is /api/v1/admin/posts; routes are policy-gated by
+// edit_posts (or edit_others_posts when the post is owned by someone
+// else, but that distinction lives downstream — this layer enforces
+// only the cheap presence check and leaves the meta-cap mapping to a
+// future cut).
+package posts
diff --git a/apps/api/internal/admin/posts/handler.go b/apps/api/internal/admin/posts/handler.go
new file mode 100644
index 00000000..246eb5ac
--- /dev/null
+++ b/apps/api/internal/admin/posts/handler.go
@@ -0,0 +1,326 @@
+package posts
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router"
+ "github.com/Singleton-Solution/GoNext/packages/go/policy"
+ "github.com/Singleton-Solution/GoNext/packages/go/revisions"
+)
+
+// Deps is the dependency bag for Mount. Every required field is
+// non-nil; Logger and Now fall back to safe defaults for convenience.
+type Deps struct {
+ // Revisions is the revisions Store the list + restore handlers
+ // read from. Required.
+ Revisions revisions.Store
+
+ // Posts is the post Store the restore handler writes the
+ // rolled-back content into. Required.
+ Posts PostUpdater
+
+ // Policy gates the edit_posts capability check.
+ Policy policy.Policy
+
+ // Logger receives structured log lines. nil falls back to
+ // slog.Default.
+ Logger *slog.Logger
+
+ // Now lets tests pin the clock for the "Restored from revision X"
+ // audit comment timestamp. nil falls back to time.Now.
+ Now func() time.Time
+}
+
+func (d Deps) validate() error {
+ if d.Revisions == nil {
+ return errors.New("admin/posts: Revisions is required")
+ }
+ if d.Posts == nil {
+ return errors.New("admin/posts: Posts is required")
+ }
+ if d.Policy == nil {
+ return errors.New("admin/posts: Policy is required")
+ }
+ return nil
+}
+
+// PostUpdater is the minimal surface the restore handler needs from
+// the posts package. Carved out so the admin/posts package doesn't
+// depend on the full rest/posts.Store and so tests can pass a stub
+// without rebuilding a memory store.
+//
+// SetContentBlocks replaces the row's content_blocks with raw — used
+// by the restore endpoint to roll the post back to a revision's
+// materialized JSON. Implementations are expected to bump the
+// row's version and re-render the content hash; the optimistic
+// concurrency guard isn't surfaced here because the admin restore
+// flow is a deliberate one-shot operation, not a multi-tab edit.
+type PostUpdater interface {
+ SetContentBlocks(ctx context.Context, postID string, raw json.RawMessage) error
+}
+
+// PostUpdaterFunc is a convenience adapter that lets the binary boot
+// wire any closure as a PostUpdater. The wiring site composes the
+// "load current version + update content_blocks" two-step against the
+// concrete rest/posts.Store without forcing this package to import the
+// public posts package.
+type PostUpdaterFunc func(ctx context.Context, postID string, raw json.RawMessage) error
+
+// SetContentBlocks satisfies PostUpdater.
+func (f PostUpdaterFunc) SetContentBlocks(ctx context.Context, postID string, raw json.RawMessage) error {
+ return f(ctx, postID, raw)
+}
+
+// handlers is the resolved-Deps form passed around inside the package.
+type handlers struct {
+ revs revisions.Store
+ posts PostUpdater
+ policy policy.Policy
+ logger *slog.Logger
+ now func() time.Time
+}
+
+// Mount wires the admin posts routes onto mux under base (typically
+// "/api/v1/admin/posts"). Returns an error rather than panicking if
+// Deps is malformed so the server boot can surface it cleanly.
+//
+// Route tree:
+//
+// GET {base}/{id}/revisions — list, most recent first
+// POST {base}/{id}/revisions/{rev}/restore — roll post back to rev
+//
+// Every route is gated by edit_posts. The list endpoint is gated
+// because revision payloads include the full editable JSON, which
+// can carry draft content the post's owner hasn't shared yet.
+func Mount(mux *http.ServeMux, base string, deps Deps) error {
+ if err := deps.validate(); err != nil {
+ return err
+ }
+ if deps.Logger == nil {
+ deps.Logger = slog.Default()
+ }
+ if deps.Now == nil {
+ deps.Now = func() time.Time { return time.Now().UTC() }
+ }
+ h := &handlers{
+ revs: deps.Revisions,
+ posts: deps.Posts,
+ policy: deps.Policy,
+ logger: deps.Logger,
+ now: deps.Now,
+ }
+ base = strings.TrimRight(base, "/")
+ mux.Handle("GET "+base+"/{id}/revisions", h.gate(h.listRevisions))
+ mux.Handle("POST "+base+"/{id}/revisions/{rev}/restore", h.gate(h.restoreRevision))
+ return nil
+}
+
+// gate wraps a handler with the auth + edit_posts capability check.
+// Returns 401 if no principal is on the context, 403 if the principal
+// lacks the capability. The richer edit_others_posts split lives in
+// the public posts mount; this admin surface is operator-only by
+// design (the UI is behind the admin shell).
+func (h *handlers) gate(next func(http.ResponseWriter, *http.Request, policy.Principal)) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ pr, ok := policy.FromContext(r.Context())
+ if !ok {
+ router.WriteError(w, http.StatusUnauthorized, "unauthenticated", "authentication required")
+ return
+ }
+ if d := h.policy.Can(pr, policy.CapEditPosts, nil); !d.Allowed {
+ router.WriteError(w, http.StatusForbidden, "forbidden", d.Reason)
+ return
+ }
+ next(w, r, pr)
+ })
+}
+
+// RevisionView is the on-wire shape returned by the list endpoint.
+// Mirrors revisions.Revision but flattens the binary hash to hex and
+// omits the (potentially large) Snapshot / Delta payloads — the list
+// UI doesn't need the materialized JSON, only the row metadata. A
+// future "preview" endpoint will materialize on demand.
+type RevisionView struct {
+ ID string `json:"id"`
+ PostID string `json:"post_id"`
+ AuthorID string `json:"author_id,omitempty"`
+ Kind string `json:"kind"`
+ CreatedAt time.Time `json:"created_at"`
+ Title string `json:"title,omitempty"`
+ Excerpt string `json:"excerpt,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ // IsSnapshot is convenient for the admin UI's "full vs delta"
+ // chip — saves callers from having to inspect the storage shape.
+ IsSnapshot bool `json:"is_snapshot"`
+ // IsPermanent surfaces the legal-hold pin so the restore button
+ // can be styled differently on rows the pruner won't touch.
+ IsPermanent bool `json:"is_permanent,omitempty"`
+}
+
+func toView(r revisions.Revision) RevisionView {
+ v := RevisionView{
+ ID: r.ID.String(),
+ PostID: r.PostID.String(),
+ Kind: string(r.Kind),
+ CreatedAt: r.CreatedAt,
+ Title: r.Title,
+ Excerpt: r.Excerpt,
+ Comment: r.Comment,
+ IsSnapshot: len(r.Snapshot) > 0,
+ IsPermanent: r.IsPermanent,
+ }
+ if r.AuthorID != uuid.Nil {
+ v.AuthorID = r.AuthorID.String()
+ }
+ return v
+}
+
+// -----------------------------------------------------------------------------
+// LIST
+// -----------------------------------------------------------------------------
+
+func (h *handlers) listRevisions(w http.ResponseWriter, r *http.Request, _ policy.Principal) {
+ postID, ok := parseUUID(r.PathValue("id"))
+ if !ok {
+ router.WriteError(w, http.StatusBadRequest, "invalid_id", "post id is not a valid uuid")
+ return
+ }
+
+ limit := parseLimit(r.URL.Query().Get("limit"), 50)
+ rows, err := h.revs.List(r.Context(), postID, revisions.Filter{Limit: limit})
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "admin/posts: list revisions failed",
+ slog.String("post_id", postID.String()), slog.Any("err", err))
+ router.WriteError(w, http.StatusInternalServerError, "internal_error",
+ "failed to list revisions")
+ return
+ }
+
+ out := make([]RevisionView, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, toView(row))
+ }
+ router.WriteJSON(w, http.StatusOK, map[string]any{"data": out})
+}
+
+// -----------------------------------------------------------------------------
+// RESTORE
+// -----------------------------------------------------------------------------
+
+func (h *handlers) restoreRevision(w http.ResponseWriter, r *http.Request, pr policy.Principal) {
+ postID, ok := parseUUID(r.PathValue("id"))
+ if !ok {
+ router.WriteError(w, http.StatusBadRequest, "invalid_id", "post id is not a valid uuid")
+ return
+ }
+ revID, ok := parseUUID(r.PathValue("rev"))
+ if !ok {
+ router.WriteError(w, http.StatusBadRequest, "invalid_id", "revision id is not a valid uuid")
+ return
+ }
+
+ rev, err := h.revs.Get(r.Context(), revID)
+ if err != nil {
+ if errors.Is(err, revisions.ErrNotFound) {
+ router.WriteError(w, http.StatusNotFound, "not_found", "revision not found")
+ return
+ }
+ h.logger.ErrorContext(r.Context(), "admin/posts: revision Get failed",
+ slog.String("rev_id", revID.String()), slog.Any("err", err))
+ router.WriteError(w, http.StatusInternalServerError, "internal_error",
+ "failed to load revision")
+ return
+ }
+ // Guard against cross-post restore. A caller-supplied (post, rev)
+ // mismatch is a 404 not a 400 — the rev exists, just not on this
+ // post, and we don't want to leak the existence of other posts'
+ // revisions to a curious admin.
+ if rev.PostID != postID {
+ router.WriteError(w, http.StatusNotFound, "not_found", "revision not found on this post")
+ return
+ }
+
+ materialized, err := h.revs.Materialize(r.Context(), revID)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "admin/posts: materialize failed",
+ slog.String("rev_id", revID.String()), slog.Any("err", err))
+ router.WriteError(w, http.StatusInternalServerError, "internal_error",
+ "failed to materialize revision")
+ return
+ }
+
+ if err := h.posts.SetContentBlocks(r.Context(), postID.String(), materialized); err != nil {
+ h.logger.ErrorContext(r.Context(), "admin/posts: post update failed",
+ slog.String("post_id", postID.String()), slog.Any("err", err))
+ router.WriteError(w, http.StatusInternalServerError, "internal_error",
+ "failed to restore post")
+ return
+ }
+
+ // Write a fresh manual revision recording the restore, per
+ // docs/01-core-cms.md §4.4. Best-effort: a failure here doesn't
+ // roll back the post update — the operator's intent (the restore)
+ // has already landed, and the audit row is a nice-to-have that
+ // can be re-emitted later by the post layer's own save trigger.
+ authorID, _ := uuid.Parse(pr.UserID)
+ auditRev := revisions.Revision{
+ PostID: postID,
+ AuthorID: authorID,
+ Kind: revisions.Manual,
+ CreatedAt: h.now().UTC(),
+ Title: rev.Title,
+ Excerpt: rev.Excerpt,
+ ContentBlocks: materialized,
+ Comment: "Restored from revision " + revID.String(),
+ }
+ if _, err := h.revs.Save(r.Context(), auditRev, revisions.WithForceSnapshot()); err != nil {
+ h.logger.WarnContext(r.Context(), "admin/posts: restore audit revision save failed",
+ slog.String("post_id", postID.String()),
+ slog.String("rev_id", revID.String()),
+ slog.Any("err", err))
+ }
+
+ router.WriteJSON(w, http.StatusOK, map[string]any{
+ "restored_from": revID.String(),
+ "post_id": postID.String(),
+ })
+}
+
+// -----------------------------------------------------------------------------
+// Helpers
+// -----------------------------------------------------------------------------
+
+func parseUUID(s string) (uuid.UUID, bool) {
+ id, err := uuid.Parse(s)
+ if err != nil {
+ return uuid.Nil, false
+ }
+ return id, true
+}
+
+// parseLimit clamps the limit to [1, 100], defaulting to fallback when
+// the query string is empty or malformed. The cap is conservative —
+// the UI lazy-loads beyond the first page, so a single response
+// doesn't need to carry the entire history.
+func parseLimit(s string, fallback int) int {
+ if s == "" {
+ return fallback
+ }
+ n, err := strconv.Atoi(s)
+ if err != nil || n <= 0 {
+ return fallback
+ }
+ if n > 100 {
+ return 100
+ }
+ return n
+}
diff --git a/apps/api/internal/admin/posts/handler_test.go b/apps/api/internal/admin/posts/handler_test.go
new file mode 100644
index 00000000..f98e5b26
--- /dev/null
+++ b/apps/api/internal/admin/posts/handler_test.go
@@ -0,0 +1,264 @@
+package posts
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/Singleton-Solution/GoNext/packages/go/policy"
+ "github.com/Singleton-Solution/GoNext/packages/go/revisions"
+)
+
+// fakePostUpdater captures the last SetContentBlocks call so tests can
+// assert the restore writes the materialized JSON to the post layer.
+type fakePostUpdater struct {
+ mu sync.Mutex
+ lastID string
+ lastRaw json.RawMessage
+ failNext bool
+}
+
+func (f *fakePostUpdater) SetContentBlocks(_ context.Context, postID string, raw json.RawMessage) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.failNext {
+ f.failNext = false
+ return errInjected
+ }
+ f.lastID = postID
+ f.lastRaw = append(f.lastRaw[:0], raw...)
+ return nil
+}
+
+var errInjected = &injectedErr{}
+
+type injectedErr struct{}
+
+func (*injectedErr) Error() string { return "injected" }
+
+// testHarness wires a memory revisions store + fake post updater into
+// a mux, and pre-loads one snapshot revision so the list + restore
+// tests have content to point at.
+type testHarness struct {
+ mux *http.ServeMux
+ revs *revisions.MemoryStore
+ posts *fakePostUpdater
+ postID uuid.UUID
+ revID uuid.UUID
+ author uuid.UUID
+}
+
+func newHarness(t *testing.T) *testHarness {
+ t.Helper()
+
+ revs := revisions.NewMemoryStore()
+ revs.NowFunc = func() time.Time {
+ return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ }
+ posts := &fakePostUpdater{}
+
+ mux := http.NewServeMux()
+ if err := Mount(mux, "/api/v1/admin/posts", Deps{
+ Revisions: revs,
+ Posts: posts,
+ Policy: policy.NewBasicPolicy(map[policy.Role]policy.CapabilitySet{
+ policy.RoleEditor: policy.NewCapabilitySet(policy.CapEditPosts),
+ }),
+ Now: func() time.Time {
+ return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+ },
+ }); err != nil {
+ t.Fatalf("Mount: %v", err)
+ }
+
+ postID := uuid.New()
+ author := uuid.New()
+ revID, err := revs.Save(context.Background(), revisions.Revision{
+ PostID: postID,
+ AuthorID: author,
+ Kind: revisions.Manual,
+ Title: "Hello",
+ Excerpt: "World",
+ ContentBlocks: json.RawMessage(`{"version":1,"blocks":[{"type":"core/paragraph","attributes":{"content":"hi"}}]}`),
+ }, revisions.WithForceSnapshot())
+ if err != nil {
+ t.Fatalf("seed revision: %v", err)
+ }
+
+ return &testHarness{
+ mux: mux,
+ revs: revs,
+ posts: posts,
+ postID: postID,
+ revID: revID,
+ author: author,
+ }
+}
+
+// editorRequest stamps a request with an editor principal so the gate
+// admits it. Test helper — production wiring sits in the auth
+// middleware.
+func editorRequest(method, path string, body []byte) *http.Request {
+ var req *http.Request
+ if body == nil {
+ req = httptest.NewRequest(method, path, nil)
+ } else {
+ req = httptest.NewRequest(method, path, strings.NewReader(string(body)))
+ }
+ pr := policy.Principal{
+ UserID: uuid.NewString(),
+ Roles: []policy.Role{policy.RoleEditor},
+ }
+ return req.WithContext(policy.WithPrincipal(req.Context(), pr))
+}
+
+func TestListRevisions_ReturnsRows(t *testing.T) {
+ h := newHarness(t)
+
+ req := editorRequest(http.MethodGet,
+ "/api/v1/admin/posts/"+h.postID.String()+"/revisions", nil)
+ rec := httptest.NewRecorder()
+ h.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ var got struct {
+ Data []RevisionView `json:"data"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v body=%s", err, rec.Body.String())
+ }
+ if len(got.Data) != 1 {
+ t.Fatalf("want 1 revision, got %d", len(got.Data))
+ }
+ if got.Data[0].ID != h.revID.String() {
+ t.Fatalf("revision id mismatch: %s != %s", got.Data[0].ID, h.revID.String())
+ }
+ if !got.Data[0].IsSnapshot {
+ t.Fatal("seed revision must be a snapshot")
+ }
+}
+
+func TestListRevisions_Unauthenticated(t *testing.T) {
+ h := newHarness(t)
+ req := httptest.NewRequest(http.MethodGet,
+ "/api/v1/admin/posts/"+h.postID.String()+"/revisions", nil)
+ rec := httptest.NewRecorder()
+ h.mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status=%d want 401", rec.Code)
+ }
+}
+
+func TestListRevisions_Forbidden(t *testing.T) {
+ h := newHarness(t)
+ req := httptest.NewRequest(http.MethodGet,
+ "/api/v1/admin/posts/"+h.postID.String()+"/revisions", nil)
+ pr := policy.Principal{
+ UserID: uuid.NewString(),
+ Roles: []policy.Role{policy.RoleSubscriber},
+ }
+ req = req.WithContext(policy.WithPrincipal(req.Context(), pr))
+ rec := httptest.NewRecorder()
+ h.mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status=%d want 403; body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestListRevisions_InvalidPostID(t *testing.T) {
+ h := newHarness(t)
+ req := editorRequest(http.MethodGet, "/api/v1/admin/posts/not-a-uuid/revisions", nil)
+ rec := httptest.NewRecorder()
+ h.mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status=%d want 400; body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestRestoreRevision_Success(t *testing.T) {
+ h := newHarness(t)
+
+ path := "/api/v1/admin/posts/" + h.postID.String() + "/revisions/" + h.revID.String() + "/restore"
+ req := editorRequest(http.MethodPost, path, nil)
+ rec := httptest.NewRecorder()
+ h.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if h.posts.lastID != h.postID.String() {
+ t.Fatalf("post updater not called with right id: %q", h.posts.lastID)
+ }
+ if !strings.Contains(string(h.posts.lastRaw), `"core/paragraph"`) {
+ t.Fatalf("post updater raw missing block: %s", string(h.posts.lastRaw))
+ }
+ // Restore should have appended an audit "manual" revision.
+ rows, err := h.revs.List(context.Background(), h.postID, revisions.Filter{Limit: 10})
+ if err != nil {
+ t.Fatalf("list after restore: %v", err)
+ }
+ if len(rows) != 2 {
+ t.Fatalf("want 2 revisions after restore, got %d", len(rows))
+ }
+ var auditRow revisions.Revision
+ for _, r := range rows {
+ if r.ID != h.revID {
+ auditRow = r
+ }
+ }
+ if !strings.HasPrefix(auditRow.Comment, "Restored from revision ") {
+ t.Fatalf("audit comment not set: %q", auditRow.Comment)
+ }
+}
+
+func TestRestoreRevision_CrossPostMismatch_404(t *testing.T) {
+ h := newHarness(t)
+
+ otherPost := uuid.New()
+ path := "/api/v1/admin/posts/" + otherPost.String() + "/revisions/" + h.revID.String() + "/restore"
+ req := editorRequest(http.MethodPost, path, nil)
+ rec := httptest.NewRecorder()
+ h.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status=%d want 404; body=%s", rec.Code, rec.Body.String())
+ }
+ if h.posts.lastID != "" {
+ t.Fatal("post updater must not be called on cross-post mismatch")
+ }
+}
+
+func TestRestoreRevision_PostUpdateFails_500(t *testing.T) {
+ h := newHarness(t)
+ h.posts.failNext = true
+
+ path := "/api/v1/admin/posts/" + h.postID.String() + "/revisions/" + h.revID.String() + "/restore"
+ req := editorRequest(http.MethodPost, path, nil)
+ rec := httptest.NewRecorder()
+ h.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status=%d want 500; body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestRestoreRevision_UnknownRevision_404(t *testing.T) {
+ h := newHarness(t)
+ missing := uuid.New()
+ path := "/api/v1/admin/posts/" + h.postID.String() + "/revisions/" + missing.String() + "/restore"
+ req := editorRequest(http.MethodPost, path, nil)
+ rec := httptest.NewRecorder()
+ h.mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status=%d want 404; body=%s", rec.Code, rec.Body.String())
+ }
+}
From 9cffd0c4d348666d68804dc29dfd9df13ee9f6f2 Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Tue, 26 May 2026 20:16:10 +0200
Subject: [PATCH 3/5] feat(customizer): postMessage bridge for live preview
updates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #22.
PreviewFrame now publishes a versioned overrides:update message to the
embedded iframe whenever the customizer's override map changes. The
URL-encoded fallback stays in place for the first load and for
out-of-band navigations so the renderer-side shim still bootstraps
from a deep-link.
Schema (parent → child):
{ channel: 'gonext.customizer',
type: 'overrides:update',
version: 1,
overrides: ThemeOverrides }
Origin allowlist via postMessage's targetOrigin argument — derived
from publicSiteUrl, browser refuses delivery on a cross-origin frame.
A malformed publicSiteUrl is treated as 'fall back to URL re-encode',
not a hard error.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
.../components/PreviewFrame.test.tsx | 161 ++++++++++++++++++
.../customizer/components/PreviewFrame.tsx | 159 +++++++++++++++--
2 files changed, 306 insertions(+), 14 deletions(-)
create mode 100644 apps/admin/src/app/(authenticated)/appearance/customizer/components/PreviewFrame.test.tsx
diff --git a/apps/admin/src/app/(authenticated)/appearance/customizer/components/PreviewFrame.test.tsx b/apps/admin/src/app/(authenticated)/appearance/customizer/components/PreviewFrame.test.tsx
new file mode 100644
index 00000000..a8aef2cc
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/appearance/customizer/components/PreviewFrame.test.tsx
@@ -0,0 +1,161 @@
+/**
+ * Tests for the PreviewFrame postMessage bridge (issue #22).
+ *
+ * Coverage:
+ * - Initial render still wires the encoded-URL src so a fresh load
+ * rebuilds CSS variables from the URL (renderer fallback).
+ * - On mount we publish one overrides:update message tagged with the
+ * documented channel + version envelope.
+ * - Subsequent overrides changes fire another update; the message
+ * body carries the new override map.
+ * - The targetOrigin passed to postMessage is the origin of
+ * publicSiteUrl (same-origin allowlist).
+ * - A malformed publicSiteUrl falls back gracefully — no postMessage,
+ * no thrown error.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import {
+ CUSTOMIZER_PREVIEW_CHANNEL,
+ CUSTOMIZER_PREVIEW_VERSION,
+ PreviewFrame,
+} from './PreviewFrame';
+import type { ThemeOverrides } from '../types';
+
+/** Build a fresh override object — distinct identity per call so the
+ * effect dependency cleanly triggers on rerender. */
+function overrides(palette?: string): ThemeOverrides {
+ return {
+ settings: {
+ color: {
+ palette: palette
+ ? [{ slug: 'p', name: 'P', color: palette }]
+ : undefined,
+ },
+ },
+ } as ThemeOverrides;
+}
+
+/** Install a postMessage spy on the iframe element's contentWindow.
+ * jsdom gives every iframe a contentWindow with its own postMessage;
+ * we replace it with vi.fn() so we can inspect calls. */
+function spyOnFrame(): {
+ postMessage: ReturnType;
+ restore: () => void;
+} {
+ // Capture every iframe created in the test and install the spy when
+ // src is assigned. Doing it on first render is enough because the
+ // PreviewFrame keeps a stable ref across rerenders.
+ const calls: ReturnType = vi.fn();
+ const origDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLIFrameElement.prototype,
+ 'contentWindow',
+ );
+ Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
+ configurable: true,
+ get() {
+ return { postMessage: calls } as unknown as Window;
+ },
+ });
+ return {
+ postMessage: calls,
+ restore: () => {
+ if (origDescriptor) {
+ Object.defineProperty(
+ HTMLIFrameElement.prototype,
+ 'contentWindow',
+ origDescriptor,
+ );
+ } else {
+ delete (HTMLIFrameElement.prototype as { contentWindow?: Window })
+ .contentWindow;
+ }
+ },
+ };
+}
+
+describe('PreviewFrame postMessage bridge', () => {
+ it('still wires the encoded-URL fallback on the iframe src', () => {
+ const spy = spyOnFrame();
+ try {
+ const { getByTestId } = render(
+ ,
+ );
+ const frame = getByTestId('customizer-preview-frame') as HTMLIFrameElement;
+ expect(frame.src).toMatch(/customizer=preview/);
+ expect(frame.src).toMatch(/overrides=/);
+ } finally {
+ spy.restore();
+ }
+ });
+
+ it('publishes a versioned update message on mount', () => {
+ const spy = spyOnFrame();
+ try {
+ render(
+ ,
+ );
+ expect(spy.postMessage).toHaveBeenCalledTimes(1);
+ const [payload, targetOrigin] = spy.postMessage.mock.calls[0] as [
+ Record,
+ string,
+ ];
+ expect(payload.channel).toBe(CUSTOMIZER_PREVIEW_CHANNEL);
+ expect(payload.type).toBe('overrides:update');
+ expect(payload.version).toBe(CUSTOMIZER_PREVIEW_VERSION);
+ expect(targetOrigin).toBe('http://example.test');
+ } finally {
+ spy.restore();
+ }
+ });
+
+ it('fires another update when overrides change', () => {
+ const spy = spyOnFrame();
+ try {
+ const { rerender } = render(
+ ,
+ );
+ const initial = spy.postMessage.mock.calls.length;
+ rerender(
+ ,
+ );
+ expect(spy.postMessage.mock.calls.length).toBeGreaterThan(initial);
+ const lastCall =
+ spy.postMessage.mock.calls[spy.postMessage.mock.calls.length - 1];
+ if (lastCall === undefined) throw new Error('expected last call');
+ const lastPayload = lastCall[0] as { overrides: ThemeOverrides };
+ expect(
+ lastPayload.overrides.settings?.color?.palette?.[0]?.color,
+ ).toBe('#def');
+ } finally {
+ spy.restore();
+ }
+ });
+
+ it('skips postMessage when publicSiteUrl is unparseable', () => {
+ const spy = spyOnFrame();
+ try {
+ render(
+ ,
+ );
+ expect(spy.postMessage).not.toHaveBeenCalled();
+ } finally {
+ spy.restore();
+ }
+ });
+});
diff --git a/apps/admin/src/app/(authenticated)/appearance/customizer/components/PreviewFrame.tsx b/apps/admin/src/app/(authenticated)/appearance/customizer/components/PreviewFrame.tsx
index 1275e291..5d0d6e35 100644
--- a/apps/admin/src/app/(authenticated)/appearance/customizer/components/PreviewFrame.tsx
+++ b/apps/admin/src/app/(authenticated)/appearance/customizer/components/PreviewFrame.tsx
@@ -3,21 +3,89 @@
/**
* PreviewFrame — live preview iframe to the public site.
*
- * The iframe loads `?customizer=preview&overrides=`
- * — the renderer detects the query flag and applies the inline
- * overrides without persisting them. As the operator tweaks the
- * sidebar controls, the URL updates and the iframe re-navigates.
+ * The iframe initially loads
+ * `?customizer=preview&overrides=` so the public
+ * renderer rebuilds the CSS-variable map from the encoded blob on first
+ * paint. This keeps preview inspectable from devtools: open the iframe
+ * URL in a new tab and the same overrides apply, no parent app needed.
*
- * We do NOT use postMessage for the preview update because the
- * renderer's preview shim is intentionally stateless — it expects the
- * overrides on every load and rebuilds the CSS variable map from
- * scratch. That keeps the preview surface inspectable from devtools
- * without a runtime message handler.
+ * On top of that one-shot load we now publish a postMessage stream so
+ * subsequent edits in the parent customizer apply *without* a full
+ * reload. The cadence — every keystroke or slider drag — is too tight
+ * for a navigation cycle to feel live, especially on slow connections.
+ * The renderer-side shim listens for these messages and patches its
+ * inline CSS variables in place; if the shim isn't yet wired the
+ * iframe simply ignores the message and the URL-encoded overrides keep
+ * acting as the source of truth.
+ *
+ * ─── Message schema ─────────────────────────────────────────────────
+ *
+ * Direction: parent → child (the customizer admin → the public site
+ * inside the iframe).
+ *
+ * Channel: {@link CUSTOMIZER_PREVIEW_CHANNEL} (`"gonext.customizer"`)
+ * on `message.data.channel`. Messages without this discriminator
+ * are ignored on both sides — keeps the shim safe to mount
+ * on any page even if a third-party widget posts unrelated
+ * messages.
+ *
+ * Origin: Same-origin only. The parent calls
+ * `iframe.contentWindow.postMessage(payload, expectedOrigin)`
+ * with `expectedOrigin` derived from `publicSiteUrl`, so the
+ * browser refuses to deliver the message if the iframe ever
+ * navigated to a different origin (mitigates a cross-origin
+ * leak of theme overrides — they're not secrets, but the
+ * same posture protects future capability payloads).
+ *
+ * Schema (TypeScript shape, version-tagged):
+ *
+ * {
+ * channel: 'gonext.customizer',
+ * type: 'overrides:update',
+ * version: 1,
+ * overrides: ThemeOverrides,
+ * }
+ *
+ * Future message types are expected to share the same envelope (channel
+ * + version) so the renderer-side switch is by `type`. The version field
+ * gates breaking schema changes — a v2 message a v1 shim doesn't
+ * recognise should still be safely ignored.
+ *
+ * ─── Why postMessage and not a re-navigate ──────────────────────────
+ *
+ * The previous shape (re-navigation per edit) was correct for the
+ * stateless renderer shim but cost an entire page lifecycle per
+ * keystroke — script re-eval, image refetch, scroll position lost.
+ * postMessage delivers the delta to a still-loaded page and lets the
+ * shim animate or merge changes; the renderer can fall back to a
+ * full reload by checking `version` if it ever finds itself behind a
+ * payload it can't apply.
*/
-import { useMemo, type ReactElement } from 'react';
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ type ReactElement,
+} from 'react';
import { previewUrl } from '../state';
import type { ThemeOverrides } from '../types';
+/** Discriminator both ends check on `message.data.channel`. Picked so
+ * the message is unambiguously ours even on a page hosting other
+ * postMessage senders (analytics SDKs, embedded widgets, etc.). */
+export const CUSTOMIZER_PREVIEW_CHANNEL = 'gonext.customizer';
+
+/** Current message-envelope version. Bump only on a breaking change. */
+export const CUSTOMIZER_PREVIEW_VERSION = 1 as const;
+
+/** Live-update message: every customizer edit produces one of these. */
+export interface CustomizerPreviewUpdateMessage {
+ channel: typeof CUSTOMIZER_PREVIEW_CHANNEL;
+ type: 'overrides:update';
+ version: typeof CUSTOMIZER_PREVIEW_VERSION;
+ overrides: ThemeOverrides;
+}
+
export interface PreviewFrameProps {
/** Absolute URL to the public site root, e.g. http://localhost:3000. */
publicSiteUrl: string;
@@ -37,10 +105,15 @@ export function PreviewFrame({
previewPath = '/',
frameWidth = null,
}: PreviewFrameProps): ReactElement {
- // Derive the iframe src from the inputs. useMemo keeps the URL
- // stable across renders that don't actually change the override —
- // without it the iframe would refetch on every keystroke that
- // didn't touch the customizer state.
+ // Initial src derives from the inputs. useMemo keeps the URL stable
+ // across renders that don't actually change the override — without
+ // it the iframe would refetch on every keystroke that didn't touch
+ // the customizer state.
+ //
+ // Once the postMessage bridge below takes over, this src only
+ // matters on first paint and on coarse navigations (different
+ // previewPath, frameWidth change). Keystroke-grained edits flow
+ // through the message channel instead.
const src = useMemo(
() => previewUrl(joinUrl(publicSiteUrl, previewPath), overrides),
[publicSiteUrl, previewPath, overrides],
@@ -52,9 +125,55 @@ export function PreviewFrame({
// assert without relying on style parsing.
const widthAttr = frameWidth && frameWidth > 0 ? frameWidth : undefined;
+ const iframeRef = useRef(null);
+
+ // Stable origin derived from the configured public site URL. The
+ // browser refuses to deliver the postMessage if the iframe ever
+ // navigates to a different origin — this is the "same-origin
+ // allowlist" called out in the brief, expressed as the second arg
+ // to postMessage rather than a parsed string-match.
+ const expectedOrigin = useMemo(
+ () => safeOrigin(publicSiteUrl),
+ [publicSiteUrl],
+ );
+
+ // Publish a live-update message whenever `overrides` changes. The
+ // effect runs once per render where the overrides reference flipped;
+ // CustomizerClient already memoizes that value so this fires once
+ // per logical edit, not once per keystroke through React tree
+ // updates that don't touch the override map.
+ //
+ // The first render also fires a message, which is intentional — the
+ // iframe might still be mid-load, in which case the renderer-side
+ // shim queues the latest payload and applies it on its DOMContentLoaded.
+ useEffect(() => {
+ const frame = iframeRef.current;
+ if (frame === null) return;
+ const target = frame.contentWindow;
+ if (target === null) return;
+ if (expectedOrigin === null) return; // bad publicSiteUrl, fall back to URL re-render
+
+ const message: CustomizerPreviewUpdateMessage = {
+ channel: CUSTOMIZER_PREVIEW_CHANNEL,
+ type: 'overrides:update',
+ version: CUSTOMIZER_PREVIEW_VERSION,
+ overrides,
+ };
+ try {
+ target.postMessage(message, expectedOrigin);
+ } catch {
+ // postMessage throws TypeError if the structured-clone fails
+ // (e.g. a cyclic object slipped into overrides). Swallow — the
+ // URL-encoded fallback already covers the user-visible state,
+ // and we don't want to break the customizer over a serialise
+ // hiccup. A console.warn would be noise during normal edits.
+ }
+ }, [overrides, expectedOrigin]);
+
return (