@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
@@ -0,0 +1,751 @@
1
+ -- Every public transition in one transaction, including the two that hide -----
2
+ --
3
+ -- The previous migration made publication rows the only thing the public
4
+ -- reader consults. That closed the scheduling leaks and left three holes,
5
+ -- because three writes still changed public visibility without going through
6
+ -- a publication:
7
+ --
8
+ -- * Moving a page to the trash set `deleted_at` and left its open
9
+ -- publication behind. Restoring it cleared `deleted_at` and the page came
10
+ -- straight back onto the internet — while `pages.status` said draft.
11
+ -- Restore had quietly become a publishing action nobody confirmed.
12
+ -- * Publishing a page whose countdown reveals another page committed the
13
+ -- publication first and scheduled the reveals afterwards, in separate
14
+ -- transactions. The second reveal failing left the first one committed and
15
+ -- the source page live, and reported the whole save as failed.
16
+ -- * `cms_save_page_version` published without writing an audit entry, so the
17
+ -- one route most editors publish through left no trace.
18
+ --
19
+ -- All three are fixed here, and the reveal logic is not copied: publishing
20
+ -- calls the same `cms_schedule_page_reveal` the countdown path always used,
21
+ -- from inside its own transaction, so either the page and every reveal commit
22
+ -- or none of them does.
23
+
24
+ -- 1. Pages already in the trash with a publication still open ----------------
25
+ --
26
+ -- They cannot be reached today — the reader joins `pages` and skips deleted
27
+ -- ones — but the row is a loaded gun pointed at the restore button. Retire it
28
+ -- now so restoring an old page is the ordinary "back as a draft" it reads as.
29
+ UPDATE public.page_publications pp
30
+ SET is_active = false
31
+ FROM public.pages p
32
+ WHERE pp.page_id = p.id
33
+ AND pp.is_active
34
+ AND p.deleted_at IS NOT NULL;
35
+
36
+ -- Deleted pages are draft pages. One of them saying "published" in a list is a
37
+ -- claim about the public website that has not been true since it was deleted.
38
+ UPDATE public.pages
39
+ SET status = 'draft'
40
+ WHERE deleted_at IS NOT NULL
41
+ AND status <> 'draft';
42
+
43
+ -- 2. Trash and restore -------------------------------------------------------
44
+
45
+ -- Move a page to the trash: it disappears from the public website, and the
46
+ -- rows that made it public are retired rather than left waiting.
47
+ CREATE OR REPLACE FUNCTION public.cms_soft_delete_page(
48
+ _page_id uuid,
49
+ _audit jsonb DEFAULT '{}'::jsonb,
50
+ _actor_email text DEFAULT NULL
51
+ )
52
+ RETURNS jsonb
53
+ LANGUAGE plpgsql
54
+ SECURITY DEFINER
55
+ SET search_path = public, pg_temp
56
+ AS $$
57
+ DECLARE
58
+ _uid uuid := app.current_user_id();
59
+ _page public.pages;
60
+ BEGIN
61
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
62
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
63
+ END IF;
64
+
65
+ SELECT * INTO _page FROM public.pages
66
+ WHERE id = _page_id AND deleted_at IS NULL
67
+ FOR UPDATE;
68
+ IF _page.id IS NULL THEN
69
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
70
+ END IF;
71
+ IF _page.is_system THEN
72
+ RAISE EXCEPTION 'System pages cannot be deleted' USING ERRCODE = '42501';
73
+ END IF;
74
+
75
+ UPDATE public.page_publications
76
+ SET is_active = false
77
+ WHERE page_id = _page_id AND is_active;
78
+
79
+ UPDATE public.pages
80
+ SET deleted_at = now(),
81
+ deleted_by = _uid,
82
+ deleted_by_email = public.cms_actor_email(_uid, _actor_email),
83
+ status = 'draft',
84
+ updated_at = now(),
85
+ updated_by = _uid
86
+ WHERE id = _page_id;
87
+
88
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
89
+ VALUES (
90
+ _uid,
91
+ public.cms_actor_email(_uid, _actor_email),
92
+ 'page.soft_delete',
93
+ 'pages',
94
+ _page_id::text,
95
+ COALESCE(_audit, '{}'::jsonb) || jsonb_build_object('key', _page.key, 'name', _page.name)
96
+ );
97
+
98
+ RETURN jsonb_build_object('deleted', true, 'key', _page.key, 'name', _page.name);
99
+ END;
100
+ $$;
101
+
102
+ -- Take a page out of the trash. It comes back as a hidden draft and nothing
103
+ -- else: republishing it is a separate, deliberate act.
104
+ --
105
+ -- The publication rows are retired again rather than trusted to be retired
106
+ -- already. This function is the last thing standing between a row written by
107
+ -- some older code path and a page the world can suddenly read.
108
+ --
109
+ -- Which is exactly why it may only touch a page that is *in* the trash. Called
110
+ -- with the id of a live page it would do everything above to a page nobody
111
+ -- deleted: retire the publication carrying it and set its status back to
112
+ -- draft. That is an unpublish wearing a restore's name, and it is reachable
113
+ -- from any caller that can guess a page id. A page that is not deleted is
114
+ -- therefore not found here at all — the row is neither read nor locked — and
115
+ -- the caller is told what is wrong instead.
116
+ CREATE OR REPLACE FUNCTION public.cms_restore_page(
117
+ _page_id uuid,
118
+ _audit jsonb DEFAULT '{}'::jsonb,
119
+ _actor_email text DEFAULT NULL
120
+ )
121
+ RETURNS jsonb
122
+ LANGUAGE plpgsql
123
+ SECURITY DEFINER
124
+ SET search_path = public, pg_temp
125
+ AS $$
126
+ DECLARE
127
+ _uid uuid := app.current_user_id();
128
+ _page public.pages;
129
+ BEGIN
130
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
131
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
132
+ END IF;
133
+
134
+ SELECT * INTO _page FROM public.pages
135
+ WHERE id = _page_id AND deleted_at IS NOT NULL
136
+ FOR UPDATE;
137
+ IF _page.id IS NULL THEN
138
+ -- Two different mistakes, and an editor can only fix the one they made.
139
+ IF EXISTS (SELECT 1 FROM public.pages WHERE id = _page_id) THEN
140
+ RAISE EXCEPTION 'Page is not in the trash, so there is nothing to restore.'
141
+ USING ERRCODE = '22023';
142
+ END IF;
143
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
144
+ END IF;
145
+
146
+ UPDATE public.page_publications
147
+ SET is_active = false
148
+ WHERE page_id = _page_id AND is_active;
149
+
150
+ UPDATE public.pages
151
+ SET deleted_at = NULL,
152
+ deleted_by = NULL,
153
+ deleted_by_email = NULL,
154
+ status = 'draft',
155
+ updated_at = now(),
156
+ updated_by = _uid
157
+ WHERE id = _page_id;
158
+
159
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
160
+ VALUES (
161
+ _uid,
162
+ public.cms_actor_email(_uid, _actor_email),
163
+ 'page.restore',
164
+ 'pages',
165
+ _page_id::text,
166
+ COALESCE(_audit, '{}'::jsonb) || jsonb_build_object('key', _page.key, 'name', _page.name)
167
+ );
168
+
169
+ RETURN jsonb_build_object('restored', true, 'key', _page.key, 'name', _page.name);
170
+ END;
171
+ $$;
172
+
173
+ -- 3. Saving, publishing and revealing as one transaction ---------------------
174
+ --
175
+ -- The parameter list grows by two, so the old signature is dropped rather than
176
+ -- replaced: `CREATE OR REPLACE` with extra defaulted parameters leaves both
177
+ -- versions installed, and a call by name then matches neither uniquely.
178
+ DROP FUNCTION IF EXISTS public.cms_save_page_version(uuid, jsonb, text, boolean, text);
179
+
180
+ CREATE OR REPLACE FUNCTION public.cms_save_page_version(
181
+ _page_id uuid,
182
+ _blocks jsonb,
183
+ _note text DEFAULT NULL,
184
+ _publish boolean DEFAULT false,
185
+ _actor_email text DEFAULT NULL,
186
+ -- `[{ "key": "...", "publishAt": "...", "description": "..." }]` — the pages
187
+ -- a countdown on this page reveals when its timer reaches zero.
188
+ _reveals jsonb DEFAULT '[]'::jsonb,
189
+ _audit jsonb DEFAULT '{}'::jsonb
190
+ )
191
+ RETURNS jsonb
192
+ LANGUAGE plpgsql
193
+ SECURITY DEFINER
194
+ SET search_path = public, pg_temp
195
+ AS $$
196
+ DECLARE
197
+ _uid uuid := app.current_user_id();
198
+ _email text;
199
+ _page public.pages;
200
+ _next_no integer;
201
+ _version_id uuid;
202
+ _reveal jsonb;
203
+ _scheduled jsonb := '[]'::jsonb;
204
+ BEGIN
205
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
206
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
207
+ END IF;
208
+
209
+ SELECT * INTO _page FROM public.pages
210
+ WHERE id = _page_id AND deleted_at IS NULL
211
+ FOR UPDATE;
212
+ IF _page.id IS NULL THEN
213
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
214
+ END IF;
215
+
216
+ _email := public.cms_actor_email(_uid, _actor_email);
217
+
218
+ SELECT COALESCE(MAX(version_no), 0) + 1 INTO _next_no
219
+ FROM public.page_versions WHERE page_id = _page_id;
220
+
221
+ INSERT INTO public.page_versions
222
+ (page_id, version_no, blocks, note, created_by, created_by_email)
223
+ VALUES
224
+ (_page_id, _next_no, COALESCE(_blocks, '[]'::jsonb), _note, _uid, _email)
225
+ RETURNING id INTO _version_id;
226
+
227
+ UPDATE public.pages
228
+ SET current_version_id = _version_id,
229
+ updated_at = now(),
230
+ updated_by = _uid
231
+ WHERE id = _page_id;
232
+
233
+ IF _publish THEN
234
+ UPDATE public.page_publications
235
+ SET is_active = false
236
+ WHERE page_id = _page_id AND is_active;
237
+
238
+ INSERT INTO public.page_publications (page_id, version_id, is_active, published_by)
239
+ VALUES (_page_id, _version_id, true, _uid);
240
+
241
+ UPDATE public.pages SET status = 'published' WHERE id = _page_id;
242
+
243
+ -- The audit row belongs to the same transaction as the publication it
244
+ -- describes. Publishing from the builder is the route most editors take,
245
+ -- and until now it was the one that left no trace.
246
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
247
+ VALUES (
248
+ _uid, _email, 'page.publish', 'pages', _page_id::text,
249
+ COALESCE(_audit, '{}'::jsonb)
250
+ || jsonb_build_object('key', _page.key, 'status', 'published',
251
+ 'versionNo', _next_no, 'versionId', _version_id)
252
+ );
253
+
254
+ /*
255
+ * Reveals are part of the publish, not a follow-up to it. Scheduling them
256
+ * here — through the very function the countdown path already used, so
257
+ * there is one implementation of a reveal and not two — means a target
258
+ * that cannot be scheduled takes the whole publish down with it instead of
259
+ * leaving a page live and reporting failure. A countdown naming a page
260
+ * that was renamed or never written to is an editing mistake rather than a
261
+ * failure: that function reports it scheduled nothing, and the save
262
+ * carries on.
263
+ */
264
+ FOR _reveal IN SELECT value FROM jsonb_array_elements(COALESCE(_reveals, '[]'::jsonb))
265
+ LOOP
266
+ _scheduled := _scheduled || jsonb_build_array(
267
+ jsonb_build_object('key', _reveal->>'key')
268
+ || public.cms_schedule_page_reveal(
269
+ _reveal->>'key',
270
+ (_reveal->>'publishAt')::timestamptz,
271
+ COALESCE(_audit, '{}'::jsonb)
272
+ || jsonb_build_object('description', _reveal->>'description'),
273
+ _actor_email,
274
+ -- Named, so a countdown pointing at the page being published is
275
+ -- refused instead of hiding it until its own deadline.
276
+ _page_id,
277
+ -- Publishing now means the countdown starts now, so a deadline
278
+ -- already past is refused here for the same reason it is on the
279
+ -- scheduled path: it would reveal the target before anyone
280
+ -- could see the countdown that was supposed to reveal it.
281
+ now()
282
+ )
283
+ );
284
+ END LOOP;
285
+ END IF;
286
+
287
+ RETURN jsonb_build_object(
288
+ 'versionId', _version_id,
289
+ 'versionNo', _next_no,
290
+ 'published', _publish,
291
+ 'reveals', _scheduled
292
+ );
293
+ END;
294
+ $$;
295
+
296
+ -- 4. One rule, in one place --------------------------------------------------
297
+ --
298
+ -- `cms_page_publication_json` was a second implementation of "what is public
299
+ -- right now", and not the same one: it discarded expired rows before choosing
300
+ -- a governing row, so an older publication could come back into force behind a
301
+ -- window that had closed — exactly the behaviour the single rule exists to
302
+ -- rule out. No caller ever read it. A second implementation that agrees today
303
+ -- is still a second implementation, so it goes.
304
+ CREATE OR REPLACE FUNCTION public.cms_set_page_status(
305
+ _page_id uuid,
306
+ _status text,
307
+ _audit jsonb DEFAULT '{}'::jsonb,
308
+ _actor_email text DEFAULT NULL
309
+ )
310
+ RETURNS jsonb
311
+ LANGUAGE plpgsql
312
+ SECURITY DEFINER
313
+ SET search_path = public, pg_temp
314
+ AS $$
315
+ DECLARE
316
+ _uid uuid := app.current_user_id();
317
+ _page public.pages;
318
+ _next text := lower(btrim(COALESCE(_status, '')));
319
+ BEGIN
320
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
321
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
322
+ END IF;
323
+ IF _next NOT IN ('draft', 'published') THEN
324
+ RAISE EXCEPTION 'Unknown page status %', _status USING ERRCODE = '22023';
325
+ END IF;
326
+
327
+ SELECT * INTO _page FROM public.pages
328
+ WHERE id = _page_id AND deleted_at IS NULL
329
+ FOR UPDATE;
330
+ IF _page.id IS NULL THEN
331
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
332
+ END IF;
333
+ IF _next = 'published' AND _page.current_version_id IS NULL THEN
334
+ RAISE EXCEPTION 'This page has no saved content yet, so there is nothing to publish.'
335
+ USING ERRCODE = '22023';
336
+ END IF;
337
+
338
+ UPDATE public.page_publications
339
+ SET is_active = false
340
+ WHERE page_id = _page_id AND is_active;
341
+
342
+ IF _next = 'published' THEN
343
+ INSERT INTO public.page_publications (page_id, version_id, is_active, published_by)
344
+ VALUES (_page_id, _page.current_version_id, true, _uid);
345
+ END IF;
346
+
347
+ UPDATE public.pages SET status = _next, updated_at = now(), updated_by = _uid
348
+ WHERE id = _page_id;
349
+
350
+ -- The audit row belongs to the same transaction: a publish nobody can account
351
+ -- for afterwards is its own kind of half-applied write.
352
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
353
+ VALUES (
354
+ _uid,
355
+ public.cms_actor_email(_uid, _actor_email),
356
+ CASE WHEN _next = 'published' THEN 'page.publish' ELSE 'page.unpublish' END,
357
+ 'pages',
358
+ _page_id::text,
359
+ COALESCE(_audit, '{}'::jsonb) || jsonb_build_object('key', _page.key, 'status', _next)
360
+ );
361
+
362
+ RETURN jsonb_build_object('status', _next, 'key', _page.key);
363
+ END;
364
+ $$;
365
+
366
+ DROP FUNCTION IF EXISTS public.cms_page_publication_json(uuid);
367
+
368
+ -- 5. A reveal reveals; it never hides ----------------------------------------
369
+ --
370
+ -- `cms_schedule_page_reveal` retired every active publication on its target
371
+ -- before writing the future one. On a page that is not public yet — the only
372
+ -- kind a countdown is meant to point at — there is nothing to retire and the
373
+ -- statement is a no-op. On any other page it is a silent unpublish:
374
+ --
375
+ -- * a countdown naming its own page retired the publication the very same
376
+ -- transaction had just written, so publishing the page hid it until the
377
+ -- deadline it was counting down to;
378
+ -- * a countdown naming a page that is live now took that page off the public
379
+ -- website, with no confirmation and nothing in the UI to suggest it might.
380
+ --
381
+ -- Both are editing mistakes rather than intentions, and both are refused here
382
+ -- rather than carried out. Refusing inside the transaction is what makes the
383
+ -- refusal safe to act on: the publish that carried the bad reveal rolls back
384
+ -- with it, so the editor is told what is wrong while the site still shows what
385
+ -- it showed a moment ago.
386
+ --
387
+ -- The signature grows by two, so the old one is dropped rather than replaced —
388
+ -- `CREATE OR REPLACE` with extra defaulted parameters leaves both installed and
389
+ -- a call by name then matches neither uniquely.
390
+ DROP FUNCTION IF EXISTS public.cms_schedule_page_reveal(text, timestamptz, jsonb, text);
391
+
392
+ CREATE OR REPLACE FUNCTION public.cms_schedule_page_reveal(
393
+ _page_key text,
394
+ _publish_at timestamptz,
395
+ _audit jsonb DEFAULT '{}'::jsonb,
396
+ _actor_email text DEFAULT NULL,
397
+ -- The page whose countdown asks for this, so a countdown pointed at its own
398
+ -- page can be recognised before anything is written.
399
+ _source_page_id uuid DEFAULT NULL,
400
+ -- When that page itself becomes public, for a reveal being scheduled
401
+ -- alongside a future publication. NULL when the source is going live now.
402
+ _source_publish_at timestamptz DEFAULT NULL
403
+ )
404
+ RETURNS jsonb
405
+ LANGUAGE plpgsql
406
+ SECURITY DEFINER
407
+ SET search_path = public, pg_temp
408
+ AS $$
409
+ DECLARE
410
+ _uid uuid := app.current_user_id();
411
+ _page public.pages;
412
+ _governing public.page_publications;
413
+ BEGIN
414
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
415
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
416
+ END IF;
417
+ IF _publish_at IS NULL THEN
418
+ RAISE EXCEPTION 'A reveal needs an instant to happen at' USING ERRCODE = '22023';
419
+ END IF;
420
+
421
+ SELECT * INTO _page FROM public.pages
422
+ WHERE key = btrim(_page_key) AND deleted_at IS NULL
423
+ FOR UPDATE;
424
+
425
+ -- A countdown may name a page that was renamed, deleted or never written to.
426
+ -- That is an editing mistake, not a database error: say nothing happened.
427
+ IF _page.id IS NULL OR _page.current_version_id IS NULL THEN
428
+ RETURN jsonb_build_object('scheduled', false, 'reason', 'no-such-page');
429
+ END IF;
430
+
431
+ -- Checked before any write, because the write this one would do is the one
432
+ -- that hides the page the editor is publishing.
433
+ IF _source_page_id IS NOT NULL AND _page.id = _source_page_id THEN
434
+ RAISE EXCEPTION
435
+ 'The countdown on "%" is set to reveal the page it is on. A page cannot '
436
+ 'reveal itself: point the countdown at another page, or change what it '
437
+ 'does when the timer reaches zero.', _page.key
438
+ USING ERRCODE = '22023';
439
+ END IF;
440
+
441
+ -- A reveal that fires before the page carrying its countdown appears would
442
+ -- publish the target while the countdown is still invisible.
443
+ IF _source_publish_at IS NOT NULL AND _publish_at <= _source_publish_at THEN
444
+ RAISE EXCEPTION
445
+ 'The countdown revealing "%" reaches zero at %, at or before the page '
446
+ 'carrying it goes live at %. Move the countdown deadline after the '
447
+ 'publication time.', _page.key, _publish_at, _source_publish_at
448
+ USING ERRCODE = '22023';
449
+ END IF;
450
+
451
+ -- The same rule the public reader uses: the newest active row whose start
452
+ -- has arrived governs, and the page is public while that row is open.
453
+ SELECT * INTO _governing FROM public.page_publications
454
+ WHERE page_id = _page.id AND is_active AND publish_at <= now()
455
+ ORDER BY publish_at DESC, published_at DESC
456
+ LIMIT 1;
457
+ IF _governing.id IS NOT NULL
458
+ AND (_governing.unpublish_at IS NULL OR _governing.unpublish_at > now()) THEN
459
+ RAISE EXCEPTION
460
+ 'The page "%" is already on the public website, so a countdown cannot '
461
+ 'reveal it. Unpublish it first, or point the countdown at a page that is '
462
+ 'still hidden.', _page.key
463
+ USING ERRCODE = '22023';
464
+ END IF;
465
+
466
+ -- Saving the same page twice must not pile up identical reveals.
467
+ IF EXISTS (
468
+ SELECT 1 FROM public.page_publications pp
469
+ WHERE pp.page_id = _page.id
470
+ AND pp.version_id = _page.current_version_id
471
+ AND pp.publish_at = _publish_at
472
+ AND pp.is_active
473
+ ) THEN
474
+ RETURN jsonb_build_object('scheduled', false, 'reason', 'already-scheduled',
475
+ 'pageId', _page.id);
476
+ END IF;
477
+
478
+ -- Whatever is retired here is either waiting or finished: a governing row
479
+ -- that is still open was refused above, so this cannot take a page offline.
480
+ UPDATE public.page_publications
481
+ SET is_active = false
482
+ WHERE page_id = _page.id AND is_active;
483
+
484
+ INSERT INTO public.page_publications
485
+ (page_id, version_id, publish_at, is_active, published_by)
486
+ VALUES
487
+ (_page.id, _page.current_version_id, _publish_at, true, _uid);
488
+
489
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
490
+ VALUES (
491
+ _uid, public.cms_actor_email(_uid, _actor_email), 'page.schedule', 'pages', _page.id::text,
492
+ COALESCE(_audit, '{}'::jsonb) || jsonb_build_object('publish_at', _publish_at)
493
+ );
494
+
495
+ RETURN jsonb_build_object('scheduled', true, 'pageId', _page.id);
496
+ END;
497
+ $$;
498
+
499
+ -- 6. Cancelling a schedule is not unpublishing a page ------------------------
500
+ --
501
+ -- The builder's Cancel button wrote `is_active = false` straight onto the row
502
+ -- through PostgREST, with no check of what that row was doing. Offered — as it
503
+ -- was — beside every active publication, including the one carrying the page
504
+ -- right now, it meant:
505
+ --
506
+ -- * cancelling the only governing row took the page off the public website
507
+ -- under a confirmation that said "Cancel this scheduled publication?";
508
+ -- * cancelling a governing row that had replaced an earlier one brought that
509
+ -- earlier version back, because the newest started row governs and the
510
+ -- newest had just been retired.
511
+ --
512
+ -- Cancelling may therefore only touch a publication that has not started. The
513
+ -- boundary is read from the database clock inside the transaction that does the
514
+ -- write, so a row that starts between the screen rendering and the button being
515
+ -- clicked is refused rather than raced: `now()` here is the transaction's own
516
+ -- start time, and the row is locked before it is judged.
517
+ --
518
+ -- Taking a live page offline is a different act with a different confirmation,
519
+ -- and it already has a function: `cms_set_page_status(_page_id, 'draft')`.
520
+ CREATE OR REPLACE FUNCTION public.cms_cancel_page_publication(
521
+ _publication_id uuid,
522
+ _audit jsonb DEFAULT '{}'::jsonb,
523
+ _actor_email text DEFAULT NULL
524
+ )
525
+ RETURNS jsonb
526
+ LANGUAGE plpgsql
527
+ SECURITY DEFINER
528
+ SET search_path = public, pg_temp
529
+ AS $$
530
+ DECLARE
531
+ _uid uuid := app.current_user_id();
532
+ _pub public.page_publications;
533
+ _page public.pages;
534
+ BEGIN
535
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
536
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
537
+ END IF;
538
+
539
+ SELECT * INTO _pub FROM public.page_publications
540
+ WHERE id = _publication_id
541
+ FOR UPDATE;
542
+ IF _pub.id IS NULL THEN
543
+ RAISE EXCEPTION 'Publication not found' USING ERRCODE = 'P0002';
544
+ END IF;
545
+
546
+ SELECT * INTO _page FROM public.pages WHERE id = _pub.page_id FOR UPDATE;
547
+
548
+ -- Cancelling twice is the same wish twice, not an error worth failing a
549
+ -- screen over. Nothing is written and nothing is logged.
550
+ IF NOT _pub.is_active THEN
551
+ RETURN jsonb_build_object('cancelled', false, 'reason', 'already-cancelled',
552
+ 'pageId', _pub.page_id);
553
+ END IF;
554
+
555
+ IF _pub.publish_at <= now() THEN
556
+ RAISE EXCEPTION
557
+ 'This publication has already started, so cancelling it would take "%" '
558
+ 'off the public website — possibly putting an older version back. Use '
559
+ 'Unpublish for that instead.', _page.key
560
+ USING ERRCODE = '22023';
561
+ END IF;
562
+
563
+ UPDATE public.page_publications
564
+ SET is_active = false
565
+ WHERE id = _publication_id;
566
+
567
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
568
+ VALUES (
569
+ _uid,
570
+ public.cms_actor_email(_uid, _actor_email),
571
+ 'page.schedule.cancel',
572
+ 'pages',
573
+ _pub.page_id::text,
574
+ COALESCE(_audit, '{}'::jsonb)
575
+ || jsonb_build_object('key', _page.key, 'publicationId', _publication_id,
576
+ 'publish_at', _pub.publish_at,
577
+ 'unpublish_at', _pub.unpublish_at)
578
+ );
579
+
580
+ RETURN jsonb_build_object('cancelled', true, 'pageId', _pub.page_id, 'key', _page.key);
581
+ END;
582
+ $$;
583
+
584
+ -- 7. A scheduled version carries its reveals too -----------------------------
585
+ --
586
+ -- Scheduling was a save with `publish = false` followed by a bare insert into
587
+ -- `page_publications`. Reveals were only ever read off a version being
588
+ -- published *now*, so a countdown on a version scheduled for next week
589
+ -- scheduled nothing: the source appeared on time and the page it promised to
590
+ -- reveal stayed hidden for good.
591
+ --
592
+ -- The publication, its reveals and the audit trail are written here in one
593
+ -- transaction, through the same reveal function every other path uses. A reveal
594
+ -- that cannot be honoured takes the schedule down with it, and the editor is
595
+ -- told before anything is committed.
596
+ CREATE OR REPLACE FUNCTION public.cms_schedule_page_version(
597
+ _version_id uuid,
598
+ _publish_at timestamptz,
599
+ _unpublish_at timestamptz DEFAULT NULL,
600
+ _note text DEFAULT NULL,
601
+ -- `[{ "key": "...", "publishAt": "...", "description": "..." }]`, read off
602
+ -- the blocks of the version being scheduled.
603
+ _reveals jsonb DEFAULT '[]'::jsonb,
604
+ _audit jsonb DEFAULT '{}'::jsonb,
605
+ _actor_email text DEFAULT NULL
606
+ )
607
+ RETURNS jsonb
608
+ LANGUAGE plpgsql
609
+ SECURITY DEFINER
610
+ SET search_path = public, pg_temp
611
+ AS $$
612
+ DECLARE
613
+ _uid uuid := app.current_user_id();
614
+ _version public.page_versions;
615
+ _page public.pages;
616
+ _publication_id uuid;
617
+ _reveal jsonb;
618
+ _scheduled jsonb := '[]'::jsonb;
619
+ BEGIN
620
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
621
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
622
+ END IF;
623
+
624
+ -- The browser control offers a wall clock and a calendar; nothing stops a
625
+ -- caller sending something else, and a window that ends before it begins is
626
+ -- a page that is never public and a schedule nobody can read.
627
+ IF _publish_at IS NULL THEN
628
+ RAISE EXCEPTION 'A scheduled publication needs a time to start at.'
629
+ USING ERRCODE = '22023';
630
+ END IF;
631
+ IF _unpublish_at IS NOT NULL AND _unpublish_at <= _publish_at THEN
632
+ RAISE EXCEPTION
633
+ 'A publication window has to end after it starts: % is not after %.',
634
+ _unpublish_at, _publish_at
635
+ USING ERRCODE = '22023';
636
+ END IF;
637
+
638
+ SELECT * INTO _version FROM public.page_versions WHERE id = _version_id;
639
+ IF _version.id IS NULL THEN
640
+ RAISE EXCEPTION 'Version not found' USING ERRCODE = 'P0002';
641
+ END IF;
642
+
643
+ -- The page is derived from the version and locked, so a schedule can only
644
+ -- ever be written against the page the version belongs to.
645
+ SELECT * INTO _page FROM public.pages
646
+ WHERE id = _version.page_id AND deleted_at IS NULL
647
+ FOR UPDATE;
648
+ IF _page.id IS NULL THEN
649
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
650
+ END IF;
651
+
652
+ INSERT INTO public.page_publications
653
+ (page_id, version_id, publish_at, unpublish_at, is_active, published_by, note)
654
+ VALUES
655
+ (_page.id, _version.id, _publish_at, _unpublish_at, true, _uid, _note)
656
+ RETURNING id INTO _publication_id;
657
+
658
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
659
+ VALUES (
660
+ _uid,
661
+ public.cms_actor_email(_uid, _actor_email),
662
+ 'page.schedule',
663
+ 'pages',
664
+ _page.id::text,
665
+ COALESCE(_audit, '{}'::jsonb)
666
+ || jsonb_build_object('key', _page.key, 'versionId', _version.id,
667
+ 'publicationId', _publication_id,
668
+ 'publish_at', _publish_at, 'unpublish_at', _unpublish_at)
669
+ );
670
+
671
+ FOR _reveal IN SELECT value FROM jsonb_array_elements(COALESCE(_reveals, '[]'::jsonb))
672
+ LOOP
673
+ _scheduled := _scheduled || jsonb_build_array(
674
+ jsonb_build_object('key', _reveal->>'key')
675
+ || public.cms_schedule_page_reveal(
676
+ _reveal->>'key',
677
+ (_reveal->>'publishAt')::timestamptz,
678
+ COALESCE(_audit, '{}'::jsonb)
679
+ || jsonb_build_object('description', _reveal->>'description'),
680
+ _actor_email,
681
+ _page.id,
682
+ _publish_at
683
+ )
684
+ );
685
+ END LOOP;
686
+
687
+ RETURN jsonb_build_object(
688
+ 'publicationId', _publication_id,
689
+ 'pageId', _page.id,
690
+ 'versionId', _version.id,
691
+ 'reveals', _scheduled
692
+ );
693
+ END;
694
+ $$;
695
+
696
+ -- 8. Who may call these ------------------------------------------------------
697
+ --
698
+ -- Same rule as the previous migration: taking the grant away from PUBLIC is
699
+ -- plain SQL and runs anywhere, while `anon`, `authenticated` and
700
+ -- `service_role` are PostgREST deployment conventions that a plain PostgreSQL
701
+ -- server does not have. Naming a role that does not exist is an error, not a
702
+ -- warning, so each of those statements runs only if the role is there.
703
+ --
704
+ -- A self-hosted installation grants EXECUTE to the role its application
705
+ -- connects as, in a migration of its own:
706
+ --
707
+ -- GRANT EXECUTE ON FUNCTION
708
+ -- public.cms_save_page_version(uuid, jsonb, text, boolean, text, jsonb, jsonb)
709
+ -- TO cms_app;
710
+ --
711
+ -- `docs/PAGES.md` lists every statement.
712
+ REVOKE ALL ON FUNCTION
713
+ public.cms_save_page_version(uuid, jsonb, text, boolean, text, jsonb, jsonb) FROM PUBLIC;
714
+ REVOKE ALL ON FUNCTION public.cms_soft_delete_page(uuid, jsonb, text) FROM PUBLIC;
715
+ REVOKE ALL ON FUNCTION public.cms_restore_page(uuid, jsonb, text) FROM PUBLIC;
716
+ -- Re-stated for the widened signature: the previous migration's REVOKE named
717
+ -- the four-argument function, which no longer exists.
718
+ REVOKE ALL ON FUNCTION
719
+ public.cms_schedule_page_reveal(text, timestamptz, jsonb, text, uuid, timestamptz)
720
+ FROM PUBLIC;
721
+ REVOKE ALL ON FUNCTION public.cms_cancel_page_publication(uuid, jsonb, text) FROM PUBLIC;
722
+ REVOKE ALL ON FUNCTION
723
+ public.cms_schedule_page_version(uuid, timestamptz, timestamptz, text, jsonb, jsonb, text)
724
+ FROM PUBLIC;
725
+
726
+ DO $$
727
+ DECLARE
728
+ _signature text;
729
+ _role text;
730
+ _writers text[] := ARRAY[
731
+ 'public.cms_save_page_version(uuid, jsonb, text, boolean, text, jsonb, jsonb)',
732
+ 'public.cms_soft_delete_page(uuid, jsonb, text)',
733
+ 'public.cms_restore_page(uuid, jsonb, text)',
734
+ 'public.cms_schedule_page_reveal(text, timestamptz, jsonb, text, uuid, timestamptz)',
735
+ 'public.cms_cancel_page_publication(uuid, jsonb, text)',
736
+ 'public.cms_schedule_page_version(uuid, timestamptz, timestamptz, text, jsonb, jsonb, text)'
737
+ ];
738
+ BEGIN
739
+ FOREACH _role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] LOOP
740
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = _role) THEN
741
+ CONTINUE;
742
+ END IF;
743
+ FOREACH _signature IN ARRAY _writers LOOP
744
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM %I', _signature, _role);
745
+ IF _role <> 'anon' THEN
746
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO %I', _signature, _role);
747
+ END IF;
748
+ END LOOP;
749
+ END LOOP;
750
+ END
751
+ $$;