create-nextblock 0.14.4 → 0.14.6
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/AGENTS.md +9 -0
- package/templates/nextblock-template/CLAUDE.md +1 -0
- 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 +457 -1
- package/templates/nextblock-template/app/api/mcp/route.ts +346 -0
- 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/cms/settings/cortex-ai/McpServerSettingsCard.tsx +584 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +6 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +4 -20
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +205 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +64 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -0
- 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 +38 -3
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +151 -0
- 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 +15 -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/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from 'next/cache';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CORTEX_AI_MCP_SETTINGS_KEY,
|
|
7
|
+
CORTEX_AI_MCP_TOKENS_TABLE,
|
|
8
|
+
mintCortexAiMcpToken,
|
|
9
|
+
normalizeCortexAiMcpSettings,
|
|
10
|
+
type CortexAiMcpScope,
|
|
11
|
+
type CortexAiMcpSettings,
|
|
12
|
+
} from '@nextblock-cms/cortex';
|
|
13
|
+
|
|
14
|
+
import { requireAdminSupabaseClient } from './require-admin';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Server actions for the "MCP server access" card.
|
|
18
|
+
*
|
|
19
|
+
* Unlike the rest of `actions.ts`, these return values instead of redirecting with a
|
|
20
|
+
* `?success=` message. A minted token is displayed exactly once and must never travel
|
|
21
|
+
* in a URL — it would land in browser history, the referrer header, and the server
|
|
22
|
+
* access log. Returning it to a client component that renders it in-place and forgets
|
|
23
|
+
* it keeps the secret out of every one of those.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const CORTEX_AI_SETTINGS_PATH = '/cms/settings/cortex-ai';
|
|
27
|
+
|
|
28
|
+
const MAX_TOKEN_NAME_LENGTH = 80;
|
|
29
|
+
const MAX_TOKENS = 20;
|
|
30
|
+
|
|
31
|
+
export type McpAccessTokenSummary = {
|
|
32
|
+
createdAt: string;
|
|
33
|
+
expiresAt: string | null;
|
|
34
|
+
id: string;
|
|
35
|
+
lastUsedAt: string | null;
|
|
36
|
+
name: string;
|
|
37
|
+
scopes: CortexAiMcpScope[];
|
|
38
|
+
tokenPrefix: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type McpSettingsStatus = {
|
|
42
|
+
settings: CortexAiMcpSettings;
|
|
43
|
+
tokens: McpAccessTokenSummary[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function toScopeList(raw: unknown): CortexAiMcpScope[] {
|
|
47
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
48
|
+
const scopes = list.filter(
|
|
49
|
+
(entry): entry is CortexAiMcpScope => entry === 'read' || entry === 'write'
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return scopes.length > 0 ? Array.from(new Set(scopes)) : ['read'];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load MCP settings and the non-secret token list.
|
|
57
|
+
*
|
|
58
|
+
* Revoked tokens are filtered out rather than deleted at revoke time so the row keeps
|
|
59
|
+
* acting as a tombstone: the hash stays in the unique index, which makes an
|
|
60
|
+
* accidental re-mint of the same value impossible.
|
|
61
|
+
*/
|
|
62
|
+
export async function getMcpSettingsStatus(): Promise<McpSettingsStatus> {
|
|
63
|
+
const { supabase } = await requireAdminSupabaseClient();
|
|
64
|
+
|
|
65
|
+
const [{ data: settingsRow }, { data: tokenRows }] = await Promise.all([
|
|
66
|
+
supabase.from('site_settings').select('value').eq('key', CORTEX_AI_MCP_SETTINGS_KEY).maybeSingle(),
|
|
67
|
+
supabase
|
|
68
|
+
.from(CORTEX_AI_MCP_TOKENS_TABLE)
|
|
69
|
+
.select('id, name, scopes, token_prefix, created_at, expires_at, last_used_at, revoked_at')
|
|
70
|
+
.is('revoked_at', null)
|
|
71
|
+
.order('created_at', { ascending: false })
|
|
72
|
+
.limit(MAX_TOKENS),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
settings: normalizeCortexAiMcpSettings(settingsRow?.value),
|
|
77
|
+
tokens: (tokenRows ?? []).map((row: Record<string, any>) => ({
|
|
78
|
+
createdAt: row.created_at,
|
|
79
|
+
expiresAt: row.expires_at,
|
|
80
|
+
id: row.id,
|
|
81
|
+
lastUsedAt: row.last_used_at,
|
|
82
|
+
name: row.name,
|
|
83
|
+
scopes: toScopeList(row.scopes),
|
|
84
|
+
tokenPrefix: row.token_prefix,
|
|
85
|
+
})),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function saveMcpSettingsAction(input: {
|
|
90
|
+
allowLocalhostWithoutToken: boolean;
|
|
91
|
+
enabled: boolean;
|
|
92
|
+
}): Promise<{ error?: string; success: boolean }> {
|
|
93
|
+
try {
|
|
94
|
+
const { supabase } = await requireAdminSupabaseClient();
|
|
95
|
+
const value = normalizeCortexAiMcpSettings(input);
|
|
96
|
+
|
|
97
|
+
const { error } = await supabase
|
|
98
|
+
.from('site_settings')
|
|
99
|
+
.upsert({ key: CORTEX_AI_MCP_SETTINGS_KEY, value });
|
|
100
|
+
|
|
101
|
+
if (error) {
|
|
102
|
+
throw new Error(error.message);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
error: error instanceof Error ? error.message : 'Failed to save MCP server settings.',
|
|
107
|
+
success: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
revalidatePath(CORTEX_AI_SETTINGS_PATH);
|
|
112
|
+
return { success: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function createMcpAccessTokenAction(input: {
|
|
116
|
+
expiresInDays?: number | null;
|
|
117
|
+
name: string;
|
|
118
|
+
scopes: CortexAiMcpScope[];
|
|
119
|
+
}): Promise<{ error?: string; success: boolean; token?: string; tokenPrefix?: string }> {
|
|
120
|
+
try {
|
|
121
|
+
const { supabase, userId } = await requireAdminSupabaseClient();
|
|
122
|
+
|
|
123
|
+
const name = String(input.name || '').trim().slice(0, MAX_TOKEN_NAME_LENGTH);
|
|
124
|
+
|
|
125
|
+
if (!name) {
|
|
126
|
+
throw new Error('Give the token a name so you can tell your clients apart later.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const scopes = toScopeList(input.scopes);
|
|
130
|
+
|
|
131
|
+
const { count } = await supabase
|
|
132
|
+
.from(CORTEX_AI_MCP_TOKENS_TABLE)
|
|
133
|
+
.select('id', { count: 'exact', head: true })
|
|
134
|
+
.is('revoked_at', null);
|
|
135
|
+
|
|
136
|
+
if ((count ?? 0) >= MAX_TOKENS) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`You already have ${MAX_TOKENS} active MCP tokens. Revoke one before creating another.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const expiresInDays =
|
|
143
|
+
typeof input.expiresInDays === 'number' && Number.isFinite(input.expiresInDays)
|
|
144
|
+
? Math.min(3650, Math.max(1, Math.round(input.expiresInDays)))
|
|
145
|
+
: null;
|
|
146
|
+
|
|
147
|
+
const expiresAt = expiresInDays
|
|
148
|
+
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString()
|
|
149
|
+
: null;
|
|
150
|
+
|
|
151
|
+
const minted = mintCortexAiMcpToken();
|
|
152
|
+
|
|
153
|
+
const { error } = await supabase.from(CORTEX_AI_MCP_TOKENS_TABLE).insert({
|
|
154
|
+
created_by: userId,
|
|
155
|
+
expires_at: expiresAt,
|
|
156
|
+
name,
|
|
157
|
+
scopes,
|
|
158
|
+
token_hash: minted.tokenHash,
|
|
159
|
+
token_prefix: minted.tokenPrefix,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (error) {
|
|
163
|
+
throw new Error(error.message);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
revalidatePath(CORTEX_AI_SETTINGS_PATH);
|
|
167
|
+
|
|
168
|
+
return { success: true, token: minted.token, tokenPrefix: minted.tokenPrefix };
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return {
|
|
171
|
+
error: error instanceof Error ? error.message : 'Failed to create the MCP access token.',
|
|
172
|
+
success: false,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function revokeMcpAccessTokenAction(input: {
|
|
178
|
+
id: string;
|
|
179
|
+
}): Promise<{ error?: string; success: boolean }> {
|
|
180
|
+
try {
|
|
181
|
+
const { supabase } = await requireAdminSupabaseClient();
|
|
182
|
+
const id = String(input.id || '').trim();
|
|
183
|
+
|
|
184
|
+
if (!id) {
|
|
185
|
+
throw new Error('Missing token id.');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { error } = await supabase
|
|
189
|
+
.from(CORTEX_AI_MCP_TOKENS_TABLE)
|
|
190
|
+
.update({ revoked_at: new Date().toISOString() })
|
|
191
|
+
.eq('id', id);
|
|
192
|
+
|
|
193
|
+
if (error) {
|
|
194
|
+
throw new Error(error.message);
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return {
|
|
198
|
+
error: error instanceof Error ? error.message : 'Failed to revoke the MCP access token.',
|
|
199
|
+
success: false,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
revalidatePath(CORTEX_AI_SETTINGS_PATH);
|
|
204
|
+
return { success: true };
|
|
205
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { headers } from 'next/headers';
|
|
2
|
+
|
|
1
3
|
import { listCortexAiCompatibleOpenRouterModels } from '@nextblock-cms/cortex';
|
|
2
4
|
import { getCortexAiSettingsStatus } from './actions';
|
|
5
|
+
import { getMcpSettingsStatus } from './mcp-actions';
|
|
6
|
+
import { McpServerSettingsCard } from './McpServerSettingsCard';
|
|
3
7
|
import { SandboxCortexAiSettingsClient } from './SandboxCortexAiSettingsClient';
|
|
4
8
|
import { StoredCortexAiSettingsClient } from './StoredCortexAiSettingsClient';
|
|
5
9
|
import { redirect } from 'next/navigation';
|
|
@@ -22,6 +26,51 @@ function formatDate(value: string | null) {
|
|
|
22
26
|
}).format(new Date(value));
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The origin an external MCP client should dial.
|
|
31
|
+
*
|
|
32
|
+
* Prefers NEXT_PUBLIC_URL (the deployed canonical origin) and falls back to the
|
|
33
|
+
* request's own host, so the snippet is correct on a preview deployment or a custom
|
|
34
|
+
* domain that was never written into the env.
|
|
35
|
+
*/
|
|
36
|
+
async function resolveSiteOrigin(): Promise<string> {
|
|
37
|
+
const configured = process.env.NEXT_PUBLIC_URL?.trim();
|
|
38
|
+
|
|
39
|
+
if (configured) {
|
|
40
|
+
return configured.replace(/\/+$/, '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const headerList = await headers();
|
|
44
|
+
const host = headerList.get('host');
|
|
45
|
+
|
|
46
|
+
if (!host) {
|
|
47
|
+
return 'https://your-site.com';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const protocol = headerList.get('x-forwarded-proto') || (host.startsWith('localhost') ? 'http' : 'https');
|
|
51
|
+
|
|
52
|
+
return `${protocol}://${host}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The loopback origin to show in the "Localhost" client snippets.
|
|
57
|
+
*
|
|
58
|
+
* Ports differ per setup — `nx serve nextblock` uses Nx's default 4200, not Next's
|
|
59
|
+
* plain 3000 — and a snippet pointing at the wrong port fails with a bare connection
|
|
60
|
+
* error that gives the reader nothing to go on. When this page is itself being viewed
|
|
61
|
+
* over loopback, that request's own host is the authoritative answer.
|
|
62
|
+
*/
|
|
63
|
+
async function resolveLocalOrigin(): Promise<string> {
|
|
64
|
+
const headerList = await headers();
|
|
65
|
+
const host = headerList.get('host')?.trim();
|
|
66
|
+
|
|
67
|
+
if (host && /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host)) {
|
|
68
|
+
return `http://${host}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return 'http://localhost:4200';
|
|
72
|
+
}
|
|
73
|
+
|
|
25
74
|
export default async function CortexAiSettingsPage({
|
|
26
75
|
searchParams,
|
|
27
76
|
}: CortexAiSettingsPageProps) {
|
|
@@ -70,6 +119,12 @@ export default async function CortexAiSettingsPage({
|
|
|
70
119
|
);
|
|
71
120
|
}
|
|
72
121
|
|
|
122
|
+
const [mcpStatus, siteOrigin, localOrigin] = await Promise.all([
|
|
123
|
+
getMcpSettingsStatus(),
|
|
124
|
+
resolveSiteOrigin(),
|
|
125
|
+
resolveLocalOrigin(),
|
|
126
|
+
]);
|
|
127
|
+
|
|
73
128
|
return (
|
|
74
129
|
<StoredCortexAiSettingsClient
|
|
75
130
|
compatibleModels={compatibleModels as any}
|
|
@@ -95,6 +150,14 @@ export default async function CortexAiSettingsPage({
|
|
|
95
150
|
agentSettings={status.agentSettings}
|
|
96
151
|
successMessage={params.success}
|
|
97
152
|
errorMessage={params.error}
|
|
98
|
-
|
|
153
|
+
>
|
|
154
|
+
<McpServerSettingsCard
|
|
155
|
+
allowLocalhostWithoutToken={mcpStatus.settings.allowLocalhostWithoutToken}
|
|
156
|
+
enabled={mcpStatus.settings.enabled}
|
|
157
|
+
localMcpUrl={`${localOrigin}/api/mcp`}
|
|
158
|
+
mcpUrl={`${siteOrigin}/api/mcp`}
|
|
159
|
+
tokens={mcpStatus.tokens}
|
|
160
|
+
/>
|
|
161
|
+
</StoredCortexAiSettingsClient>
|
|
99
162
|
);
|
|
100
163
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createClient } from '@nextblock-cms/db/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Admin gate shared by the Cortex AI settings server actions.
|
|
5
|
+
*
|
|
6
|
+
* Lives outside `actions.ts` because that file is `'use server'`, where every export
|
|
7
|
+
* must itself be a valid server action — a helper returning a Supabase client cannot
|
|
8
|
+
* be exported from there. Keeping one copy matters more than the file count: this is
|
|
9
|
+
* the check standing between a WRITER and the OpenRouter key, the stock-photo keys,
|
|
10
|
+
* and the MCP access tokens.
|
|
11
|
+
*/
|
|
12
|
+
export async function requireAdminSupabaseClient() {
|
|
13
|
+
const supabase = createClient();
|
|
14
|
+
const {
|
|
15
|
+
data: { user },
|
|
16
|
+
error: userError,
|
|
17
|
+
} = await supabase.auth.getUser();
|
|
18
|
+
|
|
19
|
+
if (userError || !user) {
|
|
20
|
+
throw new Error('You must be logged in to manage Cortex AI settings.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { data: profile, error: profileError } = await supabase
|
|
24
|
+
.from('profiles')
|
|
25
|
+
.select('role')
|
|
26
|
+
.eq('id', user.id)
|
|
27
|
+
.single();
|
|
28
|
+
|
|
29
|
+
if (profileError || !profile || profile.role !== 'ADMIN') {
|
|
30
|
+
throw new Error('You do not have permission to manage Cortex AI settings.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { supabase, userId: user.id };
|
|
34
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSsgSupabaseClient } from '@nextblock-cms/db/server';
|
|
2
|
+
import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
|
|
2
3
|
import { getHomepageTranslationGroupId } from './homepage';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -164,7 +165,8 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
|
|
|
164
165
|
supabase
|
|
165
166
|
.from('pages')
|
|
166
167
|
.select('slug, updated_at, language_id, translation_group_id')
|
|
167
|
-
.eq('status', 'published')
|
|
168
|
+
.eq('status', 'published')
|
|
169
|
+
.or(buildPublishedAtOrFilter()),
|
|
168
170
|
fetchLanguageMap(supabase),
|
|
169
171
|
getHomepageTranslationGroupId(supabase),
|
|
170
172
|
]);
|
|
@@ -200,13 +202,12 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
|
|
|
200
202
|
export async function fetchAllPublishedPosts(): Promise<SitemapEntry[]> {
|
|
201
203
|
const supabase = getSsgSupabaseClient();
|
|
202
204
|
try {
|
|
203
|
-
const nowIso = new Date().toISOString();
|
|
204
205
|
const [{ data: posts, error }, languageMap] = await Promise.all([
|
|
205
206
|
supabase
|
|
206
207
|
.from('posts')
|
|
207
208
|
.select('slug, updated_at, language_id, translation_group_id')
|
|
208
209
|
.eq('status', 'published')
|
|
209
|
-
.or(
|
|
210
|
+
.or(buildPublishedAtOrFilter()),
|
|
210
211
|
fetchLanguageMap(supabase),
|
|
211
212
|
]);
|
|
212
213
|
|
|
@@ -238,7 +239,8 @@ export async function fetchAllActiveProducts(): Promise<SitemapEntry[]> {
|
|
|
238
239
|
supabase
|
|
239
240
|
.from('products')
|
|
240
241
|
.select('slug, updated_at, created_at, language_id, translation_group_id')
|
|
241
|
-
.eq('status', 'active')
|
|
242
|
+
.eq('status', 'active')
|
|
243
|
+
.or(buildPublishedAtOrFilter()),
|
|
242
244
|
fetchLanguageMap(supabase),
|
|
243
245
|
]);
|
|
244
246
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
2
|
|
|
3
3
|
import { getServiceRoleSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
4
|
+
import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
|
|
4
5
|
import {
|
|
5
6
|
getDefaultCurrency,
|
|
6
7
|
inferCurrencyCodeFromLocale,
|
|
@@ -1099,6 +1100,7 @@ export async function searchCatalogProducts(body: unknown, request: Request) {
|
|
|
1099
1100
|
.from('products')
|
|
1100
1101
|
.select(PRODUCT_SELECT, { count: 'exact' })
|
|
1101
1102
|
.eq('status', 'active')
|
|
1103
|
+
.or(buildPublishedAtOrFilter())
|
|
1102
1104
|
.order('created_at', { ascending: false })
|
|
1103
1105
|
.range(pagination.offset, pagination.offset + pagination.limit - 1);
|
|
1104
1106
|
|
|
@@ -1192,7 +1194,7 @@ async function selectRowsByField(
|
|
|
1192
1194
|
|
|
1193
1195
|
let query = client.from(table).select(select).in(field, values);
|
|
1194
1196
|
if (table === 'products') {
|
|
1195
|
-
query = query.eq('status', 'active');
|
|
1197
|
+
query = query.eq('status', 'active').or(buildPublishedAtOrFilter());
|
|
1196
1198
|
}
|
|
1197
1199
|
|
|
1198
1200
|
const { data } = await query;
|
|
@@ -1251,6 +1253,7 @@ async function resolveProductRowsByIdentifiers(ids: string[]): Promise<{
|
|
|
1251
1253
|
.from('products')
|
|
1252
1254
|
.select(PRODUCT_SELECT)
|
|
1253
1255
|
.eq('status', 'active')
|
|
1256
|
+
.or(buildPublishedAtOrFilter())
|
|
1254
1257
|
.in('id', productIds);
|
|
1255
1258
|
|
|
1256
1259
|
if (error) {
|
|
@@ -3,6 +3,7 @@ import { cookies, draftMode, headers } from 'next/headers';
|
|
|
3
3
|
import { notFound } from 'next/navigation';
|
|
4
4
|
import type { Metadata } from 'next';
|
|
5
5
|
import { createClient, getSsgSupabaseClient } from '@nextblock-cms/db/server';
|
|
6
|
+
import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
|
|
6
7
|
import PageClientContent from './[slug]/PageClientContent';
|
|
7
8
|
import { getPageDataBySlug } from './[slug]/page.utils';
|
|
8
9
|
import BlockRenderer from '../components/BlockRenderer';
|
|
@@ -56,7 +57,7 @@ async function resolveHomepageData(preferredLocale: string) {
|
|
|
56
57
|
.limit(1);
|
|
57
58
|
|
|
58
59
|
if (!draft.isEnabled) {
|
|
59
|
-
siblingQuery = siblingQuery.eq('status', 'published');
|
|
60
|
+
siblingQuery = siblingQuery.eq('status', 'published').or(buildPublishedAtOrFilter());
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
const { data: sibling } = await siblingQuery.maybeSingle();
|
|
@@ -131,7 +132,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
131
132
|
.from('pages')
|
|
132
133
|
.select('language_id, slug')
|
|
133
134
|
.eq('translation_group_id', pageData.translation_group_id)
|
|
134
|
-
.eq('status', 'published')
|
|
135
|
+
.eq('status', 'published')
|
|
136
|
+
.or(buildPublishedAtOrFilter()),
|
|
135
137
|
]);
|
|
136
138
|
|
|
137
139
|
const { data: languages } = languagesResult;
|
|
@@ -188,7 +190,8 @@ export default async function RootPage() {
|
|
|
188
190
|
.from('pages')
|
|
189
191
|
.select('slug, languages!inner(code)')
|
|
190
192
|
.eq('translation_group_id', pageData.translation_group_id)
|
|
191
|
-
.eq('status', 'published')
|
|
193
|
+
.eq('status', 'published')
|
|
194
|
+
.or(buildPublishedAtOrFilter());
|
|
192
195
|
|
|
193
196
|
if (translations) {
|
|
194
197
|
translations.forEach((translation: PageTranslation) => {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
resolveTranslatedText,
|
|
9
9
|
} from '@nextblock-cms/ecommerce';
|
|
10
10
|
import { getSsgSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
11
|
+
import { LIVE_STATUS, buildPublishedAtOrFilter, isPubliclyVisible } from '@nextblock-cms/utils';
|
|
11
12
|
import { notFound } from 'next/navigation';
|
|
12
13
|
import { Metadata } from 'next';
|
|
13
14
|
import { draftMode, cookies, headers } from 'next/headers';
|
|
@@ -124,7 +125,16 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|
|
124
125
|
const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
|
|
125
126
|
const productRecord = product as any;
|
|
126
127
|
|
|
127
|
-
if (
|
|
128
|
+
if (
|
|
129
|
+
!productRecord ||
|
|
130
|
+
!isPubliclyVisible({
|
|
131
|
+
status: productRecord.status,
|
|
132
|
+
publishedAt: productRecord.published_at,
|
|
133
|
+
liveStatus: LIVE_STATUS.product,
|
|
134
|
+
})
|
|
135
|
+
) {
|
|
136
|
+
return { title: 'Product Not Found' };
|
|
137
|
+
}
|
|
128
138
|
|
|
129
139
|
// Resolve image URL for OG Image
|
|
130
140
|
let imageUrl = undefined;
|
|
@@ -147,6 +157,8 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|
|
147
157
|
.select('language_id, slug')
|
|
148
158
|
.eq('translation_group_id', productRecord.translation_group_id)
|
|
149
159
|
.eq('status', 'active')
|
|
160
|
+
// Never advertise a scheduled translation via hreflang.
|
|
161
|
+
.or(buildPublishedAtOrFilter())
|
|
150
162
|
]);
|
|
151
163
|
|
|
152
164
|
const { data: languages } = languagesResult;
|
|
@@ -225,11 +237,23 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|
|
225
237
|
const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
|
|
226
238
|
let productRecord = product as any;
|
|
227
239
|
|
|
228
|
-
|
|
240
|
+
const draft = await draftMode();
|
|
241
|
+
|
|
242
|
+
// Draft mode is how the CMS previews a product before it is public, so it must
|
|
243
|
+
// reach draft and scheduled rows — everyone else only sees active products whose
|
|
244
|
+
// go-live moment has passed.
|
|
245
|
+
if (
|
|
246
|
+
!productRecord ||
|
|
247
|
+
(!draft.isEnabled &&
|
|
248
|
+
!isPubliclyVisible({
|
|
249
|
+
status: productRecord.status,
|
|
250
|
+
publishedAt: productRecord.published_at,
|
|
251
|
+
liveStatus: LIVE_STATUS.product,
|
|
252
|
+
}))
|
|
253
|
+
) {
|
|
229
254
|
notFound();
|
|
230
255
|
}
|
|
231
256
|
|
|
232
|
-
const draft = await draftMode();
|
|
233
257
|
const visualEditingEnabled =
|
|
234
258
|
draft.isEnabled || process.env.NEXTBLOCK_VISUAL_EDITING_ENABLED === 'true';
|
|
235
259
|
|
|
@@ -1018,7 +1018,10 @@ function VisualEditingToolbar() {
|
|
|
1018
1018
|
return;
|
|
1019
1019
|
}
|
|
1020
1020
|
|
|
1021
|
-
|
|
1021
|
+
// A warning means the content is live but the revision didn't record — still a
|
|
1022
|
+
// publish, so the editor closes, but don't report it as a clean one.
|
|
1023
|
+
const warning = result && "success" in result ? result.warning : undefined;
|
|
1024
|
+
setMessage(warning ?? "Draft published.");
|
|
1022
1025
|
hasSavedSinceOpenRef.current = false;
|
|
1023
1026
|
closeVisualEditor();
|
|
1024
1027
|
router.refresh();
|
|
@@ -100,6 +100,7 @@ Defined primarily in `00000000000002_setup_content_tables.sql`:
|
|
|
100
100
|
- `navigation_items`
|
|
101
101
|
- `page_revisions`
|
|
102
102
|
- `post_revisions`
|
|
103
|
+
- `product_revisions` (added in `00000000000016`, alongside `products.version`)
|
|
103
104
|
|
|
104
105
|
### Commerce tables
|
|
105
106
|
|
|
@@ -179,8 +180,12 @@ tree. The current sequence is:
|
|
|
179
180
|
|
|
180
181
|
Every file is fully idempotent. Existing databases already have versions
|
|
181
182
|
`000`–`003` recorded, so both appliers skip the baseline — it only runs on a
|
|
182
|
-
fresh/empty database.
|
|
183
|
-
|
|
183
|
+
fresh/empty database.
|
|
184
|
+
|
|
185
|
+
`00000000000004` was the first migration appended after that re-baseline, not the
|
|
186
|
+
one still to be written — the folder has grown well past it. **To find the next
|
|
187
|
+
number, list `libs/db/src/supabase/migrations` and take the one after the highest
|
|
188
|
+
file on disk.** Never copy a hardcoded "next is N" out of a doc.
|
|
184
189
|
|
|
185
190
|
### Production migration policy
|
|
186
191
|
|
|
@@ -193,7 +198,13 @@ production or shared database change.
|
|
|
193
198
|
`libs/db/src/supabase/migrations` for each new schema/data change.
|
|
194
199
|
- Keep migrations non-destructive by default. Avoid dropping or rewriting data
|
|
195
200
|
that may include orders, users, payments, or customer records.
|
|
196
|
-
- Run `npm run db:migrate:check` before `npm run db:migrate`.
|
|
201
|
+
- Run `npm run db:migrate:check` before `npm run db:migrate`. **Read its pending
|
|
202
|
+
list** — do not just look for a success line. If you added a migration and the
|
|
203
|
+
check reports `Pending: 0`, that file will never run (see below).
|
|
204
|
+
- **Supabase matches migration history by version only, never by content.** A file
|
|
205
|
+
whose 14-digit version is already recorded remotely is skipped in silence — no
|
|
206
|
+
error, no output. That is why the check prints the pending list and warns when a
|
|
207
|
+
version is recorded remotely with no local file behind it.
|
|
197
208
|
- If an existing database lists old baseline files such as
|
|
198
209
|
`00000000000000_baseline_schema.sql` as pending, do not replay them. Use
|
|
199
210
|
`npm run db:migrate:repair-history:check`, then
|
|
@@ -202,6 +213,30 @@ production or shared database change.
|
|
|
202
213
|
rerun `npm run db:migrate:check`.
|
|
203
214
|
- Use `npm run db:migrate:fresh` only for a brand-new empty database.
|
|
204
215
|
|
|
216
|
+
#### Why `db:migrate:check` is read-only by construction
|
|
217
|
+
|
|
218
|
+
On 2026-08-10 the check applied migration `00000000000017` to the production
|
|
219
|
+
project while printing `DRY RUN: migrations will *not* be pushed` and `Dry run
|
|
220
|
+
complete. No database changes were applied.` The `--check` path then ran
|
|
221
|
+
`supabase link --yes` followed by `supabase db push --dry-run` (Supabase CLI
|
|
222
|
+
v2.107); which of the two executed the SQL was never established, and the decisive
|
|
223
|
+
probe would have written a row to the production migration history.
|
|
224
|
+
|
|
225
|
+
`tools/scripts/push-db-migrations.js` no longer runs either on the check path. It
|
|
226
|
+
now runs only `supabase migration list` — a pure read — and derives the pending set
|
|
227
|
+
by diffing local files against remote history. Consequences worth keeping:
|
|
228
|
+
|
|
229
|
+
- The check links nothing. An unlinked repo is told to run `supabase link` itself
|
|
230
|
+
rather than having project state written underneath a command called "check".
|
|
231
|
+
- The check needs no `SUPABASE_ACCESS_TOKEN`, because only linking did.
|
|
232
|
+
- The apply path derives its baseline-replay guard from the same read instead of
|
|
233
|
+
regex-scraping `db push --dry-run` output, and returns early when nothing is
|
|
234
|
+
pending, so `db push` is never invoked without work to do.
|
|
235
|
+
- `parseMigrationList` is unit-tested in `tools/scripts/push-db-migrations.test.ts`.
|
|
236
|
+
|
|
237
|
+
If a future CLI upgrade tempts you back toward `db push --dry-run` for previewing:
|
|
238
|
+
don't. A command named `check` must not be able to write.
|
|
239
|
+
|
|
205
240
|
### Category map
|
|
206
241
|
|
|
207
242
|
| Migration file | Domain | What it covers |
|