@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.0
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/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
package/src/server/pages-core.ts
CHANGED
|
@@ -11,6 +11,13 @@ import type { BlockInstance } from "../blocks/types";
|
|
|
11
11
|
import { diffBlocks, summarizeChanges } from "../cms/version-diff";
|
|
12
12
|
import type { VersionChange } from "../cms/version-diff";
|
|
13
13
|
import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_PAGE_STATUS,
|
|
16
|
+
normalizePageStatus,
|
|
17
|
+
uniquePageKey,
|
|
18
|
+
type PageStatus,
|
|
19
|
+
} from "../cms/page-status";
|
|
20
|
+
import { duplicateBlockTree, flattenBlockTree } from "../blocks/block-tree";
|
|
14
21
|
import { assertEditable } from "./edit-locks-core";
|
|
15
22
|
import { actorEmail, assertStaff, assertSuperadmin } from "./guards";
|
|
16
23
|
import type { PlatformAuthContext } from "./guards";
|
|
@@ -19,6 +26,16 @@ import {
|
|
|
19
26
|
withPublishedCache,
|
|
20
27
|
} from "./published-cache";
|
|
21
28
|
import { resolveCountdownTarget } from "../blocks/countdown-time";
|
|
29
|
+
import {
|
|
30
|
+
nextPublicationBoundary,
|
|
31
|
+
nextPublicationTransition,
|
|
32
|
+
publicationStateAt,
|
|
33
|
+
publicVersionId,
|
|
34
|
+
toPublicationWindows,
|
|
35
|
+
type PublicationState,
|
|
36
|
+
type PublicationTransitionKind,
|
|
37
|
+
type PublicationWindow,
|
|
38
|
+
} from "../cms/page-publication";
|
|
22
39
|
import { resolveGlobalsDeep } from "./resolve-globals";
|
|
23
40
|
|
|
24
41
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
@@ -39,6 +56,12 @@ export type ResolvedPage = {
|
|
|
39
56
|
blocks: BlockInstance[];
|
|
40
57
|
versionId: string | null;
|
|
41
58
|
globals: Record<string, BlockInstance[]>;
|
|
59
|
+
/**
|
|
60
|
+
* The next instant this page's public answer can change — the start of a
|
|
61
|
+
* scheduled version, or the end of the window showing this one. Null when
|
|
62
|
+
* nothing is scheduled. Caches downstream use it as their ceiling.
|
|
63
|
+
*/
|
|
64
|
+
revalidateAt: string | null;
|
|
42
65
|
};
|
|
43
66
|
|
|
44
67
|
const blocksOf = (value: unknown): BlockInstance[] =>
|
|
@@ -53,43 +76,218 @@ export async function getPublishedPageCore(
|
|
|
53
76
|
client: any,
|
|
54
77
|
key: string,
|
|
55
78
|
): Promise<ResolvedPage | null> {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
79
|
+
/*
|
|
80
|
+
* Cached per key, dropped by every page/global write below, and — through the
|
|
81
|
+
* boundary the read hands back — never held across the moment a publication
|
|
82
|
+
* window opens or closes. A cache that outlived a `publish_at` would answer
|
|
83
|
+
* "not found" for up to twenty seconds after a page went live, which is the
|
|
84
|
+
* same bug as publishing late.
|
|
85
|
+
*/
|
|
86
|
+
const read = await withPublishedCache(
|
|
87
|
+
"page",
|
|
88
|
+
key,
|
|
89
|
+
() => readPublishedPage(client, key),
|
|
90
|
+
(result) => result.revalidateAt,
|
|
91
|
+
);
|
|
92
|
+
return read.page;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A public answer plus the instant it stops being true. */
|
|
96
|
+
type PublicPageRead = { page: ResolvedPage | null; revalidateAt: number | null };
|
|
97
|
+
|
|
98
|
+
/** The `page_publications` rows that can affect this page's public answer. */
|
|
99
|
+
async function activePublications(
|
|
100
|
+
client: any,
|
|
101
|
+
pageId: string,
|
|
102
|
+
): Promise<PublicationWindow[]> {
|
|
103
|
+
const { data, error } = await client
|
|
104
|
+
.from("page_publications")
|
|
105
|
+
.select("version_id, publish_at, unpublish_at, published_at")
|
|
106
|
+
.eq("page_id", pageId)
|
|
107
|
+
.eq("is_active", true)
|
|
108
|
+
.order("publish_at", { ascending: false });
|
|
109
|
+
// A database we could not ask is not the same answer as "this page is not
|
|
110
|
+
// public": one is a 500 the host retries, the other a 404 a CDN may keep.
|
|
111
|
+
if (error) throw new Error(error.message);
|
|
112
|
+
return toPublicationWindows(data);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The public shape of a page's state, shared by the list and the builder. */
|
|
116
|
+
export type PublicationSummaryFields = {
|
|
117
|
+
/** What a visitor gets right now. */
|
|
118
|
+
publication: PublicationState;
|
|
119
|
+
/** When that changes next, ISO-8601, or null when nothing is due. */
|
|
120
|
+
publication_at: string | null;
|
|
121
|
+
/** What happens then — a publish, a replacement, or a hide. */
|
|
122
|
+
publication_transition: PublicationTransitionKind | null;
|
|
123
|
+
/** The version showing afterwards, for a publish or a replacement. */
|
|
124
|
+
publication_next_version_id: string | null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The same four fields, from rows that have already been read. */
|
|
128
|
+
function summarize(
|
|
129
|
+
windows: readonly PublicationWindow[],
|
|
130
|
+
now: number,
|
|
131
|
+
): PublicationSummaryFields {
|
|
132
|
+
const next = nextPublicationTransition(windows, now);
|
|
133
|
+
return {
|
|
134
|
+
publication: publicationStateAt(windows, now),
|
|
135
|
+
publication_at: next === null ? null : new Date(next.at).toISOString(),
|
|
136
|
+
publication_transition: next?.kind ?? null,
|
|
137
|
+
publication_next_version_id: next?.versionId ?? null,
|
|
138
|
+
};
|
|
59
139
|
}
|
|
60
140
|
|
|
61
|
-
|
|
62
|
-
|
|
141
|
+
/**
|
|
142
|
+
* What a visitor gets for one page, and what changes next.
|
|
143
|
+
*
|
|
144
|
+
* The list screen and the builder header both need this, and neither may
|
|
145
|
+
* compute it from `pages.status`: that column records the last editorial
|
|
146
|
+
* decision, not whether the page is reachable.
|
|
147
|
+
*/
|
|
148
|
+
async function publicationOf(
|
|
149
|
+
client: any,
|
|
150
|
+
pageId: string,
|
|
151
|
+
now: number = Date.now(),
|
|
152
|
+
): Promise<PublicationSummaryFields> {
|
|
153
|
+
return summarize(await activePublications(client, pageId), now);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
* Publication rows alone decide what a visitor sees. `pages.status` is not
|
|
158
|
+
* read here: it says whether an editor considers the page finished, and a
|
|
159
|
+
* finished page is not thereby public — a version scheduled for next week, a
|
|
160
|
+
* countdown target waiting for its deadline and a page whose window has closed
|
|
161
|
+
* are all finished and all hidden. The rules themselves live in
|
|
162
|
+
* `cms/page-publication` so that this route and the sitemap below cannot come
|
|
163
|
+
* to different conclusions about the same page.
|
|
164
|
+
*/
|
|
165
|
+
async function readPublishedPage(client: any, key: string): Promise<PublicPageRead> {
|
|
166
|
+
const { data: page, error: pageError } = await client
|
|
63
167
|
.from("pages")
|
|
64
|
-
.select("id,key,name
|
|
168
|
+
.select("id,key,name")
|
|
65
169
|
.eq("key", key)
|
|
66
170
|
.is("deleted_at", null)
|
|
67
171
|
.maybeSingle();
|
|
68
|
-
|
|
172
|
+
/*
|
|
173
|
+
* Every read on this path is checked, because the only other answer a public
|
|
174
|
+
* route has is "no such page" — and that answer is a 404 the host and its CDN
|
|
175
|
+
* are entitled to keep. Ignoring the error would turn one bad minute in the
|
|
176
|
+
* database into an hour of pages that do not exist.
|
|
177
|
+
*/
|
|
178
|
+
if (pageError) throw new Error(pageError.message);
|
|
179
|
+
if (!page) return { page: null, revalidateAt: null };
|
|
69
180
|
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
.eq("page_id", page.id)
|
|
76
|
-
.eq("is_active", true)
|
|
77
|
-
.lte("publish_at", nowIso)
|
|
78
|
-
.or(`unpublish_at.is.null,unpublish_at.gt.${nowIso}`)
|
|
79
|
-
.order("publish_at", { ascending: false })
|
|
80
|
-
.limit(1)
|
|
81
|
-
.maybeSingle();
|
|
181
|
+
const windows = await activePublications(client, page.id);
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const revalidateAt = nextPublicationBoundary(windows, now);
|
|
184
|
+
const versionId = publicVersionId(windows, now);
|
|
185
|
+
if (!versionId) return { page: null, revalidateAt };
|
|
82
186
|
|
|
83
|
-
const
|
|
84
|
-
if (!versionId) return null;
|
|
85
|
-
const { data: version } = await client
|
|
187
|
+
const { data: version, error: versionError } = await client
|
|
86
188
|
.from("page_versions")
|
|
87
189
|
.select("id, blocks")
|
|
88
190
|
.eq("id", versionId)
|
|
89
191
|
.maybeSingle();
|
|
90
|
-
|
|
192
|
+
if (versionError) throw new Error(versionError.message);
|
|
193
|
+
/*
|
|
194
|
+
* An open publication naming a version that is not there is a broken
|
|
195
|
+
* database, not a page with nothing on it. Serving 200 and no blocks would
|
|
196
|
+
* quietly replace the page a visitor asked for with a blank one — and cache
|
|
197
|
+
* it — so say what is actually wrong and let the host answer 500.
|
|
198
|
+
*/
|
|
199
|
+
if (!version) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`The page "${key}" is published as version ${versionId}, but that version ` +
|
|
202
|
+
`is missing from the database.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const blocks = blocksOf(version.blocks);
|
|
91
206
|
const globals = await resolveGlobalsDeep(client, blocks);
|
|
92
|
-
return {
|
|
207
|
+
return {
|
|
208
|
+
page: {
|
|
209
|
+
key: page.key,
|
|
210
|
+
name: page.name,
|
|
211
|
+
blocks,
|
|
212
|
+
versionId: version.id ?? null,
|
|
213
|
+
globals,
|
|
214
|
+
revalidateAt: revalidateAt === null ? null : new Date(revalidateAt).toISOString(),
|
|
215
|
+
},
|
|
216
|
+
revalidateAt,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Public page keys, and the instant that list stops being true. */
|
|
221
|
+
export type PublishedRoutes = {
|
|
222
|
+
/** Page keys a visitor may reach right now, in key order. */
|
|
223
|
+
keys: string[];
|
|
224
|
+
/**
|
|
225
|
+
* The earliest publication boundary across every page — the next moment a
|
|
226
|
+
* key joins or leaves the list — or null when nothing is scheduled. The
|
|
227
|
+
* sitemap uses it as the ceiling on how long its answer may be cached.
|
|
228
|
+
*/
|
|
229
|
+
revalidateAt: number | null;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Page keys a visitor may reach right now.
|
|
234
|
+
*
|
|
235
|
+
* Sitemap generation and any other public route discovery call this instead of
|
|
236
|
+
* listing `pages` directly, and it applies the very same window rules as
|
|
237
|
+
* {@link getPublishedPageCore} — the same function on the same columns — so a
|
|
238
|
+
* key can never be advertised in `sitemap.xml` by a route that would 404, or
|
|
239
|
+
* withheld from it by a route that would serve it.
|
|
240
|
+
*/
|
|
241
|
+
export async function listPublishedRoutesCore(client: any): Promise<PublishedRoutes> {
|
|
242
|
+
const { data: pubs, error } = await client
|
|
243
|
+
.from("page_publications")
|
|
244
|
+
.select("page_id, version_id, publish_at, unpublish_at, published_at")
|
|
245
|
+
.eq("is_active", true)
|
|
246
|
+
.order("publish_at", { ascending: false });
|
|
247
|
+
if (error) throw new Error(error.message);
|
|
248
|
+
|
|
249
|
+
const now = Date.now();
|
|
250
|
+
const byPage = new Map<string, PublicationWindow[]>();
|
|
251
|
+
for (const row of (pubs ?? []) as Array<Record<string, string | null>>) {
|
|
252
|
+
const pageId = row.page_id;
|
|
253
|
+
if (!pageId) continue;
|
|
254
|
+
byPage.set(pageId, [...(byPage.get(pageId) ?? []), ...toPublicationWindows([row])]);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const live = new Set<string>();
|
|
258
|
+
let revalidateAt: number | null = null;
|
|
259
|
+
for (const [pageId, windows] of byPage) {
|
|
260
|
+
if (publicVersionId(windows, now) !== null) live.add(pageId);
|
|
261
|
+
/*
|
|
262
|
+
* Boundaries are collected for every page, live or not: a page that
|
|
263
|
+
* appears in ten minutes changes this list exactly as much as one that
|
|
264
|
+
* disappears in ten minutes, and the sitemap has to stop being cached
|
|
265
|
+
* before either happens.
|
|
266
|
+
*/
|
|
267
|
+
const boundary = nextPublicationBoundary(windows, now);
|
|
268
|
+
if (boundary !== null && (revalidateAt === null || boundary < revalidateAt)) {
|
|
269
|
+
revalidateAt = boundary;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (live.size === 0) return { keys: [], revalidateAt };
|
|
273
|
+
|
|
274
|
+
// Deleted pages keep their publication rows until the trash is emptied, so
|
|
275
|
+
// the page table still has the last word on whether a key exists at all.
|
|
276
|
+
const { data: pages, error: pageError } = await client
|
|
277
|
+
.from("pages")
|
|
278
|
+
.select("id,key")
|
|
279
|
+
.is("deleted_at", null)
|
|
280
|
+
.order("key");
|
|
281
|
+
if (pageError) throw new Error(pageError.message);
|
|
282
|
+
const keys = ((pages ?? []) as Array<{ id: string; key: string }>)
|
|
283
|
+
.filter((row) => live.has(row.id))
|
|
284
|
+
.map((row) => row.key);
|
|
285
|
+
return { keys, revalidateAt };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** {@link listPublishedRoutesCore} for callers that only want the keys. */
|
|
289
|
+
export async function listPublishedPageKeysCore(client: any): Promise<string[]> {
|
|
290
|
+
return (await listPublishedRoutesCore(client)).keys;
|
|
93
291
|
}
|
|
94
292
|
|
|
95
293
|
export type PageListRow = {
|
|
@@ -97,10 +295,14 @@ export type PageListRow = {
|
|
|
97
295
|
key: string;
|
|
98
296
|
name: string;
|
|
99
297
|
is_system: boolean;
|
|
298
|
+
/**
|
|
299
|
+
* Editorial state — whether an editor considers the page finished. It is not
|
|
300
|
+
* public visibility; `publication` is.
|
|
301
|
+
*/
|
|
100
302
|
status: string;
|
|
101
303
|
updated_at: string;
|
|
102
304
|
current_version_id: string | null;
|
|
103
|
-
};
|
|
305
|
+
} & PublicationSummaryFields;
|
|
104
306
|
|
|
105
307
|
export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow[]> {
|
|
106
308
|
await assertStaff(context);
|
|
@@ -111,7 +313,34 @@ export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow
|
|
|
111
313
|
.order("is_system", { ascending: false })
|
|
112
314
|
.order("name");
|
|
113
315
|
if (error) throw new Error(error.message);
|
|
114
|
-
|
|
316
|
+
const rows = (data ?? []) as Array<Omit<PageListRow, keyof PublicationSummaryFields>>;
|
|
317
|
+
if (rows.length === 0) return [];
|
|
318
|
+
|
|
319
|
+
/*
|
|
320
|
+
* `pages.status` cannot answer "can a visitor reach this page?", and that is
|
|
321
|
+
* the question a list of pages is really being asked. A draft whose
|
|
322
|
+
* scheduled window has opened is on the internet; a published page past its
|
|
323
|
+
* `unpublish_at` is not. One extra query for the listed pages' active
|
|
324
|
+
* windows, run through the same rule the public reader uses, so the
|
|
325
|
+
* backoffice and the website cannot disagree about what is live.
|
|
326
|
+
*/
|
|
327
|
+
const { data: pubs, error: pubError } = await context.supabase
|
|
328
|
+
.from("page_publications")
|
|
329
|
+
.select("page_id, version_id, publish_at, unpublish_at, published_at")
|
|
330
|
+
.eq("is_active", true)
|
|
331
|
+
.in("page_id", rows.map((row) => row.id))
|
|
332
|
+
.order("publish_at", { ascending: false });
|
|
333
|
+
if (pubError) throw new Error(pubError.message);
|
|
334
|
+
|
|
335
|
+
const byPage = new Map<string, PublicationWindow[]>();
|
|
336
|
+
for (const row of (pubs ?? []) as Array<Record<string, string | null>>) {
|
|
337
|
+
const pageId = row.page_id;
|
|
338
|
+
if (!pageId) continue;
|
|
339
|
+
byPage.set(pageId, [...(byPage.get(pageId) ?? []), ...toPublicationWindows([row])]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
return rows.map((row) => ({ ...row, ...summarize(byPage.get(row.id) ?? [], now) }));
|
|
115
344
|
}
|
|
116
345
|
|
|
117
346
|
export async function getPageForEditCore({ context }: PagesDeps, key: string) {
|
|
@@ -126,14 +355,75 @@ export async function getPageForEditCore({ context }: PagesDeps, key: string) {
|
|
|
126
355
|
if (!page) throw new Error("Page not found");
|
|
127
356
|
let blocks: BlockInstance[] = [];
|
|
128
357
|
if (page.current_version_id) {
|
|
129
|
-
const { data: v } = await context.supabase
|
|
358
|
+
const { data: v, error: versionError } = await context.supabase
|
|
130
359
|
.from("page_versions")
|
|
131
360
|
.select("blocks")
|
|
132
361
|
.eq("id", page.current_version_id)
|
|
133
362
|
.maybeSingle();
|
|
363
|
+
// Opening the builder on an empty canvas because a read failed invites the
|
|
364
|
+
// editor to save that emptiness over the real content.
|
|
365
|
+
if (versionError) throw new Error(versionError.message);
|
|
134
366
|
blocks = blocksOf(v?.blocks);
|
|
135
367
|
}
|
|
136
|
-
|
|
368
|
+
/*
|
|
369
|
+
* The header says whether visitors can reach this page, which is a different
|
|
370
|
+
* question from the status column beside it and is answered by the same rule
|
|
371
|
+
* the public routes use.
|
|
372
|
+
*/
|
|
373
|
+
const publication = await publicationOf(context.supabase, page.id);
|
|
374
|
+
return { page: { ...page, ...publication }, blocks };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Codes and wording that mean "this installation has not run the publication
|
|
379
|
+
* migration yet". `PGRST202`/`PGRST203` come from PostgREST, `42883`/`42P01`
|
|
380
|
+
* from PostgreSQL itself.
|
|
381
|
+
*/
|
|
382
|
+
const FUNCTION_MISSING = new Set(["PGRST202", "PGRST203", "42883", "42P01"]);
|
|
383
|
+
|
|
384
|
+
/** The migrations that install the functions below, named in the error. */
|
|
385
|
+
const PUBLICATION_MIGRATIONS = [
|
|
386
|
+
"20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql",
|
|
387
|
+
"20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql",
|
|
388
|
+
] as const;
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* One database-function call, for the writes that change what the public sees.
|
|
392
|
+
*
|
|
393
|
+
* Publishing is four statements — retire the old publication, write the new
|
|
394
|
+
* one, move the current-version pointer, flip the status — and a PostgREST
|
|
395
|
+
* client cannot wrap them in a transaction. Checking each response, which this
|
|
396
|
+
* code used to do, reports the failure honestly but leaves the site half
|
|
397
|
+
* published either way: an open publication with the status never flipped, or a
|
|
398
|
+
* retired publication behind a status that still says published. Only the
|
|
399
|
+
* database can make the whole change or none of it, so the whole change is
|
|
400
|
+
* written there and called through here.
|
|
401
|
+
*
|
|
402
|
+
* There is deliberately no JavaScript fallback. Compensating for a failed step
|
|
403
|
+
* with further un-transacted steps is precisely the thing that does not work,
|
|
404
|
+
* so an installation that has not run the migration is told which one to run
|
|
405
|
+
* rather than quietly served by the code path this replaces.
|
|
406
|
+
*/
|
|
407
|
+
async function publicationRpc(
|
|
408
|
+
client: any,
|
|
409
|
+
fn: string,
|
|
410
|
+
args: Record<string, unknown>,
|
|
411
|
+
whenFailed: string,
|
|
412
|
+
): Promise<any> {
|
|
413
|
+
const { data, error } = await client.rpc(fn, args);
|
|
414
|
+
if (!error) return data;
|
|
415
|
+
const code = String(error.code ?? "");
|
|
416
|
+
const message = String(error.message ?? "");
|
|
417
|
+
if (
|
|
418
|
+
FUNCTION_MISSING.has(code) ||
|
|
419
|
+
/could not find the function|does not exist/i.test(message)
|
|
420
|
+
) {
|
|
421
|
+
throw new Error(
|
|
422
|
+
`${whenFailed}: this database does not have ${fn}. Apply the migrations ` +
|
|
423
|
+
`${PUBLICATION_MIGRATIONS.join(" and ")} shipped with this package and try again.`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
throw new Error(message || whenFailed);
|
|
137
427
|
}
|
|
138
428
|
|
|
139
429
|
export async function savePageVersionCore(
|
|
@@ -153,114 +443,94 @@ export async function savePageVersionCore(
|
|
|
153
443
|
resourceId: input.pageId,
|
|
154
444
|
sessionId: input.editSessionId,
|
|
155
445
|
});
|
|
446
|
+
// Before, so a read racing this write cannot serve replaced content, and
|
|
447
|
+
// again after, so one that raced it does not leave the old answer behind.
|
|
156
448
|
invalidatePublishedCache();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
version_no: nextNo,
|
|
170
|
-
blocks: input.blocks as unknown as any,
|
|
171
|
-
note: input.note ?? null,
|
|
172
|
-
created_by: context.userId,
|
|
173
|
-
created_by_email: actorEmail(context),
|
|
174
|
-
})
|
|
175
|
-
.select("id")
|
|
176
|
-
.single();
|
|
177
|
-
if (error) throw new Error(error.message);
|
|
449
|
+
/*
|
|
450
|
+
* Worked out before anything is written, and handed to the database with the
|
|
451
|
+
* publish itself.
|
|
452
|
+
*
|
|
453
|
+
* Scheduling the reveals afterwards, as their own calls, meant a target that
|
|
454
|
+
* could not be scheduled was reported as a failed publish although the
|
|
455
|
+
* source page was already live and irrevocably so — and left whichever
|
|
456
|
+
* reveals came before it scheduled. Passed in here, the page and every
|
|
457
|
+
* reveal it promises are one transaction: all of them happen or none does,
|
|
458
|
+
* and a failure is a failure the editor can act on.
|
|
459
|
+
*/
|
|
460
|
+
const reveals = input.publish ? countdownReveals(input.blocks) : [];
|
|
178
461
|
|
|
179
|
-
await
|
|
180
|
-
.
|
|
181
|
-
|
|
182
|
-
|
|
462
|
+
const saved = await publicationRpc(
|
|
463
|
+
context.supabase,
|
|
464
|
+
"cms_save_page_version",
|
|
465
|
+
{
|
|
466
|
+
_page_id: input.pageId,
|
|
467
|
+
_blocks: input.blocks as unknown,
|
|
468
|
+
_note: input.note ?? null,
|
|
469
|
+
_publish: Boolean(input.publish),
|
|
470
|
+
_actor_email: actorEmail(context),
|
|
471
|
+
_reveals: reveals,
|
|
472
|
+
// Named so a countdown pointing at the page being published is refused
|
|
473
|
+
// rather than hiding it until its own deadline.
|
|
474
|
+
_source_page_id: input.pageId,
|
|
475
|
+
_audit: input.publish
|
|
476
|
+
? {
|
|
477
|
+
description:
|
|
478
|
+
"Published a new version from the page builder — this page is now " +
|
|
479
|
+
"visible on the public website.",
|
|
480
|
+
}
|
|
481
|
+
: {},
|
|
482
|
+
},
|
|
483
|
+
input.publish ? "Could not publish the page" : "Could not save the version",
|
|
484
|
+
);
|
|
485
|
+
invalidatePublishedCache();
|
|
183
486
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
.eq("is_active", true);
|
|
190
|
-
await context.supabase.from("page_publications").insert({
|
|
191
|
-
page_id: input.pageId,
|
|
192
|
-
version_id: inserted.id,
|
|
193
|
-
is_active: true,
|
|
194
|
-
published_by: context.userId,
|
|
195
|
-
});
|
|
196
|
-
await context.supabase.from("pages").update({ status: "published" }).eq("id", input.pageId);
|
|
197
|
-
await scheduleCountdownReveals(context, input.blocks);
|
|
198
|
-
}
|
|
199
|
-
return { versionId: inserted.id as string, versionNo: nextNo };
|
|
487
|
+
const versionId = typeof saved?.versionId === "string" ? saved.versionId : "";
|
|
488
|
+
const versionNo = Number(saved?.versionNo ?? 0);
|
|
489
|
+
if (!versionId) throw new Error("The database saved no version for this page.");
|
|
490
|
+
|
|
491
|
+
return { versionId, versionNo };
|
|
200
492
|
}
|
|
201
493
|
|
|
494
|
+
/** One page a countdown on the saved page reveals when its timer reaches zero. */
|
|
495
|
+
export type RevealInstruction = {
|
|
496
|
+
key: string;
|
|
497
|
+
/** ISO instant the target page becomes public. */
|
|
498
|
+
publishAt: string;
|
|
499
|
+
/** What the audit log should say about it. */
|
|
500
|
+
description: string;
|
|
501
|
+
};
|
|
502
|
+
|
|
202
503
|
/**
|
|
504
|
+
* The reveals a page's blocks ask for, read off the blocks and nothing else.
|
|
505
|
+
*
|
|
203
506
|
* A countdown set to "reveal a hidden page" schedules that page to publish at
|
|
204
507
|
* the deadline, reusing the normal time-based publication window. Nothing is
|
|
205
|
-
* ever unpublished or deleted
|
|
508
|
+
* ever unpublished or deleted by one, and the target's status is left alone: it
|
|
509
|
+
* becomes public when its window opens, not when somebody schedules it, which
|
|
510
|
+
* is the whole point of a reveal.
|
|
206
511
|
*/
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
blocks
|
|
210
|
-
) {
|
|
211
|
-
const reveals = blocks
|
|
512
|
+
export function countdownReveals(blocks: BlockInstance[]): RevealInstruction[] {
|
|
513
|
+
// Flattened: a countdown inside a layout column reveals its page too.
|
|
514
|
+
const countdowns = flattenBlockTree(blocks)
|
|
212
515
|
.filter((b) => b.type === "countdown")
|
|
213
516
|
.map((b) => b.props as { onExpire?: string; revealPageKey?: string; targetLocal?: string; timezone?: string } | undefined)
|
|
214
517
|
.filter((p): p is { onExpire: string; revealPageKey: string; targetLocal: string; timezone?: string } =>
|
|
215
518
|
Boolean(p && p.onExpire === "revealPage" && p.revealPageKey?.trim() && p.targetLocal?.trim()),
|
|
216
519
|
);
|
|
217
520
|
|
|
218
|
-
|
|
219
|
-
|
|
521
|
+
const reveals: RevealInstruction[] = [];
|
|
522
|
+
for (const countdown of countdowns) {
|
|
523
|
+
const at = resolveCountdownTarget(countdown.targetLocal, countdown.timezone);
|
|
220
524
|
if (at == null) continue;
|
|
525
|
+
const key = countdown.revealPageKey.trim();
|
|
221
526
|
const publishAt = new Date(at).toISOString();
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
.is("deleted_at", null)
|
|
227
|
-
.maybeSingle();
|
|
228
|
-
if (!target?.current_version_id) continue;
|
|
229
|
-
|
|
230
|
-
const { data: existing } = await context.supabase
|
|
231
|
-
.from("page_publications")
|
|
232
|
-
.select("id")
|
|
233
|
-
.eq("page_id", target.id)
|
|
234
|
-
.eq("version_id", target.current_version_id)
|
|
235
|
-
.eq("publish_at", publishAt)
|
|
236
|
-
.eq("is_active", true)
|
|
237
|
-
.maybeSingle();
|
|
238
|
-
if (existing) continue;
|
|
239
|
-
|
|
240
|
-
await context.supabase
|
|
241
|
-
.from("page_publications")
|
|
242
|
-
.update({ is_active: false })
|
|
243
|
-
.eq("page_id", target.id)
|
|
244
|
-
.eq("is_active", true);
|
|
245
|
-
await context.supabase.from("page_publications").insert({
|
|
246
|
-
page_id: target.id,
|
|
247
|
-
version_id: target.current_version_id,
|
|
248
|
-
is_active: true,
|
|
249
|
-
publish_at: publishAt,
|
|
250
|
-
published_by: context.userId,
|
|
251
|
-
});
|
|
252
|
-
await context.supabase.from("pages").update({ status: "published" }).eq("id", target.id);
|
|
253
|
-
await context.supabase.from("audit_logs").insert({
|
|
254
|
-
actor_id: context.userId,
|
|
255
|
-
action: "page.schedule",
|
|
256
|
-
entity: "pages",
|
|
257
|
-
entity_id: target.id,
|
|
258
|
-
meta: {
|
|
259
|
-
description: `Scheduled the page "${reveal.revealPageKey.trim()}" to go live at ${publishAt} because a countdown block reveals it when the timer reaches zero.`,
|
|
260
|
-
publish_at: publishAt,
|
|
261
|
-
},
|
|
527
|
+
reveals.push({
|
|
528
|
+
key,
|
|
529
|
+
publishAt,
|
|
530
|
+
description: `Scheduled the page "${key}" to go live at ${publishAt} because a countdown block reveals it when the timer reaches zero.`,
|
|
262
531
|
});
|
|
263
532
|
}
|
|
533
|
+
return reveals;
|
|
264
534
|
}
|
|
265
535
|
|
|
266
536
|
export async function listPageVersionsCore(deps: PagesDeps, pageId: string) {
|
|
@@ -340,10 +610,11 @@ export async function restorePageVersionCore(
|
|
|
340
610
|
export async function createPageCore({ context }: PagesDeps, input: { key: string; name: string }) {
|
|
341
611
|
await assertStaff(context);
|
|
342
612
|
invalidatePublishedCache();
|
|
343
|
-
|
|
613
|
+
// A new page is a draft: it exists in the builder and 404s in public until
|
|
614
|
+
// somebody publishes it deliberately.
|
|
344
615
|
const { data: page, error } = await context.supabase
|
|
345
616
|
.from("pages")
|
|
346
|
-
.insert({ key: input.key, name: input.name, is_system: false, status:
|
|
617
|
+
.insert({ key: input.key, name: input.name, is_system: false, status: DEFAULT_PAGE_STATUS })
|
|
347
618
|
.select("id")
|
|
348
619
|
.single();
|
|
349
620
|
if (error) throw new Error(error.message);
|
|
@@ -358,6 +629,150 @@ export async function createPageCore({ context }: PagesDeps, input: { key: strin
|
|
|
358
629
|
return { id: page.id as string };
|
|
359
630
|
}
|
|
360
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Publish or unpublish a page from the Pages list, without saving a version.
|
|
634
|
+
*
|
|
635
|
+
* Publishing opens a publication window on the current version, which is the
|
|
636
|
+
* same mechanism `savePageVersionCore({ publish: true })` uses, so there is one
|
|
637
|
+
* notion of "live". Unpublishing sets the status back to draft *and* closes
|
|
638
|
+
* every open window, so a page cannot stay live through a stale publication row
|
|
639
|
+
* or a schedule somebody forgot about.
|
|
640
|
+
*/
|
|
641
|
+
export async function setPageStatusCore(
|
|
642
|
+
{ context }: PagesDeps,
|
|
643
|
+
input: { pageId: string; status: PageStatus; editSessionId?: string },
|
|
644
|
+
) {
|
|
645
|
+
await assertStaff(context);
|
|
646
|
+
await assertEditable(context, {
|
|
647
|
+
resourceType: EDIT_LOCK_TYPES.page,
|
|
648
|
+
resourceId: input.pageId,
|
|
649
|
+
sessionId: input.editSessionId,
|
|
650
|
+
});
|
|
651
|
+
const status = normalizePageStatus(input.status);
|
|
652
|
+
const { data: page, error: readErr } = await context.supabase
|
|
653
|
+
.from("pages")
|
|
654
|
+
.select("id,key,name,status,current_version_id")
|
|
655
|
+
.eq("id", input.pageId)
|
|
656
|
+
.is("deleted_at", null)
|
|
657
|
+
.maybeSingle();
|
|
658
|
+
if (readErr) throw new Error(readErr.message);
|
|
659
|
+
if (!page) throw new Error("Page not found");
|
|
660
|
+
if (status === "published" && !page.current_version_id) {
|
|
661
|
+
throw new Error("This page has no saved content yet, so there is nothing to publish.");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
invalidatePublishedCache();
|
|
665
|
+
|
|
666
|
+
/*
|
|
667
|
+
* Retiring the old publication is the same act either way: publishing
|
|
668
|
+
* replaces the live version, unpublishing simply leaves nothing behind it.
|
|
669
|
+
* Both, plus the status column and the audit row, are one transaction in the
|
|
670
|
+
* database — including the audit row, because a publication nobody can
|
|
671
|
+
* account for afterwards is its own kind of half-applied write.
|
|
672
|
+
*/
|
|
673
|
+
await publicationRpc(
|
|
674
|
+
context.supabase,
|
|
675
|
+
"cms_set_page_status",
|
|
676
|
+
{
|
|
677
|
+
_page_id: input.pageId,
|
|
678
|
+
_status: status,
|
|
679
|
+
_actor_email: actorEmail(context),
|
|
680
|
+
_audit: {
|
|
681
|
+
description:
|
|
682
|
+
status === "published"
|
|
683
|
+
? `Published the page "${page.name}" — it is now visible on the public website.`
|
|
684
|
+
: `Unpublished the page "${page.name}" — it is no longer visible on the public website.`,
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
status === "published" ? "Could not publish the page" : "Could not unpublish the page",
|
|
688
|
+
);
|
|
689
|
+
invalidatePublishedCache();
|
|
690
|
+
return { ok: true as const, status };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Copy a page, content and all, into a new draft.
|
|
695
|
+
*
|
|
696
|
+
* The copy gets its own row, its own key, its own first version and its own
|
|
697
|
+
* block ids; the source page is never written to. SEO rows are deliberately not
|
|
698
|
+
* copied — canonical URLs, slugs and meta titles are per-page facts, and
|
|
699
|
+
* duplicating them would publish two pages claiming the same canonical address.
|
|
700
|
+
* The copy starts as a draft whatever the source's status is.
|
|
701
|
+
*/
|
|
702
|
+
export async function duplicatePageCore(
|
|
703
|
+
{ context }: PagesDeps,
|
|
704
|
+
input: { pageId: string; name: string; key?: string },
|
|
705
|
+
) {
|
|
706
|
+
await assertStaff(context);
|
|
707
|
+
const name = input.name.trim();
|
|
708
|
+
if (!name) throw new Error("A name is required for the copy.");
|
|
709
|
+
|
|
710
|
+
const { data: source, error: srcErr } = await context.supabase
|
|
711
|
+
.from("pages")
|
|
712
|
+
.select("id,key,name,current_version_id")
|
|
713
|
+
.eq("id", input.pageId)
|
|
714
|
+
.is("deleted_at", null)
|
|
715
|
+
.maybeSingle();
|
|
716
|
+
if (srcErr) throw new Error(srcErr.message);
|
|
717
|
+
if (!source) throw new Error("Page not found");
|
|
718
|
+
|
|
719
|
+
// Existing keys, including trashed ones: a restored page must not collide.
|
|
720
|
+
const { data: existing, error: keysErr } = await context.supabase.from("pages").select("key");
|
|
721
|
+
if (keysErr) throw new Error(keysErr.message);
|
|
722
|
+
const taken = ((existing ?? []) as Array<{ key: string }>).map((row) => row.key);
|
|
723
|
+
const key = uniquePageKey(input.key?.trim() || name, taken);
|
|
724
|
+
|
|
725
|
+
let blocks: BlockInstance[] = [];
|
|
726
|
+
if (source.current_version_id) {
|
|
727
|
+
const { data: version } = await context.supabase
|
|
728
|
+
.from("page_versions")
|
|
729
|
+
.select("blocks")
|
|
730
|
+
.eq("id", source.current_version_id)
|
|
731
|
+
.maybeSingle();
|
|
732
|
+
blocks = duplicateBlockTree(blocksOf(version?.blocks));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
invalidatePublishedCache();
|
|
736
|
+
const { data: page, error } = await context.supabase
|
|
737
|
+
.from("pages")
|
|
738
|
+
.insert({ key, name, is_system: false, status: DEFAULT_PAGE_STATUS })
|
|
739
|
+
.select("id")
|
|
740
|
+
.single();
|
|
741
|
+
if (error) throw new Error(error.message);
|
|
742
|
+
|
|
743
|
+
const { data: created, error: versionErr } = await context.supabase
|
|
744
|
+
.from("page_versions")
|
|
745
|
+
.insert({
|
|
746
|
+
page_id: page.id,
|
|
747
|
+
version_no: 1,
|
|
748
|
+
blocks: blocks as unknown as any,
|
|
749
|
+
note: `Copied from "${source.name}"`,
|
|
750
|
+
created_by: context.userId,
|
|
751
|
+
created_by_email: actorEmail(context),
|
|
752
|
+
})
|
|
753
|
+
.select("id")
|
|
754
|
+
.single();
|
|
755
|
+
if (versionErr) throw new Error(versionErr.message);
|
|
756
|
+
await context.supabase
|
|
757
|
+
.from("pages")
|
|
758
|
+
.update({ current_version_id: created.id })
|
|
759
|
+
.eq("id", page.id);
|
|
760
|
+
|
|
761
|
+
await context.supabase.from("audit_logs").insert({
|
|
762
|
+
actor_id: context.userId,
|
|
763
|
+
action: "page.duplicate",
|
|
764
|
+
entity: "pages",
|
|
765
|
+
entity_id: page.id,
|
|
766
|
+
meta: {
|
|
767
|
+
key,
|
|
768
|
+
source_key: source.key,
|
|
769
|
+
description: `Duplicated the page "${source.name}" as the draft "${name}" (${key}).`,
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
return { id: page.id as string, key, name, status: DEFAULT_PAGE_STATUS };
|
|
774
|
+
}
|
|
775
|
+
|
|
361
776
|
export async function renamePageCore(
|
|
362
777
|
{ context }: PagesDeps,
|
|
363
778
|
input: { pageId: string; name: string; editSessionId?: string },
|
|
@@ -384,7 +799,15 @@ export async function renamePageCore(
|
|
|
384
799
|
return { ok: true as const };
|
|
385
800
|
}
|
|
386
801
|
|
|
387
|
-
/**
|
|
802
|
+
/**
|
|
803
|
+
* Soft delete: moves the page to the trash bin. Never destroys data.
|
|
804
|
+
*
|
|
805
|
+
* Retiring the page's publications happens in the same transaction as setting
|
|
806
|
+
* `deleted_at`, and that is not tidiness. A trashed page that keeps an open
|
|
807
|
+
* publication row is a page waiting to come back: clearing `deleted_at` would
|
|
808
|
+
* put it straight onto the public website again, which turns Restore into a
|
|
809
|
+
* publishing action nobody confirmed.
|
|
810
|
+
*/
|
|
388
811
|
export async function deletePageCore(
|
|
389
812
|
{ context }: PagesDeps,
|
|
390
813
|
pageId: string,
|
|
@@ -397,32 +820,21 @@ export async function deletePageCore(
|
|
|
397
820
|
sessionId: editSessionId,
|
|
398
821
|
});
|
|
399
822
|
invalidatePublishedCache();
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
.eq("id", pageId)
|
|
416
|
-
.is("deleted_at", null);
|
|
417
|
-
if (error) throw new Error(error.message);
|
|
418
|
-
await context.supabase.from("audit_logs").insert({
|
|
419
|
-
actor_id: context.userId,
|
|
420
|
-
actor_email: email,
|
|
421
|
-
action: "page.soft_delete",
|
|
422
|
-
entity: "pages",
|
|
423
|
-
entity_id: pageId,
|
|
424
|
-
meta: { key: page?.key ?? null, name: page?.name ?? null },
|
|
425
|
-
});
|
|
823
|
+
await publicationRpc(
|
|
824
|
+
context.supabase,
|
|
825
|
+
"cms_soft_delete_page",
|
|
826
|
+
{
|
|
827
|
+
_page_id: pageId,
|
|
828
|
+
_actor_email: actorEmail(context),
|
|
829
|
+
_audit: {
|
|
830
|
+
description:
|
|
831
|
+
"Moved the page to the trash bin. It is no longer visible on the public " +
|
|
832
|
+
"website, and any publication window it had was retired.",
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
"Could not move the page to the trash bin",
|
|
836
|
+
);
|
|
837
|
+
invalidatePublishedCache();
|
|
426
838
|
return { ok: true as const };
|
|
427
839
|
}
|
|
428
840
|
|
|
@@ -454,22 +866,32 @@ export async function listDeletedPagesCore({
|
|
|
454
866
|
return { rows: (data ?? []) as DeletedPageRow[], canPurge: !!isSuper };
|
|
455
867
|
}
|
|
456
868
|
|
|
869
|
+
/**
|
|
870
|
+
* Takes a page out of the trash bin, as a hidden draft.
|
|
871
|
+
*
|
|
872
|
+
* Restoring is not publishing. Whatever the page showed the public before it
|
|
873
|
+
* was deleted stays retired and its status stays draft, so somebody has to
|
|
874
|
+
* press Publish before it is on the internet again — which is what the word
|
|
875
|
+
* restore reads as, and what it did not do before.
|
|
876
|
+
*/
|
|
457
877
|
export async function restorePageCore({ context }: PagesDeps, pageId: string) {
|
|
458
878
|
await assertStaff(context);
|
|
459
879
|
invalidatePublishedCache();
|
|
460
|
-
|
|
461
|
-
.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
880
|
+
await publicationRpc(
|
|
881
|
+
context.supabase,
|
|
882
|
+
"cms_restore_page",
|
|
883
|
+
{
|
|
884
|
+
_page_id: pageId,
|
|
885
|
+
_actor_email: actorEmail(context),
|
|
886
|
+
_audit: {
|
|
887
|
+
description:
|
|
888
|
+
"Restored the page from the trash bin as a hidden draft. It stays off the " +
|
|
889
|
+
"public website until somebody publishes it.",
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
"Could not restore the page",
|
|
893
|
+
);
|
|
894
|
+
invalidatePublishedCache();
|
|
473
895
|
return { ok: true as const };
|
|
474
896
|
}
|
|
475
897
|
|
|
@@ -506,7 +928,8 @@ export function assertSingleBlock(blocks: BlockInstance[]) {
|
|
|
506
928
|
"A global block can only contain one block. Convert it to a global section to add more.",
|
|
507
929
|
);
|
|
508
930
|
}
|
|
509
|
-
|
|
931
|
+
// Flattened, so a reference cannot hide inside a layout column.
|
|
932
|
+
if (flattenBlockTree(blocks).some((b) => b.type === "globalRef")) {
|
|
510
933
|
throw new Error("A global block cannot contain another global block or section.");
|
|
511
934
|
}
|
|
512
935
|
}
|
|
@@ -743,9 +1166,38 @@ export async function getPageVersionPreviewCore(
|
|
|
743
1166
|
const blocks = blocksOf(v.blocks);
|
|
744
1167
|
const globals = await resolveGlobalsDeep(context.supabase, blocks);
|
|
745
1168
|
const meta = (v as unknown as { pages: { key: string; name: string } }).pages;
|
|
746
|
-
|
|
1169
|
+
// A preview is a staff-only view of one named version, not a public window:
|
|
1170
|
+
// there is nothing scheduled to revalidate against.
|
|
1171
|
+
return { key: meta.key, name: meta.name, blocks, versionId: v.id, globals, revalidateAt: null };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* An instant a schedule can actually be built on.
|
|
1176
|
+
*
|
|
1177
|
+
* The browser control offers a wall clock and a calendar, so the UI's own
|
|
1178
|
+
* checks cover the editor typing into it and nothing else — not another host
|
|
1179
|
+
* screen, not a script, not a replayed request. `Date.parse` accepts a good
|
|
1180
|
+
* deal of nonsense and returns `NaN` for the rest, and `NaN` written into a
|
|
1181
|
+
* `timestamptz` is a database error message rather than an answer anyone can
|
|
1182
|
+
* act on, so the answer is worked out here first.
|
|
1183
|
+
*/
|
|
1184
|
+
function scheduleInstant(value: unknown, field: string): number {
|
|
1185
|
+
const at = typeof value === "string" ? Date.parse(value) : Number.NaN;
|
|
1186
|
+
if (!Number.isFinite(at)) {
|
|
1187
|
+
throw new Error(`${field} is not a date and time this schedule can use.`);
|
|
1188
|
+
}
|
|
1189
|
+
return at;
|
|
747
1190
|
}
|
|
748
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* Put a saved version on the calendar, with whatever it reveals.
|
|
1194
|
+
*
|
|
1195
|
+
* One database call, and therefore one transaction: the publication row, the
|
|
1196
|
+
* reveals its countdowns promise and the audit trail all land together or none
|
|
1197
|
+
* of them does. Scheduling used to be a bare insert that read no blocks at
|
|
1198
|
+
* all, so a countdown on a version scheduled for next week scheduled nothing
|
|
1199
|
+
* and the page it promised to reveal stayed hidden for good.
|
|
1200
|
+
*/
|
|
749
1201
|
export async function schedulePageVersionCore(
|
|
750
1202
|
{ context }: PagesDeps,
|
|
751
1203
|
input: {
|
|
@@ -757,9 +1209,21 @@ export async function schedulePageVersionCore(
|
|
|
757
1209
|
},
|
|
758
1210
|
) {
|
|
759
1211
|
await assertStaff(context);
|
|
1212
|
+
const publishAt = scheduleInstant(input.publishAt, "The publish time");
|
|
1213
|
+
const unpublishAt =
|
|
1214
|
+
input.unpublishAt == null
|
|
1215
|
+
? null
|
|
1216
|
+
: scheduleInstant(input.unpublishAt, "The unpublish time");
|
|
1217
|
+
if (unpublishAt !== null && unpublishAt <= publishAt) {
|
|
1218
|
+
throw new Error(
|
|
1219
|
+
"A publication window has to end after it starts. Pick an unpublish time " +
|
|
1220
|
+
"later than the publish time, or leave it empty.",
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
760
1224
|
const { data: v, error: vErr } = await context.supabase
|
|
761
1225
|
.from("page_versions")
|
|
762
|
-
.select("page_id")
|
|
1226
|
+
.select("page_id, blocks")
|
|
763
1227
|
.eq("id", input.versionId)
|
|
764
1228
|
.single();
|
|
765
1229
|
if (vErr) throw new Error(vErr.message);
|
|
@@ -771,21 +1235,27 @@ export async function schedulePageVersionCore(
|
|
|
771
1235
|
sessionId: input.editSessionId,
|
|
772
1236
|
});
|
|
773
1237
|
invalidatePublishedCache();
|
|
774
|
-
const
|
|
775
|
-
.
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1238
|
+
const scheduled = await publicationRpc(
|
|
1239
|
+
context.supabase,
|
|
1240
|
+
"cms_schedule_page_version",
|
|
1241
|
+
{
|
|
1242
|
+
_version_id: input.versionId,
|
|
1243
|
+
_publish_at: new Date(publishAt).toISOString(),
|
|
1244
|
+
_unpublish_at: unpublishAt === null ? null : new Date(unpublishAt).toISOString(),
|
|
1245
|
+
_note: input.note ?? null,
|
|
1246
|
+
// Read off the version being scheduled, not off the canvas: what goes
|
|
1247
|
+
// live next week is what that version says, countdowns included.
|
|
1248
|
+
_reveals: countdownReveals(blocksOf(v.blocks)),
|
|
1249
|
+
_audit: {
|
|
1250
|
+
description:
|
|
1251
|
+
"Scheduled a version of this page to become visible on the public website.",
|
|
1252
|
+
},
|
|
1253
|
+
_actor_email: actorEmail(context),
|
|
1254
|
+
},
|
|
1255
|
+
"Could not schedule the page",
|
|
1256
|
+
);
|
|
1257
|
+
invalidatePublishedCache();
|
|
1258
|
+
return { publicationId: String(scheduled?.publicationId ?? "") };
|
|
789
1259
|
}
|
|
790
1260
|
|
|
791
1261
|
export type PagePublicationRow = {
|
|
@@ -816,6 +1286,19 @@ export async function listPagePublicationsCore(
|
|
|
816
1286
|
return (rows ?? []) as PagePublicationRow[];
|
|
817
1287
|
}
|
|
818
1288
|
|
|
1289
|
+
/**
|
|
1290
|
+
* Call off a publication that has not started yet.
|
|
1291
|
+
*
|
|
1292
|
+
* Only one that has not started. Retiring the row that is carrying the page
|
|
1293
|
+
* right now takes it off the public website — and, where an earlier row is
|
|
1294
|
+
* still active behind it, puts that older version back — which is not what
|
|
1295
|
+
* "Cancel this scheduled publication" means to anybody. Taking a live page
|
|
1296
|
+
* down is {@link setPageStatusCore}, with its own confirmation.
|
|
1297
|
+
*
|
|
1298
|
+
* The boundary is the database's, read inside the transaction that does the
|
|
1299
|
+
* write, so a row that starts between the screen being drawn and the button
|
|
1300
|
+
* being clicked is refused rather than raced.
|
|
1301
|
+
*/
|
|
819
1302
|
export async function cancelPagePublicationCore(
|
|
820
1303
|
{ context }: PagesDeps,
|
|
821
1304
|
publicationId: string,
|
|
@@ -836,12 +1319,24 @@ export async function cancelPagePublicationCore(
|
|
|
836
1319
|
sessionId: editSessionId,
|
|
837
1320
|
});
|
|
838
1321
|
invalidatePublishedCache();
|
|
839
|
-
const
|
|
840
|
-
.
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1322
|
+
const result = await publicationRpc(
|
|
1323
|
+
context.supabase,
|
|
1324
|
+
"cms_cancel_page_publication",
|
|
1325
|
+
{
|
|
1326
|
+
_publication_id: publicationId,
|
|
1327
|
+
_audit: {
|
|
1328
|
+
description: "Cancelled a publication that had not started yet.",
|
|
1329
|
+
},
|
|
1330
|
+
_actor_email: actorEmail(context),
|
|
1331
|
+
},
|
|
1332
|
+
"Could not cancel the publication",
|
|
1333
|
+
);
|
|
1334
|
+
invalidatePublishedCache();
|
|
1335
|
+
return {
|
|
1336
|
+
ok: true as const,
|
|
1337
|
+
cancelled: Boolean(result?.cancelled),
|
|
1338
|
+
reason: typeof result?.reason === "string" ? result.reason : null,
|
|
1339
|
+
};
|
|
845
1340
|
}
|
|
846
1341
|
|
|
847
1342
|
/** Where is a given global block used? */
|
|
@@ -863,8 +1358,9 @@ export async function getGlobalSectionUsageCore({ context }: PagesDeps, key: str
|
|
|
863
1358
|
|
|
864
1359
|
const usedIn: { kind: "page" | "section"; key: string; name: string }[] = [];
|
|
865
1360
|
|
|
1361
|
+
// Flattened, so "where is this global used?" counts nested references too.
|
|
866
1362
|
const hasRef = (blocks: unknown) =>
|
|
867
|
-
blocksOf(blocks).some(
|
|
1363
|
+
flattenBlockTree(blocksOf(blocks)).some(
|
|
868
1364
|
(b) =>
|
|
869
1365
|
b.type === "globalRef" &&
|
|
870
1366
|
String((b.props as { sectionKey?: string } | undefined)?.sectionKey ?? "") === key,
|