@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
package/docs/NEW-SITE.md CHANGED
@@ -34,6 +34,41 @@ Add one test that pins the contract version the site was built against and one
34
34
  that asserts `missingMigrations(appliedFilenames)` is empty. See
35
35
  `tests/platform-contract.test.ts` in any existing site for the exact shape.
36
36
 
37
+ The current contract is **v2**, because saving a page version — an ordinary
38
+ draft save included — is a single database function call with no JavaScript
39
+ fallback. Three canonical migrations are new since the last published
40
+ `0.90.0-m7.12` package, so a site upgrading from that baseline is missing all
41
+ three:
42
+
43
+ | File | Adds |
44
+ | --- | --- |
45
+ | `20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql` | restates the `draft` default, records the `pages.status` column semantics at that migration step, and adds two partial `pages` indexes: one on `key` for undeleted status-published rows, one on `status`/`updated_at` for undeleted rows |
46
+ | `20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql` | `page_publications` — the row that decides what is public — the backfill for pages that were already live, and the first transactional page functions |
47
+ | `20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql` | the rest of the transactional page lifecycle: reveals, trash, restore and cancellation, plus two repair `UPDATE`s |
48
+
49
+ The last two are the contract-v2 publication pair: they are what
50
+ `PLATFORM_CONTRACT_VERSION = 2` means. All three are in the canonical schema
51
+ manifest, though, so `missingMigrations` and `compareSchema` report every one
52
+ that has not been applied — applying only the pair still leaves the schema out
53
+ of sync. Go out in this order, and not in another:
54
+
55
+ 1. Apply every missing canonical migration. Upgrading from `0.90.0-m7.12`, that
56
+ is all three files above, in chronological order.
57
+ 2. Deploy the package and the site pinned to v2.
58
+ 3. Verify both: `missingMigrations(applied)` is empty, and
59
+ `assertPlatformContract(2)` passes. The second compares two version numbers
60
+ in the site's own code and never reads the database, so it is no substitute
61
+ for the first.
62
+ 4. Let editors back in.
63
+
64
+ The migrations are designed to be applied before the v2 package is deployed,
65
+ and the still-running m7.12 package remains compatible during that ordered
66
+ rollout. They are not pure DDL and not function-only: the set carries an
67
+ `ALTER … SET DEFAULT`, indexes and a column comment, a backfill `INSERT`, two
68
+ repair `UPDATE`s scoped to pages already in the trash, and new and replaced
69
+ functions. Nothing is deleted, and no page that is public before the upgrade
70
+ stops being public because of it.
71
+
37
72
  ## 3. Apply the canonical schema
38
73
 
39
74
  The package ships the canonical migrations under `migrations/`, oldest first.
package/docs/PAGES.md ADDED
@@ -0,0 +1,452 @@
1
+ # Pages: draft, published, duplicated
2
+
3
+ ## Draft is the default, everywhere
4
+
5
+ A page that has just been created — or just been duplicated — is a **draft**.
6
+ Nothing about it is on the public internet until somebody publishes it
7
+ deliberately.
8
+
9
+ That default is declared in three places that have to agree, and they are listed
10
+ here because getting one of them wrong is the failure mode that leaks an unfinished
11
+ page:
12
+
13
+ | Layer | Where |
14
+ | --- | --- |
15
+ | Database default | `ALTER TABLE public.pages ALTER COLUMN status SET DEFAULT 'draft'` |
16
+ | Server | `DEFAULT_PAGE_STATUS` in `src/cms/page-status.ts`, applied in `pages-core.ts` |
17
+ | Public read | `page_publications` — an open publication window, and nothing else, makes a page public |
18
+
19
+ Path resolution is deliberately status-blind: `resolvePathCore` answers with a
20
+ page key for a draft too, because the same map serves the backoffice preview.
21
+ The refusal happens one step later, in the public route's loader. If the
22
+ published-page query answers with nothing — draft, scheduled but not yet open,
23
+ or soft-deleted — the loader throws `notFound()`.
24
+
25
+ That ordering is the point. `head()` is built from `loaderData`, so a loader
26
+ that throws means the page's title, description and canonical URL are never
27
+ emitted; without the refusal the route answered `200` with an empty body under
28
+ a draft's own SEO. `sitemap.xml` never lists the page either, because it asks
29
+ the same question of the same rows.
30
+
31
+ Knowing the URL gets a visitor a 404.
32
+
33
+ ## One rule decides what is public
34
+
35
+ `src/cms/page-publication.ts` holds it, in a sentence:
36
+
37
+ > the newest active publication whose `publishAt` has arrived governs, and the
38
+ > page is public while that row's `unpublishAt` has not.
39
+
40
+ `pages.status` is **not** consulted by the public read. It records editorial
41
+ intent — is this page finished? — and a finished page is not thereby public: a
42
+ version scheduled for next week, a countdown target waiting for its deadline and
43
+ a page whose window has closed are all finished and all hidden.
44
+
45
+ Two things used to answer the question, and they disagreed in every direction
46
+ that matters. A public read that fell back to `pages.status` served a countdown
47
+ target the moment the countdown went up rather than at the deadline; it served a
48
+ version scheduled for next week immediately; and `unpublish_at` hid nothing at
49
+ all on a page that had already been published once. The fallback is gone.
50
+
51
+ ### The screens say what a visitor gets
52
+
53
+ If `pages.status` does not decide what is public, an admin screen that shows only
54
+ `pages.status` is telling the editor something that is not true. Both screens now
55
+ show the state the rule produces — `publicationStateAt` over the page's own
56
+ `page_publications` rows — in five words an editor can act on:
57
+
58
+ | State | Badge | Means |
59
+ | --- | --- | --- |
60
+ | `draft` | Hidden | Nothing open, nothing scheduled |
61
+ | `scheduled` | Scheduled to publish | Hidden now; a window opens later |
62
+ | `published` | Published | A visitor can open it right now |
63
+ | `ending` | Scheduled to hide | Public, with an end date already set |
64
+ | `expired` | Hidden after expiry | Was public; the window has closed |
65
+
66
+ `pages.status` is still shown, labelled **Editorial** so it cannot be mistaken
67
+ for the other one, and the action offered follows the publication state rather
68
+ than the status: a page reading `Published` offers Unpublish even when its status
69
+ says `draft`, and an expired page offers Publish even when its status says
70
+ `published`.
71
+
72
+ ### Where it is now, and what happens next, are two questions
73
+
74
+ The five states above answer only the first one. A page that is live today and
75
+ has a newer version scheduled for Friday is `published` — a visitor can open it
76
+ right now — and a badge that stopped there would say nothing about Friday. A
77
+ badge that instead read `Scheduled to publish` would be worse: it would claim the
78
+ page is hidden.
79
+
80
+ So the next boundary is derived separately, by `nextPublicationTransition`, which
81
+ answers *when* and *which of three things*:
82
+
83
+ | Kind | Happens at that instant |
84
+ | --- | --- |
85
+ | `publish` | A page that is hidden now becomes public |
86
+ | `replace` | A page that is public now starts serving a different version |
87
+ | `hide` | A page that is public now stops being reachable |
88
+
89
+ `publicationSummary` composes the two into what the badge says, so
90
+ `published` + `replace` reads **Published — update scheduled** and shows the
91
+ handover time, while `ending` + `hide` still reads `Scheduled to hide`. The
92
+ publication list marks the row that takes over as **next** —
93
+ `publication_next_version_id`, the version that transition installs — so it is
94
+ clear which of several queued rows is the one about to happen.
95
+
96
+ Both confirmations follow the transition rather than the state.
97
+ `PUBLISH_OVER_REPLACEMENT_CONFIRMATION` warns that publishing now makes the
98
+ current draft visible immediately *and cancels that update*;
99
+ `UNPUBLISH_OVER_SCHEDULE_CONFIRMATION` warns that unpublishing also cancels a
100
+ publication already scheduled, which will not happen by itself afterwards. A
101
+ host that has not started sending `publication_transition` yet still gets the
102
+ right wording: `statusChangeConfirmation` falls back to the publication state.
103
+
104
+ A screen left open crosses these boundaries without anybody touching it. Both
105
+ screens arm a timer for the soonest boundary they can see and refetch when it
106
+ passes, so a page goes from `Scheduled to publish` to `Published` in front of the
107
+ editor rather than at the next navigation. `boundaryTimeoutMs` clamps the wait to
108
+ six hours, so a boundary three months out costs one timer rather than a
109
+ `setTimeout` overflow, and re-arms until it arrives.
110
+
111
+ The awkward part is what happens *at* the boundary, and it is why both screens
112
+ call one hook — `usePublicationBoundaryRefresh` — instead of keeping a timer each.
113
+ The boundary is a fact about the server's data and the refresh is asynchronous, so
114
+ between asking for fresh data and receiving it `boundaryTimeoutMs` keeps answering
115
+ zero: the boundary really has passed, and it goes on having passed until a new
116
+ `publication_at` arrives. Re-arming on that zero is a refetch loop with no delay in
117
+ it, for as long as the answer takes and forever if the read fails. So the hook owns
118
+ a chain of timers rather than a dependency on its own state. Once a refresh has
119
+ been asked for, the next attempt waits `boundaryRetryMs` — thirty seconds, doubling
120
+ to a five-minute ceiling — and what re-arms the timer at its natural length is a
121
+ *new* boundary arriving in fresh data. Hitting the six-hour clamp is not an attempt
122
+ at anything, so it refetches and starts the wait over rather than counting against
123
+ the backoff.
124
+
125
+ ### Cancelling a schedule is not unpublishing
126
+
127
+ The Cancel button on a publication row is offered only for a row that has not
128
+ started, and the server decides that, not React. `cms_cancel_page_publication`
129
+ locks the row and compares `publish_at` against the database clock: a row already
130
+ started is refused with a message that names Unpublish as the thing to use
131
+ instead, because cancelling it would take the page off the public website —
132
+ possibly putting an older version back — under a word that promises the opposite.
133
+ A row that is already inactive answers `already-cancelled` and writes nothing, so
134
+ a second click is not an error. A cancellation that goes through retires exactly
135
+ that row, audits it as `page.schedule.cancel`, and leaves what visitors can reach
136
+ untouched.
137
+
138
+ That check is against `now()` on the server, so a screen rendered a minute before
139
+ the boundary and clicked a minute after it is refused rather than obeyed. Live
140
+ rows do not offer the button at all; they carry the sentence *Live — unpublish
141
+ the page to take it down*.
142
+
143
+ `listPagesCore` and `getPageForEditCore` read those rows, so the derivation
144
+ happens once on the server rather than three times in three components. A
145
+ `page_publications` read that fails is an error, not an empty list — a page
146
+ silently reported as `Hidden` because a query failed is the same lie in the other
147
+ direction.
148
+
149
+ The second half of the rule — *that row's* `unpublishAt` — is why an older row
150
+ never comes back into force behind a newer one. Without it, "hide this at
151
+ midnight" would quietly mean "show the previous version again at midnight".
152
+
153
+ Boundaries are exact. A page is public **at** its `publish_at` and hidden **at**
154
+ its `unpublish_at`, to the millisecond, and
155
+ `tests/page-publication-window.test.ts` pins all five positions — before, at the
156
+ start, inside, at the end, after — through the pure rule, the page reader and
157
+ the sitemap alike.
158
+
159
+ Caches inherit the boundary rather than outliving it. `getPublishedPageCore`
160
+ returns `revalidateAt`, the next instant its answer can change; the server cache
161
+ expires an entry at that instant when it comes before the TTL, and the browser
162
+ query's `staleTime` is clamped the same way. A twenty-second cache can no longer
163
+ carry a 404 five seconds past the moment a page goes live.
164
+
165
+ ### The migrations, and the order they go out in
166
+
167
+ Three files are new since the last published `0.90.0-m7.12` package, so a site
168
+ upgrading from that baseline is missing all three:
169
+
170
+ | File | Adds | Needed for |
171
+ | --- | --- | --- |
172
+ | `20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql` | restates the `draft` default, records the `pages.status` column semantics at that migration step, and adds two partial `pages` indexes: one on `key` for undeleted status-published rows, one on `status`/`updated_at` for undeleted rows | canonical schema parity |
173
+ | `20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql` | `page_publications`, the backfill, `cms_save_page_version`, `cms_set_page_status`, `cms_schedule_page_reveal` | contract-v2 publication behaviour |
174
+ | `20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql` | reveals and audit metadata inside `cms_save_page_version`, `cms_soft_delete_page`, `cms_restore_page`, `cms_cancel_page_publication`, `cms_schedule_page_version`, and two repair `UPDATE`s | contract-v2 publication behaviour |
175
+
176
+ The last two are the pair that establishes contract-v2 publication behaviour:
177
+ together they are what `PLATFORM_CONTRACT_VERSION = 2` means. A site declares the
178
+ contract it was built against and `assertPlatformContract` refuses the mismatch
179
+ at startup, rather than letting an editor discover it when a publish fails
180
+ halfway.
181
+
182
+ All three are listed in `SCHEMA_MIGRATIONS`. `compareSchema` names whichever of
183
+ them is missing and does not report in sync until every canonical migration has
184
+ been applied, so applying only the publication pair leaves the drift check
185
+ pointing at the first file.
186
+
187
+ Release order, and it is not interchangeable:
188
+
189
+ 1. **Apply every missing canonical migration.** Upgrading from `0.90.0-m7.12`,
190
+ that is all three files above, in chronological order. They add a column
191
+ default, indexes and a comment, a table, functions and replaced function
192
+ bodies, and the running m7.12 package keeps working against them. They are
193
+ not, however, pure DDL: the second backfills an open publication for every
194
+ published page that has none, and the third runs two repair `UPDATE`s over
195
+ rows the old code left inconsistent — retiring publications still open on
196
+ pages already in the trash, and setting those pages' status back to `draft`.
197
+ Both are described below. Nothing is deleted and no page that is public
198
+ before the upgrade stops being public because of it.
199
+ 2. **Deploy the contract-v2 package.** It calls the new signatures, which by then
200
+ exist.
201
+ 3. **Verify both.** `compareSchema` reports in sync — no canonical migration
202
+ missing — and `assertPlatformContract(2)` returns. The second compares two
203
+ version numbers in the site's own code and never reads the database, so on
204
+ its own it says nothing about whether the migrations ran.
205
+ 4. **Let editors back in.**
206
+
207
+ Deploying the package first gives an editor a publish that fails on a function
208
+ that is not there yet; that is the click-time failure this ordering exists to
209
+ avoid.
210
+
211
+ The `…140000_…` file backfills an open publication for every published page that
212
+ has none, so a site that was live before the upgrade is still live after it.
213
+
214
+ The `…160000_…` file repairs what the trash left behind, in two `UPDATE`s over
215
+ existing rows. A page deleted before this migration kept its open publication —
216
+ unreachable, because the reader skips deleted pages, but waiting for the restore
217
+ button to make it public again — so those rows are retired. And those same pages
218
+ often still said `published` in a list, a claim about the public website that
219
+ stopped being true when they were deleted, so their status goes back to `draft`.
220
+ Both statements touch only pages with `deleted_at` set: nothing a visitor can
221
+ reach today changes.
222
+
223
+ It deliberately skips a page that already has an active row: that row is a deliberate schedule —
224
+ a countdown reveal, a dated launch — and publishing it now is exactly the bug
225
+ being fixed. A published page whose window has already closed goes dark, because
226
+ that is what its editor asked for.
227
+
228
+ ## Publishing is one transaction
229
+
230
+ Publishing is five statements: write the version, move `current_version_id`,
231
+ retire the old publication, open the new one, flip the status. A PostgREST
232
+ client cannot wrap them, and checking each response is not a fix — it reports the
233
+ failure honestly and leaves the site half published either way. An open
234
+ publication with the status never flipped made a page public that the editor was
235
+ told had not published; a moved current-version pointer with the replacement
236
+ publication missing put the next draft in front of visitors.
237
+
238
+ So the whole change lives in the database:
239
+
240
+ | Function | Called by |
241
+ | --- | --- |
242
+ | `cms_save_page_version` | `savePageVersionCore` |
243
+ | `cms_set_page_status` | `setPageStatusCore` |
244
+ | `cms_schedule_page_reveal` | `cms_save_page_version`, and the countdown path |
245
+ | `cms_soft_delete_page` | `deletePageCore` |
246
+ | `cms_restore_page` | `restorePageCore` |
247
+
248
+ Each is `SECURITY DEFINER` with the same staff check as every other page
249
+ mutation, and each writes its audit row inside the same transaction — a
250
+ publication nobody can account for afterwards is its own half-applied write.
251
+
252
+ Countdown reveals are inside that transaction too, and that is the second half of
253
+ the fix. Scheduling them afterwards meant a reveal that could not be scheduled —
254
+ a target renamed, or never written to — reported a failed publish to an editor
255
+ whose page was already live. `savePageVersionCore` now reads the reveal
256
+ instructions out of the blocks *before* it calls anything and passes them in as
257
+ `_reveals`; the source page, every reveal and every audit row commit together or
258
+ not at all. `_audit` carries the actor's own metadata into the `page.publish`
259
+ row written by the same statement.
260
+
261
+ There is deliberately **no JavaScript fallback**. Compensating for a failed step
262
+ with further un-transacted steps is precisely the thing that does not work.
263
+
264
+ `tests/page-publish-duplicate.test.ts` breaks each statement in turn and then
265
+ asks the public reader what it serves; the answer has to be exactly what it was
266
+ before the call. `tests/page-publish-atomicity.test.ts` does the same for the
267
+ reveals — the only schedule, the second of several, the publication audit — and
268
+ checks the database rows as well as the public answer, because "nothing visible
269
+ changed" and "nothing changed" are different claims.
270
+
271
+ ## Deleting is not unpublishing, and restoring is not publishing
272
+
273
+ The trash used to leak in both directions. `deletePageCore` set `deleted_at` and
274
+ left the publication rows active; `restorePageCore` cleared `deleted_at` and
275
+ those rows came back into force. Publish, trash, restore, and the page was on the
276
+ internet again — a publishing action nobody confirmed, from a button labelled
277
+ Restore.
278
+
279
+ Both halves are now one statement each:
280
+
281
+ - **`cms_soft_delete_page`** retires every active publication, sets `deleted_at`
282
+ and sets `status` to `draft`, in one transaction. A page in the trash is off
283
+ the internet by the same rule that governs every other page, not by a second
284
+ rule that happens to agree.
285
+ - **`cms_restore_page`** clears `deleted_at` and leaves the page a hidden draft.
286
+ It never inserts a publication and never reactivates one — including the rows a
287
+ page deleted before this migration still carries, which it retires on the way
288
+ through. A restored page becomes public when somebody presses Publish.
289
+
290
+ `tests/page-trash-restore.test.ts` walks it: publish, delete, restore,
291
+ `getPublishedPageCore` answers `null`, `listPublishedPageKeysCore` omits the key,
292
+ and only an explicit publish puts it back.
293
+
294
+ ## Granting EXECUTE on a self-hosted server
295
+
296
+ The functions are `SECURITY DEFINER`, so PUBLIC is revoked unconditionally — on
297
+ every server, with or without the hosted roles. `anon`, `authenticated` and
298
+ `service_role` are PostgREST deployment conventions; naming a role that does not
299
+ exist is an error rather than a warning, so those grants run only when the role
300
+ is there. Existing hosted installations are unaffected: `authenticated` and
301
+ `service_role` keep EXECUTE, `anon` gets nothing.
302
+
303
+ A self-hosted installation grants its own application role, in a migration of its
304
+ own. Every statement, in full:
305
+
306
+ ```sql
307
+ GRANT EXECUTE ON FUNCTION
308
+ public.cms_save_page_version(uuid, jsonb, text, boolean, text, jsonb, jsonb) TO cms_app;
309
+ GRANT EXECUTE ON FUNCTION public.cms_set_page_status(uuid, text, jsonb, text) TO cms_app;
310
+ GRANT EXECUTE ON FUNCTION
311
+ public.cms_schedule_page_reveal(text, timestamptz, jsonb, text, uuid, timestamptz) TO cms_app;
312
+ GRANT EXECUTE ON FUNCTION
313
+ public.cms_schedule_page_version(uuid, timestamptz, timestamptz, text, jsonb, jsonb, text) TO cms_app;
314
+ GRANT EXECUTE ON FUNCTION public.cms_cancel_page_publication(uuid, jsonb, text) TO cms_app;
315
+ GRANT EXECUTE ON FUNCTION public.cms_soft_delete_page(uuid, jsonb, text) TO cms_app;
316
+ GRANT EXECUTE ON FUNCTION public.cms_restore_page(uuid, jsonb, text) TO cms_app;
317
+ ```
318
+
319
+ `cms_actor_email` is deliberately absent. It is an internal helper the writers
320
+ call for themselves, and nothing outside them needs it.
321
+
322
+ ## Proving it against a real server
323
+
324
+ The unit tests run all of this against a fake PostgREST client. That proves the
325
+ TypeScript, not the SQL: whether the functions parse, keep the signatures the
326
+ client calls, grant legally on a server without the hosted roles, and really do
327
+ roll a half-finished publish back. Only a server answers those.
328
+
329
+ ```bash
330
+ bun run test:migrations
331
+ ```
332
+
333
+ It starts a disposable `postgres:16-alpine` container, applies every migration to
334
+ two databases and makes 103 assertions:
335
+
336
+ - **neutral** — the hosted roles are dropped from the cluster *before* the two
337
+ publication migrations run. Both apply; PUBLIC can execute none of the seven
338
+ writers; a freshly created application role granted by the statements above
339
+ can execute all seven, and still not `cms_actor_email`.
340
+ - **legacy** — every migration in order on a database that has the hosted roles.
341
+ Signatures are checked against what the client calls, `authenticated` and
342
+ `service_role` keep EXECUTE, `anon` has none.
343
+ - **behaviour** — a publish with a reveal; then a trigger that refuses the
344
+ publication audit, and another that refuses the reveal *after* the source page
345
+ has been published, each of which has to leave the database exactly as it was;
346
+ then delete, restore, and the explicit publish that finally puts the page back.
347
+ - **guards** — the refusals, which are worth as little as the server that
348
+ enforces them: restoring a page that is not in the trash; a countdown aimed at
349
+ the page carrying it, at a page already public, or at a deadline that has
350
+ already passed; several reveals of which one is impossible; cancelling a
351
+ scheduled publication, and being refused when it has already started; and the
352
+ times `cms_schedule_page_version` will and will not accept. Each refusal is
353
+ followed by a check that the publication rows, the pages and the sitemap are
354
+ byte-for-byte what they were before it.
355
+
356
+ `ops/migration-smoke/prelude.sql` supplies the `auth` and `storage` stubs a plain
357
+ server does not have. It is not shipped and is not a migration — it exists so the
358
+ real migrations can be run against something.
359
+
360
+ ### Pointing it at a server of your own
361
+
362
+ ```bash
363
+ CMS_SMOKE_ALLOW_DESTRUCTIVE=1 CMS_SMOKE_DSN=postgres://… bun run test:migrations
364
+ ```
365
+
366
+ `CMS_SMOKE_DSN` skips Docker, which is what CI's `migrations` job does with a
367
+ `postgres:16` service. **It requires a disposable PostgreSQL cluster.** The
368
+ runner creates and drops databases, and it drops and recreates the cluster-wide
369
+ roles `anon`, `authenticated` and `service_role` — by those names, because the
370
+ migrations grant to them by those names, so they cannot be renamed per run.
371
+ Against a shared or production server that is an outage, which is why a DSN
372
+ without `CMS_SMOKE_ALLOW_DESTRUCTIVE=1` refuses to run at all rather than
373
+ starting and stopping halfway. The container form needs no acknowledgement: it
374
+ creates the server it destroys.
375
+
376
+ Everything else the run names for itself carries a random suffix —
377
+ `cms_smoke_neutral_…`, `cms_smoke_legacy_…`, `cms_smoke_app_…` — so a crashed run
378
+ leaves debris that is recognisably this script's, and the next run clears it
379
+ before it starts. Only that naming is ever touched. `CMS_SMOKE_KEEP=1` keeps
380
+ them, and the container, around to poke at.
381
+
382
+ The job is deliberately not part of `bun run check`, which must keep running with
383
+ nothing but Bun installed.
384
+
385
+ ## Publishing is a confirmed action
386
+
387
+ One click never publishes, from either screen that can.
388
+
389
+ The Pages list opens a dialog whose text is fixed in `src/cms/page-status.ts` so
390
+ it cannot drift between screens:
391
+
392
+ ```ts
393
+ PUBLISH_CONFIRMATION // "This will make the page visible to everyone on the internet…"
394
+ UNPUBLISH_CONFIRMATION // "Visitors will no longer be able to reach this page…"
395
+ ```
396
+
397
+ The dialog names the page and its address (`About us — /about`), so the last thing
398
+ an editor reads before publishing is *which* page they are publishing. Escape and
399
+ Cancel change nothing; the tests assert the mutation was **not called**, rather
400
+ than that a dialog closed.
401
+
402
+ `ConfirmDialog` handles the accessibility: `role="dialog"`, `aria-modal`,
403
+ `aria-labelledby` on the heading, `aria-describedby` on the confirmation text,
404
+ initial focus on the confirm button, Tab trapped inside, focus restored on close.
405
+
406
+ The page builder's **Publish now** sits in the same button row as **Save draft**
407
+ and is the other way a page goes live, so it confirms too — with the same native
408
+ `confirm()` its neighbours in that screen already use for cancelling a scheduled
409
+ publication and restoring a version. It names the page. Declining calls nothing;
410
+ the test asserts `save` was not invoked, not that a dialog closed.
411
+
412
+ Unpublish is styled `bg-red-600`. Publish is not. The destructive-looking one is
413
+ the one that takes something away from visitors.
414
+
415
+ ## Duplication
416
+
417
+ Duplicating asks for a name first, because the copy is going to sit next to the
418
+ original in a list and "About us (copy)" is only useful for about a minute.
419
+
420
+ - The address is derived from the name (`slugifyPageKey`) and made unique
421
+ (`uniquePageKey`), so `about-us` becomes `about-us-2`.
422
+ - Once the editor edits the address themselves, it **stops** following the name.
423
+ A field that keeps overwriting what you typed is worse than no suggestion.
424
+ - The copy is a deep copy with fresh block ids throughout
425
+ (`duplicateBlockTree`), so editing the copy cannot reach into the original.
426
+ - **No canonical URL is duplicated.** The copy is built from a deliberately
427
+ narrow `select` — key, name and the current version's blocks — so canonical
428
+ URLs, slugs and meta titles are never carried across. They are per-page facts,
429
+ and two pages claiming the same canonical is a silent search-ranking bug.
430
+ - `is_system` is `false` on the copy, whatever the original was. A duplicate of
431
+ a system page is ordinary content.
432
+ - The copy is a draft. Always.
433
+
434
+ The dialog states both of those last two facts in words — "the copy is a draft"
435
+ and "the original is not changed" — because that is the question an editor
436
+ actually has before pressing the button.
437
+
438
+ ## Host wiring
439
+
440
+ Both actions are **optional** on the host contract:
441
+
442
+ ```ts
443
+ pages: { fns: {
444
+ setPageStatus?: HostServerFn;
445
+ duplicatePage?: HostServerFn;
446
+ } }
447
+ ```
448
+
449
+ A host that has not registered them keeps working — the Pages list hides the two
450
+ actions rather than offering a button that throws. Authorization is the existing
451
+ one: both go through the same staff guard as every other page mutation, and
452
+ `tests/page-publish-duplicate.test.ts` pins a non-staff caller being refused.
@@ -0,0 +1,130 @@
1
+ # Scheduled visibility
2
+
3
+ One question — *should this be on screen right now?* — is answered in one place,
4
+ `src/cms/visibility-schedule.ts`. Menu items, footer columns and footer links all
5
+ call the same function with the same clock. Nothing re-implements the comparison,
6
+ because two implementations of "is it visible yet" drift, and the drift is
7
+ invisible until a customer sees a menu entry that the footer says is gone.
8
+
9
+ ## The shape
10
+
11
+ ```ts
12
+ type VisibilitySchedule = {
13
+ enabled?: boolean; // the manual switch; absent means shown
14
+ publishAt?: string | null; // ISO-8601 UTC instant, or absent
15
+ unpublishAt?: string | null; // ISO-8601 UTC instant, or absent
16
+ };
17
+ ```
18
+
19
+ An item with no schedule at all is visible. That is the important default: this
20
+ feature was added to a site that already had menus, and none of those rows have
21
+ a `schedule` key. `isVisibleAt(undefined, now)` is `true`, so nothing disappeared
22
+ on upgrade.
23
+
24
+ `normalizeSchedule` **omits** keys rather than writing `null` for them, so a
25
+ schedule that has never been given a date round-trips through JSONB as `{}`
26
+ instead of accumulating null fields on every save.
27
+
28
+ ## Storage is UTC. Display is the site's timezone.
29
+
30
+ The stored value is always a UTC instant. The editor never sees one.
31
+
32
+ `ScheduleField` (`src/backoffice/ScheduleFields.tsx`) is the only place the
33
+ conversion happens, in both directions:
34
+
35
+ | Direction | Function | Example |
36
+ | --- | --- | --- |
37
+ | Editor types → stored | `localInputToIso` | `2026-09-10T18:00` in `Europe/Stockholm` → `2026-09-10T16:00:00.000Z` |
38
+ | Stored → editor sees | `isoToLocalInput` | the same instant back to `2026-09-10T18:00` |
39
+
40
+ The zone comes from `siteTimezone()` (`src/config/site-config.ts`), not from the
41
+ administrator's laptop. An editor in Madrid scheduling a Swedish site's banner
42
+ gets Swedish opening time, which is what they meant.
43
+
44
+ `src/cms/timezone.ts` does the offset arithmetic with `Intl.DateTimeFormat` —
45
+ no date library was added. `zoneAbbreviation` puts `CEST` next to the fields so
46
+ the editor can see which clock they are on.
47
+
48
+ ## The one rule that matters
49
+
50
+ **Do not call `Date.now()` inside a render path that shows scheduled content.**
51
+
52
+ `renderableNavigation(items, locale, now)` takes the instant as a parameter. So
53
+ does `visibleItems`, `isVisibleAt` and `visibilityStateAt`. This is not test
54
+ convenience — it is what makes a boundary *reachable*. A component that reads the
55
+ clock itself renders once, caches, and then shows yesterday's menu until
56
+ something unrelated re-renders it.
57
+
58
+ `useScheduleClock` (`src/cms/use-schedule-clock.ts`) closes that loop: give it the
59
+ schedules on screen and it re-renders at the next boundary and no sooner. Not a
60
+ one-second interval — `nextScheduleChangeAcross` computes the single next instant
61
+ anything changes, and a page with no schedules never sets a timer at all.
62
+
63
+ ```tsx
64
+ const now = useScheduleClock(navigationSchedules(items));
65
+ const visible = renderableNavigation(items, locale, now);
66
+ ```
67
+
68
+ ## What an editor sees
69
+
70
+ `VisibilityBadge` renders six states, each with a word and an icon shape as well
71
+ as a colour: **Visible**, **Hidden**, **Scheduled to publish**, **Scheduled to
72
+ hide**, **Expired**, **Needs attention**. Status is never colour alone.
73
+
74
+ Two kinds of nonsense are refused rather than stored.
75
+
76
+ `scheduleProblems` names a range that ends before it starts. `zonedLocalExists`
77
+ catches the other one: a wall-clock time the site's timezone never reaches.
78
+ Typing `2026-03-29T02:30` for a Stockholm site would resolve to an instant an
79
+ hour from what was typed, because the clocks jump 02:00 → 03:00 that night, so
80
+ `ScheduleField` keeps the field's previous value and says why instead. In both
81
+ cases the offending field takes `aria-invalid` and the message is tied to it
82
+ with `aria-describedby`.
83
+
84
+ Autumn's *repeated* hour is deliberately allowed. `01:30` happens twice, so both
85
+ readings are real instants and taking the first is a defensible answer; the
86
+ spring gap has no reading at all.
87
+
88
+ `ScheduleField` is a controlled input, though, and the Save button belongs to
89
+ whichever screen embeds it. So the refusal cannot end at the field — two places
90
+ make it binding:
91
+
92
+ - `saveContentSectionCore` runs `scheduleProblemsIn` over the whole content
93
+ payload before writing and rejects the save, naming the path of the first bad
94
+ schedule (`columns[0].links[2].schedule`). Menus, footers and shared sections
95
+ all save through it, so a host cannot store an invalid schedule by forgetting
96
+ to wire the field's validity into its own button.
97
+ - The page builder checks the schedule panel's own fields before it saves
98
+ anything, because that button is the platform's.
99
+
100
+ The page builder reads those fields in `siteTimezone()` too, not in the
101
+ browser's zone — an editor on a laptop still set to New York schedules the same
102
+ instant as a colleague at home.
103
+
104
+ ## Pages schedule through publications, not through this
105
+
106
+ Everything above is about *items inside a page* — menu entries, footer links,
107
+ sections. A whole page's visibility is a different mechanism with its own rows:
108
+ `page_publications`, one rule in `src/cms/page-publication.ts`, described in
109
+ [`PAGES.md`](PAGES.md).
110
+
111
+ They are deliberately not merged. A page's schedule has to be readable by the
112
+ sitemap and by the public route without loading the page's content first, and it
113
+ has to survive a version being replaced underneath it. A JSONB `schedule` key on
114
+ a content row answers neither.
115
+
116
+ What they share is the vocabulary. `publishAt` and `unpublishAt` mean the same
117
+ thing on both sides, both store UTC instants and display the site's timezone,
118
+ and both are closed **at** `unpublishAt` rather than a tick later. An editor
119
+ unpublishing a page still gets it gone now: `setPageStatusCore` closes every open
120
+ window as well as setting the status, so a page cannot stay live through a
121
+ schedule somebody forgot about. Moving a page to the trash closes them too, and
122
+ restoring it does not reopen them — see
123
+ [Deleting is not unpublishing](PAGES.md#deleting-is-not-unpublishing-and-restoring-is-not-publishing).
124
+
125
+ They also share the five words for what a schedule is currently doing. A page's
126
+ badge comes from `publicationStateAt` — Hidden, Scheduled to publish, Published,
127
+ Scheduled to hide, Hidden after expiry — for the same reason an item's comes from
128
+ `visibilityStateAt`: "draft" and "published" cannot say which side of a window
129
+ something is on, and that is the only thing an editor looking at a schedule wants
130
+ to know.