create-nextblock 0.14.4 → 0.14.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
- package/templates/nextblock-template/app/actions/postActions.ts +3 -0
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +362 -1
- package/templates/nextblock-template/app/api/view/route.ts +114 -0
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
- package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
- package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
- package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
- package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
- package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
- package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
- package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
- package/templates/nextblock-template/app/page.tsx +6 -3
- package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
- package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
- package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
- package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
- package/templates/nextblock-template/lib/search/server.ts +3 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// app/[slug]/page.tsx
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { getSsgSupabaseClient } from "@nextblock-cms/db/server";
|
|
4
|
+
import { buildPublishedAtOrFilter } from "@nextblock-cms/utils";
|
|
4
5
|
import { notFound } from "next/navigation";
|
|
5
6
|
import type { Metadata } from 'next';
|
|
6
7
|
import PageClientContent from "./PageClientContent";
|
|
@@ -55,7 +56,8 @@ export async function generateStaticParams(): Promise<ResolvedPageParams[]> {
|
|
|
55
56
|
const { data: pages, error } = await supabase
|
|
56
57
|
.from("pages")
|
|
57
58
|
.select("slug")
|
|
58
|
-
.eq("status", "published")
|
|
59
|
+
.eq("status", "published")
|
|
60
|
+
.or(buildPublishedAtOrFilter());
|
|
59
61
|
|
|
60
62
|
if (error || !pages) {
|
|
61
63
|
console.error("SSG: Error fetching page slugs for static params:", error);
|
|
@@ -105,6 +107,8 @@ export async function generateMetadata(
|
|
|
105
107
|
.select('language_id, slug')
|
|
106
108
|
.eq('translation_group_id', pageData.translation_group_id)
|
|
107
109
|
.eq('status', 'published')
|
|
110
|
+
// Never advertise a scheduled translation via hreflang.
|
|
111
|
+
.or(buildPublishedAtOrFilter())
|
|
108
112
|
]);
|
|
109
113
|
|
|
110
114
|
const { data: languages } = languagesResult;
|
|
@@ -180,7 +184,8 @@ export default async function DynamicPage({ params: paramsPromise }: PageProps)
|
|
|
180
184
|
.from("pages")
|
|
181
185
|
.select("slug, languages!inner(code)")
|
|
182
186
|
.eq("translation_group_id", pageData.translation_group_id)
|
|
183
|
-
.eq("status", "published")
|
|
187
|
+
.eq("status", "published")
|
|
188
|
+
.or(buildPublishedAtOrFilter());
|
|
184
189
|
|
|
185
190
|
if (translations) {
|
|
186
191
|
translations.forEach((translation: PageTranslation) => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// app/[slug]/page.utils.ts
|
|
2
2
|
import { createClient, getSsgSupabaseClient } from "@nextblock-cms/db/server";
|
|
3
3
|
import type { Database } from "@nextblock-cms/db";
|
|
4
|
+
import { buildPublishedAtOrFilter } from "@nextblock-cms/utils";
|
|
4
5
|
import { draftMode } from "next/headers";
|
|
5
6
|
import { resolveMediaUrl } from "../../lib/media/resolveMediaUrl";
|
|
6
7
|
import { getContentDraft } from "../../lib/visual-editing/draft-content";
|
|
@@ -96,7 +97,7 @@ function applyDraftToPage(page: SelectedPageType, draft: ContentDraftRow): Selec
|
|
|
96
97
|
slug: draftString(draft, "slug", page.slug),
|
|
97
98
|
language_id: languageId,
|
|
98
99
|
language_details: languageId === page.language_id ? page.language_details : null,
|
|
99
|
-
|
|
100
|
+
// Visibility is never taken from a draft — it lives on the row.
|
|
100
101
|
meta_title: draftNullableString(draft, "meta_title", page.meta_title),
|
|
101
102
|
meta_description: draftNullableString(draft, "meta_description", page.meta_description),
|
|
102
103
|
custom_canonical: draftNullableString(draft, "custom_canonical", page.custom_canonical),
|
|
@@ -280,7 +281,11 @@ export async function getPageDataBySlug(
|
|
|
280
281
|
.order('order', { foreignTable: 'blocks', ascending: true });
|
|
281
282
|
|
|
282
283
|
if (!isDraftModeEnabled) {
|
|
283
|
-
|
|
284
|
+
// A future published_at means "scheduled" — withheld from the public until
|
|
285
|
+
// it passes. Preview (draft mode) deliberately ignores the schedule.
|
|
286
|
+
preferredQuery = preferredQuery
|
|
287
|
+
.eq("status", "published")
|
|
288
|
+
.or(buildPublishedAtOrFilter());
|
|
284
289
|
}
|
|
285
290
|
|
|
286
291
|
const { data: preferredData, error: preferredError } = await preferredQuery.maybeSingle();
|
|
@@ -298,7 +303,7 @@ export async function getPageDataBySlug(
|
|
|
298
303
|
.order('order', { foreignTable: 'blocks', ascending: true });
|
|
299
304
|
|
|
300
305
|
if (!isDraftModeEnabled) {
|
|
301
|
-
pageQuery = pageQuery.eq("status", "published");
|
|
306
|
+
pageQuery = pageQuery.eq("status", "published").or(buildPublishedAtOrFilter());
|
|
302
307
|
}
|
|
303
308
|
|
|
304
309
|
const { data: candidatePagesData, error: pageError } = await pageQuery;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { cache } from 'react';
|
|
4
4
|
import { createClient } from '@nextblock-cms/db/server';
|
|
5
|
+
import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
|
|
5
6
|
import { revalidatePath } from 'next/cache';
|
|
6
7
|
import type { Database } from '@nextblock-cms/db';
|
|
7
8
|
import type { PostWithMediaDimensions } from '../../components/blocks/types';
|
|
@@ -105,6 +106,8 @@ async function fetchPublishedPostsPage(languageId: number, page: number, limit:
|
|
|
105
106
|
{ count: 'exact' }
|
|
106
107
|
)
|
|
107
108
|
.eq('status', 'published')
|
|
109
|
+
// Without this a scheduled post is listed here while its own URL 404s.
|
|
110
|
+
.or(buildPublishedAtOrFilter())
|
|
108
111
|
.eq('language_id', languageId)
|
|
109
112
|
.order('published_at', { ascending: false })
|
|
110
113
|
.range(offset, offset + limit - 1);
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Visibility actions — the ONLY writes that can change what the public sees.
|
|
5
|
+
*
|
|
6
|
+
* These deliberately bypass the Live Draft pipeline (`content_drafts` /
|
|
7
|
+
* `product_drafts`) entirely: they read nothing from a draft, create nothing, and
|
|
8
|
+
* leave any pending content edits exactly where they are. Publishing a page and
|
|
9
|
+
* publishing your unsaved edits are two different decisions, so they are two
|
|
10
|
+
* different code paths.
|
|
11
|
+
*
|
|
12
|
+
* Visibility is stored as the (status, published_at) pair — see
|
|
13
|
+
* `libs/utils/src/lib/publishing.ts` for how "scheduled" is derived from it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createClient } from "@nextblock-cms/db/server";
|
|
17
|
+
import { revalidatePath } from "next/cache";
|
|
18
|
+
import {
|
|
19
|
+
LIVE_STATUS,
|
|
20
|
+
isFutureSchedule,
|
|
21
|
+
resolveVisibilityState,
|
|
22
|
+
type PublishableType,
|
|
23
|
+
type VisibilityState,
|
|
24
|
+
} from "@nextblock-cms/utils";
|
|
25
|
+
import { getHomepageTranslationGroupId } from "../lib/homepage";
|
|
26
|
+
|
|
27
|
+
export interface VisibilityResult {
|
|
28
|
+
error?: string;
|
|
29
|
+
state?: VisibilityState;
|
|
30
|
+
publishedAt?: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the caller wants the content to become. */
|
|
34
|
+
export type VisibilityIntent =
|
|
35
|
+
| { action: "publish" }
|
|
36
|
+
| { action: "schedule"; publishedAt: string }
|
|
37
|
+
| { action: "unpublish" }
|
|
38
|
+
| { action: "archive" };
|
|
39
|
+
|
|
40
|
+
const TABLE: Record<PublishableType, "pages" | "posts" | "products"> = {
|
|
41
|
+
page: "pages",
|
|
42
|
+
post: "posts",
|
|
43
|
+
product: "products",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const CMS_LIST_PATH: Record<PublishableType, string> = {
|
|
47
|
+
page: "/cms/pages",
|
|
48
|
+
post: "/cms/posts",
|
|
49
|
+
product: "/cms/products",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function publicPath(type: PublishableType, slug: string): string {
|
|
53
|
+
if (type === "post") return `/article/${slug}`;
|
|
54
|
+
if (type === "product") return `/product/${slug}`;
|
|
55
|
+
return `/${slug}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Turn an intent into the columns to write.
|
|
60
|
+
*
|
|
61
|
+
* "Publish" always clears `published_at`. Leaving a past timestamp behind would be
|
|
62
|
+
* harmless for the public gate but would keep rendering as a stale schedule in the
|
|
63
|
+
* CMS; leaving a FUTURE one would mean "Publish now" silently did nothing.
|
|
64
|
+
*/
|
|
65
|
+
function buildUpdate(type: PublishableType, intent: VisibilityIntent) {
|
|
66
|
+
const liveStatus = LIVE_STATUS[type];
|
|
67
|
+
|
|
68
|
+
switch (intent.action) {
|
|
69
|
+
case "publish":
|
|
70
|
+
return { status: liveStatus, published_at: null };
|
|
71
|
+
case "schedule":
|
|
72
|
+
return { status: liveStatus, published_at: intent.publishedAt };
|
|
73
|
+
case "unpublish":
|
|
74
|
+
return { status: "draft", published_at: null };
|
|
75
|
+
case "archive":
|
|
76
|
+
return { status: "archived", published_at: null };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Set a page/post/product's visibility directly on its row.
|
|
82
|
+
*
|
|
83
|
+
* `id` is a number for pages and posts, a uuid string for products.
|
|
84
|
+
*/
|
|
85
|
+
export async function setContentVisibility(
|
|
86
|
+
type: PublishableType,
|
|
87
|
+
id: number | string,
|
|
88
|
+
intent: VisibilityIntent,
|
|
89
|
+
): Promise<VisibilityResult> {
|
|
90
|
+
const supabase = createClient();
|
|
91
|
+
const {
|
|
92
|
+
data: { user },
|
|
93
|
+
} = await supabase.auth.getUser();
|
|
94
|
+
if (!user) {
|
|
95
|
+
return { error: "You must be signed in to change visibility." };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { data: profile } = await supabase
|
|
99
|
+
.from("profiles")
|
|
100
|
+
.select("role")
|
|
101
|
+
.eq("id", user.id)
|
|
102
|
+
.single();
|
|
103
|
+
if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
|
|
104
|
+
return { error: "You do not have permission to change visibility." };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (intent.action === "schedule" && !isFutureSchedule(intent.publishedAt)) {
|
|
108
|
+
return { error: "Pick a date and time in the future, or publish now instead." };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const update = buildUpdate(type, intent);
|
|
112
|
+
const { data: row, error } = await supabase
|
|
113
|
+
.from(TABLE[type])
|
|
114
|
+
.update({ ...update, updated_at: new Date().toISOString() } as never)
|
|
115
|
+
.eq("id", id as never)
|
|
116
|
+
.select("slug, status, published_at, translation_group_id")
|
|
117
|
+
.single();
|
|
118
|
+
|
|
119
|
+
if (error || !row) {
|
|
120
|
+
return { error: error?.message || "Could not update visibility." };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const updated = row as unknown as {
|
|
124
|
+
slug: string | null;
|
|
125
|
+
status: string;
|
|
126
|
+
published_at: string | null;
|
|
127
|
+
translation_group_id: string | null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
revalidatePath(CMS_LIST_PATH[type]);
|
|
131
|
+
revalidatePath(`${CMS_LIST_PATH[type]}/${id}/edit`);
|
|
132
|
+
|
|
133
|
+
if (updated.slug) {
|
|
134
|
+
revalidatePath(publicPath(type, updated.slug));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Every language variation of the homepage is also served at "/", whatever its
|
|
138
|
+
// slug, so taking one down (or putting it up) has to bust that cache too.
|
|
139
|
+
if (type === "page") {
|
|
140
|
+
const homepageGroupId = await getHomepageTranslationGroupId(supabase);
|
|
141
|
+
if (
|
|
142
|
+
(homepageGroupId && updated.translation_group_id === homepageGroupId) ||
|
|
143
|
+
updated.slug === "home"
|
|
144
|
+
) {
|
|
145
|
+
revalidatePath("/");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (type === "post") {
|
|
150
|
+
revalidatePath("/articles");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
state: resolveVisibilityState({
|
|
155
|
+
status: updated.status,
|
|
156
|
+
publishedAt: updated.published_at,
|
|
157
|
+
liveStatus: LIVE_STATUS[type],
|
|
158
|
+
}),
|
|
159
|
+
publishedAt: updated.published_at,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Sibling-language versions of the same content, with their visibility state.
|
|
165
|
+
*
|
|
166
|
+
* Status is per-row, so publishing the French page does nothing for English. The
|
|
167
|
+
* publish dialog shows this list to catch the half-published translation — the
|
|
168
|
+
* single most common multilingual mistake.
|
|
169
|
+
*/
|
|
170
|
+
export interface SiblingVisibility {
|
|
171
|
+
id: number | string;
|
|
172
|
+
languageId: number;
|
|
173
|
+
title: string;
|
|
174
|
+
state: VisibilityState;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function getSiblingVisibility(
|
|
178
|
+
type: PublishableType,
|
|
179
|
+
translationGroupId: string,
|
|
180
|
+
excludeId: number | string,
|
|
181
|
+
): Promise<SiblingVisibility[]> {
|
|
182
|
+
if (!translationGroupId) return [];
|
|
183
|
+
|
|
184
|
+
const supabase = createClient();
|
|
185
|
+
const { data, error } = await supabase
|
|
186
|
+
.from(TABLE[type])
|
|
187
|
+
.select("id, title, language_id, status, published_at")
|
|
188
|
+
.eq("translation_group_id", translationGroupId);
|
|
189
|
+
|
|
190
|
+
if (error || !data) return [];
|
|
191
|
+
|
|
192
|
+
return (data as unknown as Array<{
|
|
193
|
+
id: number | string;
|
|
194
|
+
title: string;
|
|
195
|
+
language_id: number;
|
|
196
|
+
status: string;
|
|
197
|
+
published_at: string | null;
|
|
198
|
+
}>)
|
|
199
|
+
.filter((row) => String(row.id) !== String(excludeId))
|
|
200
|
+
.map((row) => ({
|
|
201
|
+
id: row.id,
|
|
202
|
+
languageId: row.language_id,
|
|
203
|
+
title: row.title,
|
|
204
|
+
state: resolveVisibilityState({
|
|
205
|
+
status: row.status,
|
|
206
|
+
publishedAt: row.published_at,
|
|
207
|
+
liveStatus: LIVE_STATUS[type],
|
|
208
|
+
}),
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
@@ -18,13 +18,20 @@ const cacheMocks = vi.hoisted(() => ({
|
|
|
18
18
|
|
|
19
19
|
vi.mock("next/cache", () => cacheMocks);
|
|
20
20
|
vi.mock("server-only", () => ({}));
|
|
21
|
-
vi.
|
|
21
|
+
const revisionMocks = vi.hoisted(() => ({
|
|
22
|
+
createPageRevision: vi.fn(),
|
|
23
|
+
createPostRevision: vi.fn(),
|
|
22
24
|
getFullPageContent: vi.fn(),
|
|
23
25
|
getFullPostContent: vi.fn(),
|
|
24
26
|
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("../cms/revisions/utils", () => ({
|
|
29
|
+
getFullPageContent: revisionMocks.getFullPageContent,
|
|
30
|
+
getFullPostContent: revisionMocks.getFullPostContent,
|
|
31
|
+
}));
|
|
25
32
|
vi.mock("../cms/revisions/service", () => ({
|
|
26
|
-
createPageRevision:
|
|
27
|
-
createPostRevision:
|
|
33
|
+
createPageRevision: revisionMocks.createPageRevision,
|
|
34
|
+
createPostRevision: revisionMocks.createPostRevision,
|
|
28
35
|
}));
|
|
29
36
|
vi.mock("../../lib/visual-editing/draft-content", () => draftContentMocks);
|
|
30
37
|
vi.mock("../../lib/visual-editing/product-drafts", () => ({
|
|
@@ -176,4 +183,77 @@ describe("visual editing server actions", () => {
|
|
|
176
183
|
expect(result).toEqual({ error: "No draft exists for this content." });
|
|
177
184
|
expect(from).toHaveBeenCalledWith("content_drafts");
|
|
178
185
|
});
|
|
186
|
+
|
|
187
|
+
describe("publishing a page draft", () => {
|
|
188
|
+
const previousContent = { meta: { title: "Before" }, blocks: [] };
|
|
189
|
+
const nextContent = { meta: { title: "After" }, blocks: [] };
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Publishing is the only path that writes the live tables, so it is also the only
|
|
193
|
+
* place a page/post revision gets recorded. The chain here mirrors the real one:
|
|
194
|
+
* read the draft, update the row, swap the blocks, delete the draft.
|
|
195
|
+
*/
|
|
196
|
+
function mockPublishChain() {
|
|
197
|
+
const chain: any = {
|
|
198
|
+
delete: vi.fn(() => chain),
|
|
199
|
+
eq: vi.fn(() => chain),
|
|
200
|
+
error: null,
|
|
201
|
+
insert: vi.fn(() => chain),
|
|
202
|
+
maybeSingle: vi.fn().mockResolvedValue({ data: baseDraft, error: null }),
|
|
203
|
+
select: vi.fn(() => chain),
|
|
204
|
+
update: vi.fn(() => chain),
|
|
205
|
+
};
|
|
206
|
+
const from = vi.fn(() => chain);
|
|
207
|
+
draftContentMocks.getCurrentUserCanEdit.mockResolvedValue({
|
|
208
|
+
canEdit: true,
|
|
209
|
+
supabase: { from },
|
|
210
|
+
user: { id: "user-1" },
|
|
211
|
+
});
|
|
212
|
+
return { chain, from };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
it("records a revision from the pre-publish state", async () => {
|
|
216
|
+
mockPublishChain();
|
|
217
|
+
revisionMocks.getFullPageContent
|
|
218
|
+
.mockResolvedValueOnce(previousContent)
|
|
219
|
+
.mockResolvedValueOnce(nextContent);
|
|
220
|
+
revisionMocks.createPageRevision.mockResolvedValue({
|
|
221
|
+
recorded: true,
|
|
222
|
+
success: true,
|
|
223
|
+
version: 2,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const result = await publishVisualEditingDraft("page", 2);
|
|
227
|
+
|
|
228
|
+
expect(result).toEqual({ success: true });
|
|
229
|
+
expect(revisionMocks.createPageRevision).toHaveBeenCalledWith(
|
|
230
|
+
2,
|
|
231
|
+
"user-1",
|
|
232
|
+
previousContent,
|
|
233
|
+
nextContent
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("still completes the publish when the revision fails, and warns instead of erroring", async () => {
|
|
238
|
+
const { chain } = mockPublishChain();
|
|
239
|
+
revisionMocks.getFullPageContent
|
|
240
|
+
.mockResolvedValueOnce(previousContent)
|
|
241
|
+
.mockResolvedValueOnce(nextContent);
|
|
242
|
+
revisionMocks.createPageRevision.mockResolvedValue({
|
|
243
|
+
error: "Failed to insert page revision: permission denied",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const result = await publishVisualEditingDraft("page", 2);
|
|
247
|
+
|
|
248
|
+
// The page row and its blocks are already live at this point. Aborting would strand
|
|
249
|
+
// the draft row and leave the public route un-revalidated, which is worse than a
|
|
250
|
+
// missing history entry — so this is a partial success, not a failure.
|
|
251
|
+
expect(result).toMatchObject({
|
|
252
|
+
success: true,
|
|
253
|
+
warning: expect.stringContaining("history was not recorded"),
|
|
254
|
+
});
|
|
255
|
+
expect(chain.delete).toHaveBeenCalled();
|
|
256
|
+
expect(cacheMocks.revalidatePath).toHaveBeenCalledWith("/about");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
179
259
|
});
|
|
@@ -164,7 +164,15 @@ function addNumberMeta(
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Applies a page draft to the live tables and records the revision.
|
|
169
|
+
*
|
|
170
|
+
* Returns a warning string when the content went live but the revision did not record —
|
|
171
|
+
* never throws for that case. Once the page row and its blocks have been rewritten, the
|
|
172
|
+
* publish has happened; aborting here would leave the draft row undeleted and the public
|
|
173
|
+
* route un-revalidated, which is strictly worse than a missing history entry.
|
|
174
|
+
*/
|
|
175
|
+
async function publishPageDraft(draft: ContentDraftRow, authorId: string): Promise<string | null> {
|
|
168
176
|
const auth = await getCurrentUserCanEdit();
|
|
169
177
|
const supabase = auth.supabase;
|
|
170
178
|
const previousContent = await getFullPageContent(draft.parent_id);
|
|
@@ -172,7 +180,11 @@ async function publishPageDraft(draft: ContentDraftRow, authorId: string) {
|
|
|
172
180
|
addStringMeta(pageUpdate, draft, "title");
|
|
173
181
|
addStringMeta(pageUpdate, draft, "slug");
|
|
174
182
|
addNumberMeta(pageUpdate, draft, "language_id");
|
|
175
|
-
|
|
183
|
+
// `status`/`published_at` are deliberately NOT copied from the draft. Visibility
|
|
184
|
+
// is owned by the top-bar control (setPageVisibility) and written straight to the
|
|
185
|
+
// row; publishing a draft that still carries an old status would silently roll a
|
|
186
|
+
// page back to draft — or push one live — behind the editor's back. Older drafts
|
|
187
|
+
// may still hold those keys; they are inert.
|
|
176
188
|
addNullableStringMeta(pageUpdate, draft, "meta_title");
|
|
177
189
|
addNullableStringMeta(pageUpdate, draft, "meta_description");
|
|
178
190
|
addNullableStringMeta(pageUpdate, draft, "custom_canonical");
|
|
@@ -204,12 +216,15 @@ async function publishPageDraft(draft: ContentDraftRow, authorId: string) {
|
|
|
204
216
|
}
|
|
205
217
|
|
|
206
218
|
const nextContent = await getFullPageContent(draft.parent_id);
|
|
207
|
-
if (previousContent
|
|
208
|
-
|
|
219
|
+
if (!previousContent || !nextContent) {
|
|
220
|
+
return "the page content could not be read back";
|
|
209
221
|
}
|
|
222
|
+
const revision = await createPageRevision(draft.parent_id, authorId, previousContent, nextContent);
|
|
223
|
+
return "error" in revision ? revision.error : null;
|
|
210
224
|
}
|
|
211
225
|
|
|
212
|
-
|
|
226
|
+
/** See publishPageDraft — same contract. */
|
|
227
|
+
async function publishPostDraft(draft: ContentDraftRow, authorId: string): Promise<string | null> {
|
|
213
228
|
const auth = await getCurrentUserCanEdit();
|
|
214
229
|
const supabase = auth.supabase;
|
|
215
230
|
const previousContent = await getFullPostContent(draft.parent_id);
|
|
@@ -217,14 +232,14 @@ async function publishPostDraft(draft: ContentDraftRow, authorId: string) {
|
|
|
217
232
|
addStringMeta(postUpdate, draft, "title");
|
|
218
233
|
addStringMeta(postUpdate, draft, "slug");
|
|
219
234
|
addNumberMeta(postUpdate, draft, "language_id");
|
|
220
|
-
|
|
235
|
+
// See publishPageDraft: visibility (`status`/`published_at`) never rides along
|
|
236
|
+
// with a content draft.
|
|
221
237
|
addNullableStringMeta(postUpdate, draft, "meta_title");
|
|
222
238
|
addNullableStringMeta(postUpdate, draft, "meta_description");
|
|
223
239
|
addNullableStringMeta(postUpdate, draft, "custom_canonical");
|
|
224
240
|
addNullableStringMeta(postUpdate, draft, "label");
|
|
225
241
|
addNullableStringMeta(postUpdate, draft, "excerpt");
|
|
226
242
|
addNullableStringMeta(postUpdate, draft, "subtitle");
|
|
227
|
-
addNullableStringMeta(postUpdate, draft, "published_at");
|
|
228
243
|
addNullableStringMeta(postUpdate, draft, "feature_image_id");
|
|
229
244
|
|
|
230
245
|
const { error: postError } = await supabase
|
|
@@ -253,9 +268,11 @@ async function publishPostDraft(draft: ContentDraftRow, authorId: string) {
|
|
|
253
268
|
}
|
|
254
269
|
|
|
255
270
|
const nextContent = await getFullPostContent(draft.parent_id);
|
|
256
|
-
if (previousContent
|
|
257
|
-
|
|
271
|
+
if (!previousContent || !nextContent) {
|
|
272
|
+
return "the post content could not be read back";
|
|
258
273
|
}
|
|
274
|
+
const revision = await createPostRevision(draft.parent_id, authorId, previousContent, nextContent);
|
|
275
|
+
return "error" in revision ? revision.error : null;
|
|
259
276
|
}
|
|
260
277
|
|
|
261
278
|
export async function publishVisualEditingDraft(parentType: NextblockDocumentType, parentId: number) {
|
|
@@ -283,11 +300,10 @@ export async function publishVisualEditingDraft(parentType: NextblockDocumentTyp
|
|
|
283
300
|
|
|
284
301
|
const draft = normalizeContentDraftRow(data);
|
|
285
302
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
303
|
+
const revisionWarning =
|
|
304
|
+
parentType === "page"
|
|
305
|
+
? await publishPageDraft(draft, auth.user.id)
|
|
306
|
+
: await publishPostDraft(draft, auth.user.id);
|
|
291
307
|
|
|
292
308
|
const { error: deleteError } = await (auth.supabase as any)
|
|
293
309
|
.from("content_drafts")
|
|
@@ -304,6 +320,10 @@ export async function publishVisualEditingDraft(parentType: NextblockDocumentTyp
|
|
|
304
320
|
}
|
|
305
321
|
revalidateVisualEditingPath(parentType === "page" ? `/cms/pages/${parentId}/edit` : `/cms/posts/${parentId}/edit`);
|
|
306
322
|
|
|
323
|
+
if (revisionWarning) {
|
|
324
|
+
return { success: true, warning: `Published, but history was not recorded: ${revisionWarning}` };
|
|
325
|
+
}
|
|
326
|
+
|
|
307
327
|
return { success: true };
|
|
308
328
|
} catch (error) {
|
|
309
329
|
return {
|
|
@@ -41,6 +41,11 @@ import {
|
|
|
41
41
|
} from '@nextblock-cms/cortex';
|
|
42
42
|
import { validateBlockContent } from '../../../../lib/blocks/blockRegistry';
|
|
43
43
|
import { importExternalImageToMedia } from '../../../cms/media/import-external-image';
|
|
44
|
+
import {
|
|
45
|
+
captureRevisionBaseline,
|
|
46
|
+
commitRevisionFromBaseline,
|
|
47
|
+
} from '../../../cms/revisions/service';
|
|
48
|
+
import type { AnyFullContent } from '../../../cms/revisions/utils';
|
|
44
49
|
|
|
45
50
|
export const dynamic = 'force-dynamic';
|
|
46
51
|
|
|
@@ -72,6 +77,43 @@ async function importExternalImageForCortex(input: {
|
|
|
72
77
|
return { id: result.media.id };
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Bridges the app's revision engine into the Cortex tool context so that content the agent
|
|
82
|
+
* writes straight to the live tables lands in Revision History like any other edit.
|
|
83
|
+
*
|
|
84
|
+
* Every AI write goes through here, including the ones inside a multi-step action plan —
|
|
85
|
+
* the callback lives on the tool context, not on the route, so it also covers the executors
|
|
86
|
+
* that run in-stream rather than via the Confirm button.
|
|
87
|
+
*
|
|
88
|
+
* `authorId` is the admin whose session authorised the request; the agent's Supabase client
|
|
89
|
+
* is service-role, so without this the revision row would land with a null author.
|
|
90
|
+
*/
|
|
91
|
+
function createCortexRevisionRecorder(authorId: string | null) {
|
|
92
|
+
return async function recordRevision(input: {
|
|
93
|
+
baseline?: unknown;
|
|
94
|
+
contentType: 'page' | 'post' | 'product';
|
|
95
|
+
entityId: number | string;
|
|
96
|
+
phase: 'capture' | 'commit';
|
|
97
|
+
}): Promise<unknown> {
|
|
98
|
+
if (input.phase === 'capture') {
|
|
99
|
+
return captureRevisionBaseline(input.contentType, input.entityId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result = await commitRevisionFromBaseline(
|
|
103
|
+
input.contentType,
|
|
104
|
+
input.entityId,
|
|
105
|
+
authorId,
|
|
106
|
+
(input.baseline ?? null) as AnyFullContent | null
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if ('error' in result) {
|
|
110
|
+
console.error('Cortex AI: revision not recorded —', result.error);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return undefined;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
75
117
|
const globalAgentMessageSchema = z.strictObject({
|
|
76
118
|
content: z.string().min(1).max(8000),
|
|
77
119
|
role: z.enum(['system', 'user', 'assistant']),
|
|
@@ -909,6 +951,7 @@ export async function POST(request: Request) {
|
|
|
909
951
|
importExternalImage: importExternalImageForCortex,
|
|
910
952
|
latestUserMessage: confirmedToolCall.confirmationPhrase,
|
|
911
953
|
pageContext,
|
|
954
|
+
recordRevision: createCortexRevisionRecorder(adminAccess.userId),
|
|
912
955
|
supabase: getServiceRoleSupabaseClient(),
|
|
913
956
|
validateBlockContent,
|
|
914
957
|
},
|
|
@@ -935,6 +978,7 @@ export async function POST(request: Request) {
|
|
|
935
978
|
importExternalImage: importExternalImageForCortex,
|
|
936
979
|
latestUserMessage,
|
|
937
980
|
pageContext,
|
|
981
|
+
recordRevision: createCortexRevisionRecorder(adminAccess.userId),
|
|
938
982
|
supabase: getServiceRoleSupabaseClient(),
|
|
939
983
|
validateBlockContent,
|
|
940
984
|
},
|
|
@@ -980,6 +1024,7 @@ export async function POST(request: Request) {
|
|
|
980
1024
|
importExternalImage: importExternalImageForCortex,
|
|
981
1025
|
latestUserMessage,
|
|
982
1026
|
pageContext,
|
|
1027
|
+
recordRevision: createCortexRevisionRecorder(adminAccess.userId),
|
|
983
1028
|
supabase: getServiceRoleSupabaseClient(),
|
|
984
1029
|
validateBlockContent,
|
|
985
1030
|
});
|