@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -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
- // Cached per key and dropped by every page/global write below, so a preview
57
- // reload costs one database round trip instead of several.
58
- return withPublishedCache("page", key, () => readPublishedPage(client, key));
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
- async function readPublishedPage(client: any, key: string): Promise<ResolvedPage | null> {
62
- const { data: page } = await client
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,current_version_id")
168
+ .select("id,key,name")
65
169
  .eq("key", key)
66
170
  .is("deleted_at", null)
67
171
  .maybeSingle();
68
- if (!page) return null;
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
- // Time-based publication: newest active row whose window is open right now.
71
- const nowIso = new Date().toISOString();
72
- const { data: pub } = await client
73
- .from("page_publications")
74
- .select("version_id, publish_at")
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 versionId = pub?.version_id ?? page.current_version_id;
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
- const blocks = blocksOf(version?.blocks);
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 { key: page.key, name: page.name, blocks, versionId: version?.id ?? null, globals };
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
- return (data ?? []) as PageListRow[];
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
- return { page, blocks };
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
- const { data: last } = await context.supabase
158
- .from("page_versions")
159
- .select("version_no")
160
- .eq("page_id", input.pageId)
161
- .order("version_no", { ascending: false })
162
- .limit(1)
163
- .maybeSingle();
164
- const nextNo = (last?.version_no ?? 0) + 1;
165
- const { data: inserted, error } = await context.supabase
166
- .from("page_versions")
167
- .insert({
168
- page_id: input.pageId,
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 context.supabase
180
- .from("pages")
181
- .update({ current_version_id: inserted.id })
182
- .eq("id", input.pageId);
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
- if (input.publish) {
185
- await context.supabase
186
- .from("page_publications")
187
- .update({ is_active: false })
188
- .eq("page_id", input.pageId)
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 here.
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
- async function scheduleCountdownReveals(
208
- context: PagesDeps["context"],
209
- blocks: BlockInstance[],
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
- for (const reveal of reveals) {
219
- const at = resolveCountdownTarget(reveal.targetLocal, reveal.timezone);
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
- const { data: target } = await context.supabase
223
- .from("pages")
224
- .select("id, current_version_id")
225
- .eq("key", reveal.revealPageKey.trim())
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
- invalidatePublishedCache();
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: "draft" })
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
- /** Soft delete: moves the page to the trash bin. Never destroys data. */
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
- const email = actorEmail(context);
401
- const { data: page } = await context.supabase
402
- .from("pages")
403
- .select("key,name,is_system")
404
- .eq("id", pageId)
405
- .maybeSingle();
406
- if (page?.is_system) throw new Error("System pages cannot be deleted");
407
- const { error } = await context.supabase
408
- .from("pages")
409
- .update({
410
- deleted_at: new Date().toISOString(),
411
- deleted_by: context.userId,
412
- deleted_by_email: email,
413
- status: "draft",
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
- const { error } = await context.supabase
461
- .from("pages")
462
- .update({ deleted_at: null, deleted_by: null, deleted_by_email: null })
463
- .eq("id", pageId);
464
- if (error) throw new Error(error.message);
465
- await context.supabase.from("audit_logs").insert({
466
- actor_id: context.userId,
467
- actor_email: actorEmail(context),
468
- action: "page.restore",
469
- entity: "pages",
470
- entity_id: pageId,
471
- meta: {},
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
- if (blocks.some((b) => b.type === "globalRef")) {
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
- return { key: meta.key, name: meta.name, blocks, versionId: v.id, globals };
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 { data: inserted, error } = await context.supabase
775
- .from("page_publications")
776
- .insert({
777
- page_id: v.page_id,
778
- version_id: input.versionId,
779
- publish_at: input.publishAt,
780
- unpublish_at: input.unpublishAt ?? null,
781
- is_active: true,
782
- published_by: context.userId,
783
- note: input.note ?? null,
784
- })
785
- .select("id")
786
- .single();
787
- if (error) throw new Error(error.message);
788
- return { publicationId: inserted.id as string };
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 { error } = await context.supabase
840
- .from("page_publications")
841
- .update({ is_active: false })
842
- .eq("id", publicationId);
843
- if (error) throw new Error(error.message);
844
- return { ok: true as const };
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,