@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,86 @@
1
+ # Menu and footer typography
2
+
3
+ The menu and the footer each choose their own font, independently. Everything
4
+ else on the site keeps using the theme tokens.
5
+
6
+ ## The model
7
+
8
+ ```ts
9
+ type FontChoice =
10
+ | { source: "theme"; token?: "sans" | "serif" }
11
+ | { source: "google"; family: string; weights?: number[] };
12
+
13
+ type SiteTypography = Partial<Record<"menu" | "footer", FontChoice>>;
14
+ ```
15
+
16
+ Two slots, `menu` and `footer`, and either can be left alone. The default is
17
+ `{}` — no choice recorded — and `resolveTypography` maps an absent slot to its
18
+ theme fallback (`TYPOGRAPHY_SLOTS`). This is what makes the feature backwards
19
+ compatible: an existing site has no `typography` key in its config, resolves to
20
+ the theme fonts, and looks *exactly* as it did before the upgrade.
21
+
22
+ No font is pre-selected. In particular **DM Serif Display is not switched on for
23
+ anyone** — it is available in the picker like every other family, and a site that
24
+ wants it opts in.
25
+
26
+ The value is stored in the existing site-config JSONB. There is no new table and
27
+ no new migration for typography; `parseSiteTypography` validates whatever comes
28
+ back and falls through to the theme on anything it does not recognise, so a
29
+ hand-edited or downgraded config cannot break rendering.
30
+
31
+ ## No API key in the browser
32
+
33
+ Google's font *catalogue* API needs a key. The picker does not use it.
34
+
35
+ `GOOGLE_FONT_CATALOG` in `src/site/google-fonts.ts` is a static list compiled into
36
+ the package, and `searchGoogleFonts(query, limit)` filters it in memory. The
37
+ selector is searchable, fast, works offline, and requires no key anywhere —
38
+ frontend or backend.
39
+
40
+ The trade is that the catalogue is a curated subset rather than all ~1,700
41
+ families. That is the right trade for a font picker in a CMS: shipping a key to
42
+ the browser to make the list longer is not a good exchange, and neither is a
43
+ server round trip per keystroke.
44
+
45
+ ## Loading
46
+
47
+ `googleFontsCssUrl` builds one `fonts.googleapis.com/css2` URL for *all* requested
48
+ families, so choosing different fonts for the menu and the footer is one stylesheet
49
+ request, not two. `display=swap` is always set — text renders in the fallback
50
+ immediately rather than staying invisible while the font downloads.
51
+
52
+ `googleFontsHeadLinks` returns the link descriptors — the two preconnects Google
53
+ recommends plus the stylesheet — for a host that builds its `<head>` itself. In a
54
+ React 19 tree, `SiteFontLinks` does the same job through the framework:
55
+ `preconnect()` from `react-dom` (which de-duplicates by origin) and a
56
+ `<link rel="stylesheet" precedence="high">` that React hoists and de-duplicates.
57
+
58
+ That stylesheet sits inside **its own `<Suspense>` boundary**, and that is not
59
+ decoration. A `precedence` stylesheet suspends whatever boundary contains it
60
+ until the file has loaded — so without the dedicated boundary, picking a menu
61
+ font would delay the entire page's first paint on the font request.
62
+
63
+ `sanitizeFontFamily` is applied before a family name ever reaches a URL or a CSS
64
+ declaration. A family name is editor input, and editor input does not get
65
+ concatenated into CSS unchecked.
66
+
67
+ When both slots are on theme fonts, `googleFontsCssUrl` returns `null` and
68
+ **nothing is requested at all**. A site that does not use the feature pays
69
+ nothing for it.
70
+
71
+ ## Applying the font
72
+
73
+ `fontStyle(font)` returns an inline `fontFamily` for the slot's own element —
74
+ not a global override and not a change to the theme tokens. The menu's font
75
+ applies to the menu and stops there, and `--font-sans` / `--font-serif` are never
76
+ reassigned, which is why nothing else on the page shifts when an editor picks a
77
+ menu font.
78
+
79
+ For a slot left on the theme, `fontStyle` returns **`undefined`** rather than a
80
+ style naming the current theme token. That distinction is the backwards
81
+ compatibility guarantee in one line: an un-configured site emits no inline style
82
+ at all and is pixel-identical to before, instead of merely computing to something
83
+ that looks the same today.
84
+
85
+ Each resolved font carries a real fallback stack, so the layout is stable before
86
+ the webfont arrives and readable if it never does.
@@ -0,0 +1,33 @@
1
+ -- Draft / published pages ------------------------------------------------
2
+ --
3
+ -- Publishing became an explicit, confirmed action in the backoffice, so
4
+ -- `pages.status` went from a mostly-decorative column to the gate the public
5
+ -- route, the sitemap and every other public discovery path check. This
6
+ -- migration makes that read cheap and writes down what the column means.
7
+ --
8
+ -- Deliberately additive. Existing rows are NOT touched: a page that is live
9
+ -- today stays live after the upgrade, and no site loses content because a
10
+ -- migration decided its pages should be drafts. New pages default to 'draft'
11
+ -- (as the original schema already declared), which is what makes a freshly
12
+ -- created — or freshly duplicated — page invisible until somebody publishes it.
13
+
14
+ -- Restated rather than changed, so a database restored from an older baseline
15
+ -- ends up with the same default as a fresh install.
16
+ ALTER TABLE public.pages ALTER COLUMN status SET DEFAULT 'draft';
17
+
18
+ COMMENT ON COLUMN public.pages.status IS
19
+ 'draft | published. A page is served publicly only when it is published, or '
20
+ 'while a page_publications window is open for it. Unpublishing sets this back '
21
+ 'to draft and deactivates those rows.';
22
+
23
+ -- Public route discovery lists published, undeleted pages ordered by key
24
+ -- (see listPublishedPageKeysCore). A partial index keeps drafts and trashed
25
+ -- rows out of it entirely, so the index stays small on sites with many drafts.
26
+ CREATE INDEX IF NOT EXISTS pages_published_key_idx
27
+ ON public.pages (key)
28
+ WHERE deleted_at IS NULL AND status = 'published';
29
+
30
+ -- The Pages screen lists newest-first within a status filter.
31
+ CREATE INDEX IF NOT EXISTS pages_status_updated_at_idx
32
+ ON public.pages (status, updated_at DESC)
33
+ WHERE deleted_at IS NULL;
@@ -0,0 +1,383 @@
1
+ -- One authoritative publication model ------------------------------------
2
+ --
3
+ -- Before this migration two things decided whether a page was public:
4
+ -- `pages.status` and the rows in `page_publications`. The public reader
5
+ -- consulted both and served a page when *either* said yes, which quietly broke
6
+ -- every scheduled state built on top of it. A countdown target went live the
7
+ -- moment it was scheduled instead of at its deadline; a version scheduled for
8
+ -- next week appeared at once on an already-published page; an `unpublish_at`
9
+ -- that had passed hid nothing, because the status column still said published.
10
+ --
11
+ -- From here `page_publications` alone decides what the public sees. The rule is
12
+ -- one sentence: the newest active row whose `publish_at` has arrived governs,
13
+ -- and the page is public while that row's `unpublish_at` has not. An older row
14
+ -- never comes back into force behind a newer one — that is what makes
15
+ -- "invisible at and after `unpublish_at`" true rather than "invisible until the
16
+ -- previous publication takes over again".
17
+ --
18
+ -- `pages.status` keeps its editorial meaning (is this page finished?) and still
19
+ -- drives the backoffice, but nothing public reads it any more.
20
+
21
+ -- 1. Backfill --------------------------------------------------------------
22
+ --
23
+ -- Pages published before publication rows existed, and pages whose rows were
24
+ -- retired by an older code path, have no active publication. They are public
25
+ -- today and must stay public, so give each one an open publication naming the
26
+ -- version it is already serving.
27
+ --
28
+ -- Pages that *do* have an active row are left alone on purpose. A page with a
29
+ -- single row scheduled for next Tuesday is deliberately scheduled: writing an
30
+ -- immediate publication next to it would publish it now, which is precisely the
31
+ -- countdown leak this migration exists to close.
32
+ INSERT INTO public.page_publications (page_id, version_id, publish_at, is_active, published_by, note)
33
+ SELECT
34
+ p.id,
35
+ p.current_version_id,
36
+ now(),
37
+ true,
38
+ p.updated_by,
39
+ 'Backfilled when publication rows became the single source of truth for public visibility.'
40
+ FROM public.pages p
41
+ WHERE p.deleted_at IS NULL
42
+ AND p.status = 'published'
43
+ AND p.current_version_id IS NOT NULL
44
+ AND NOT EXISTS (
45
+ SELECT 1 FROM public.page_publications pp
46
+ WHERE pp.page_id = p.id AND pp.is_active
47
+ );
48
+
49
+ -- The public reader asks for one page's active rows newest-first. The existing
50
+ -- `page_publications_active_idx` finds the page; this one also gives the order.
51
+ CREATE INDEX IF NOT EXISTS page_publications_page_publish_at_idx
52
+ ON public.page_publications (page_id, publish_at DESC)
53
+ WHERE is_active;
54
+
55
+ COMMENT ON TABLE public.page_publications IS
56
+ 'Publication windows. The newest active row whose publish_at has arrived decides which version the public sees, and the page is public until that row''s unpublish_at. pages.status is editorial state only and is not consulted by the public reader.';
57
+
58
+ -- Restated, because the previous migration's comment describes the two-source
59
+ -- model this one replaces and a stale comment on a column this sensitive is
60
+ -- worse than none.
61
+ COMMENT ON COLUMN public.pages.status IS
62
+ 'draft | published. Editorial state: is this page finished? It does not make a page public — an open row in page_publications does, and unpublishing retires those rows as well as setting this back to draft.';
63
+
64
+ -- 2. Atomic visibility transitions -----------------------------------------
65
+ --
66
+ -- Publishing is several statements — retire the old publication, write the new
67
+ -- one, move the current-version pointer, flip the status. Run from a PostgREST
68
+ -- client they are four independent round trips, and a failure in the middle
69
+ -- leaves the site in a state nobody asked for: an active publication with the
70
+ -- status still draft, or worse, a retired publication and a status that says
71
+ -- published. Reporting the failure honestly (which the previous commit does)
72
+ -- stops the lie but not the damage.
73
+ --
74
+ -- These functions put the whole transition in one transaction. Either the page
75
+ -- changes what it shows the public or it does not; there is no half of it, and
76
+ -- no compensation logic in JavaScript to get wrong.
77
+
78
+ -- Everything the public reader would need to re-derive after a write, so the
79
+ -- caller can invalidate precisely and the audit trail records what happened.
80
+ CREATE OR REPLACE FUNCTION public.cms_page_publication_json(_page_id uuid)
81
+ RETURNS jsonb
82
+ LANGUAGE sql
83
+ STABLE
84
+ SET search_path = public, pg_temp
85
+ AS $$
86
+ SELECT jsonb_build_object(
87
+ 'pageId', _page_id,
88
+ 'versionId', (SELECT pp.version_id
89
+ FROM public.page_publications pp
90
+ WHERE pp.page_id = _page_id
91
+ AND pp.is_active
92
+ AND pp.publish_at <= now()
93
+ AND (pp.unpublish_at IS NULL OR pp.unpublish_at > now())
94
+ ORDER BY pp.publish_at DESC, pp.published_at DESC
95
+ LIMIT 1),
96
+ 'now', now()
97
+ )
98
+ $$;
99
+
100
+ -- Who to name in version history and the audit log.
101
+ --
102
+ -- The server's own record wins; the caller's copy only fills the gap for an
103
+ -- installation that signs people in without mirroring them into `app_users`.
104
+ -- Display data may be completed by a caller, never overwritten by one.
105
+ CREATE OR REPLACE FUNCTION public.cms_actor_email(_uid uuid, _fallback text)
106
+ RETURNS text
107
+ LANGUAGE sql
108
+ STABLE
109
+ SECURITY DEFINER
110
+ SET search_path = public, pg_temp
111
+ AS $$
112
+ SELECT COALESCE(
113
+ (SELECT NULLIF(btrim(au.email), '') FROM public.app_users au WHERE au.id = _uid),
114
+ NULLIF(btrim(_fallback), '')
115
+ )
116
+ $$;
117
+
118
+ -- Save a new version, and optionally publish it, in one go.
119
+ --
120
+ -- The page row is locked first, so two editors saving at the same moment cannot
121
+ -- pick the same `version_no` — the unique constraint would refuse the second
122
+ -- one anyway, but the lock turns a confusing constraint error into an orderly
123
+ -- queue of two saves.
124
+ CREATE OR REPLACE FUNCTION public.cms_save_page_version(
125
+ _page_id uuid,
126
+ _blocks jsonb,
127
+ _note text DEFAULT NULL,
128
+ _publish boolean DEFAULT false,
129
+ _actor_email text DEFAULT NULL
130
+ )
131
+ RETURNS jsonb
132
+ LANGUAGE plpgsql
133
+ SECURITY DEFINER
134
+ SET search_path = public, pg_temp
135
+ AS $$
136
+ DECLARE
137
+ _uid uuid := app.current_user_id();
138
+ _email text;
139
+ _page public.pages;
140
+ _next_no integer;
141
+ _version_id uuid;
142
+ BEGIN
143
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
144
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
145
+ END IF;
146
+
147
+ SELECT * INTO _page FROM public.pages
148
+ WHERE id = _page_id AND deleted_at IS NULL
149
+ FOR UPDATE;
150
+ IF _page.id IS NULL THEN
151
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
152
+ END IF;
153
+
154
+ _email := public.cms_actor_email(_uid, _actor_email);
155
+
156
+ SELECT COALESCE(MAX(version_no), 0) + 1 INTO _next_no
157
+ FROM public.page_versions WHERE page_id = _page_id;
158
+
159
+ INSERT INTO public.page_versions
160
+ (page_id, version_no, blocks, note, created_by, created_by_email)
161
+ VALUES
162
+ (_page_id, _next_no, COALESCE(_blocks, '[]'::jsonb), _note, _uid, _email)
163
+ RETURNING id INTO _version_id;
164
+
165
+ UPDATE public.pages
166
+ SET current_version_id = _version_id,
167
+ updated_at = now(),
168
+ updated_by = _uid
169
+ WHERE id = _page_id;
170
+
171
+ IF _publish THEN
172
+ UPDATE public.page_publications
173
+ SET is_active = false
174
+ WHERE page_id = _page_id AND is_active;
175
+
176
+ INSERT INTO public.page_publications (page_id, version_id, is_active, published_by)
177
+ VALUES (_page_id, _version_id, true, _uid);
178
+
179
+ UPDATE public.pages SET status = 'published' WHERE id = _page_id;
180
+ END IF;
181
+
182
+ RETURN jsonb_build_object(
183
+ 'versionId', _version_id,
184
+ 'versionNo', _next_no,
185
+ 'published', _publish,
186
+ 'public', public.cms_page_publication_json(_page_id)
187
+ );
188
+ END;
189
+ $$;
190
+
191
+ -- Publish or unpublish a page that already has content.
192
+ --
193
+ -- Publishing retires whatever was live or scheduled and opens one window on the
194
+ -- current version, starting now and with no end. Unpublishing retires
195
+ -- everything and opens nothing, so the page goes dark the instant this commits.
196
+ CREATE OR REPLACE FUNCTION public.cms_set_page_status(
197
+ _page_id uuid,
198
+ _status text,
199
+ _audit jsonb DEFAULT '{}'::jsonb,
200
+ _actor_email text DEFAULT NULL
201
+ )
202
+ RETURNS jsonb
203
+ LANGUAGE plpgsql
204
+ SECURITY DEFINER
205
+ SET search_path = public, pg_temp
206
+ AS $$
207
+ DECLARE
208
+ _uid uuid := app.current_user_id();
209
+ _page public.pages;
210
+ _next text := lower(btrim(COALESCE(_status, '')));
211
+ BEGIN
212
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
213
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
214
+ END IF;
215
+ IF _next NOT IN ('draft', 'published') THEN
216
+ RAISE EXCEPTION 'Unknown page status %', _status USING ERRCODE = '22023';
217
+ END IF;
218
+
219
+ SELECT * INTO _page FROM public.pages
220
+ WHERE id = _page_id AND deleted_at IS NULL
221
+ FOR UPDATE;
222
+ IF _page.id IS NULL THEN
223
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
224
+ END IF;
225
+ IF _next = 'published' AND _page.current_version_id IS NULL THEN
226
+ RAISE EXCEPTION 'This page has no saved content yet, so there is nothing to publish.'
227
+ USING ERRCODE = '22023';
228
+ END IF;
229
+
230
+ UPDATE public.page_publications
231
+ SET is_active = false
232
+ WHERE page_id = _page_id AND is_active;
233
+
234
+ IF _next = 'published' THEN
235
+ INSERT INTO public.page_publications (page_id, version_id, is_active, published_by)
236
+ VALUES (_page_id, _page.current_version_id, true, _uid);
237
+ END IF;
238
+
239
+ UPDATE public.pages SET status = _next, updated_at = now(), updated_by = _uid
240
+ WHERE id = _page_id;
241
+
242
+ -- The audit row belongs to the same transaction: a publish nobody can account
243
+ -- for afterwards is its own kind of half-applied write.
244
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
245
+ VALUES (
246
+ _uid,
247
+ public.cms_actor_email(_uid, _actor_email),
248
+ CASE WHEN _next = 'published' THEN 'page.publish' ELSE 'page.unpublish' END,
249
+ 'pages',
250
+ _page_id::text,
251
+ COALESCE(_audit, '{}'::jsonb) || jsonb_build_object('key', _page.key, 'status', _next)
252
+ );
253
+
254
+ RETURN jsonb_build_object(
255
+ 'status', _next,
256
+ 'key', _page.key,
257
+ 'public', public.cms_page_publication_json(_page_id)
258
+ );
259
+ END;
260
+ $$;
261
+
262
+ -- Schedule a page to appear at a given instant, replacing whatever was live or
263
+ -- already scheduled for it. Used by countdown blocks set to reveal a page.
264
+ --
265
+ -- `pages.status` is deliberately untouched: the page becomes public when the
266
+ -- window opens, not when somebody schedules it, and the backoffice should go on
267
+ -- describing it as the draft it still is until then.
268
+ CREATE OR REPLACE FUNCTION public.cms_schedule_page_reveal(
269
+ _page_key text,
270
+ _publish_at timestamptz,
271
+ _audit jsonb DEFAULT '{}'::jsonb,
272
+ _actor_email text DEFAULT NULL
273
+ )
274
+ RETURNS jsonb
275
+ LANGUAGE plpgsql
276
+ SECURITY DEFINER
277
+ SET search_path = public, pg_temp
278
+ AS $$
279
+ DECLARE
280
+ _uid uuid := app.current_user_id();
281
+ _page public.pages;
282
+ BEGIN
283
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
284
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
285
+ END IF;
286
+ IF _publish_at IS NULL THEN
287
+ RAISE EXCEPTION 'A reveal needs an instant to happen at' USING ERRCODE = '22023';
288
+ END IF;
289
+
290
+ SELECT * INTO _page FROM public.pages
291
+ WHERE key = btrim(_page_key) AND deleted_at IS NULL
292
+ FOR UPDATE;
293
+
294
+ -- A countdown may name a page that was renamed, deleted or never written to.
295
+ -- That is an editing mistake, not a database error: say nothing happened.
296
+ IF _page.id IS NULL OR _page.current_version_id IS NULL THEN
297
+ RETURN jsonb_build_object('scheduled', false, 'reason', 'no-such-page');
298
+ END IF;
299
+
300
+ -- Saving the same page twice must not pile up identical reveals.
301
+ IF EXISTS (
302
+ SELECT 1 FROM public.page_publications pp
303
+ WHERE pp.page_id = _page.id
304
+ AND pp.version_id = _page.current_version_id
305
+ AND pp.publish_at = _publish_at
306
+ AND pp.is_active
307
+ ) THEN
308
+ RETURN jsonb_build_object('scheduled', false, 'reason', 'already-scheduled',
309
+ 'pageId', _page.id);
310
+ END IF;
311
+
312
+ UPDATE public.page_publications
313
+ SET is_active = false
314
+ WHERE page_id = _page.id AND is_active;
315
+
316
+ INSERT INTO public.page_publications
317
+ (page_id, version_id, publish_at, is_active, published_by)
318
+ VALUES
319
+ (_page.id, _page.current_version_id, _publish_at, true, _uid);
320
+
321
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
322
+ VALUES (
323
+ _uid, public.cms_actor_email(_uid, _actor_email), 'page.schedule', 'pages', _page.id::text,
324
+ COALESCE(_audit, '{}'::jsonb) || jsonb_build_object('publish_at', _publish_at)
325
+ );
326
+
327
+ RETURN jsonb_build_object('scheduled', true, 'pageId', _page.id);
328
+ END;
329
+ $$;
330
+
331
+ -- Who may call these -------------------------------------------------------
332
+ --
333
+ -- Definer functions that decide what the public website shows are not
334
+ -- something to leave on PUBLIC's default grant, and none of them is for
335
+ -- anonymous callers. The bodies check `is_staff` regardless; this is the
336
+ -- second lock.
337
+ --
338
+ -- Taking the grant away from PUBLIC is plain SQL and runs anywhere. The role
339
+ -- names below are not: `anon`, `authenticated` and `service_role` are
340
+ -- PostgREST deployment conventions, and this package also runs on a plain
341
+ -- PostgreSQL server where none of them exists — where `REVOKE ... FROM anon`
342
+ -- is not a warning but an error that stops the migration. So every statement
343
+ -- naming one of them is executed only if that role is actually present.
344
+ --
345
+ -- A self-hosted installation grants EXECUTE to whichever role its application
346
+ -- connects as, in its own migration. `docs/PAGES.md` lists the statements.
347
+ REVOKE ALL ON FUNCTION public.cms_page_publication_json(uuid) FROM PUBLIC;
348
+ REVOKE ALL ON FUNCTION public.cms_actor_email(uuid, text) FROM PUBLIC;
349
+ REVOKE ALL ON FUNCTION public.cms_save_page_version(uuid, jsonb, text, boolean, text) FROM PUBLIC;
350
+ REVOKE ALL ON FUNCTION public.cms_set_page_status(uuid, text, jsonb, text) FROM PUBLIC;
351
+ REVOKE ALL ON FUNCTION public.cms_schedule_page_reveal(text, timestamptz, jsonb, text) FROM PUBLIC;
352
+
353
+ DO $$
354
+ DECLARE
355
+ _signature text;
356
+ _role text;
357
+ -- Helpers first: nobody calls these directly, in any deployment.
358
+ _internal text[] := ARRAY[
359
+ 'public.cms_page_publication_json(uuid)',
360
+ 'public.cms_actor_email(uuid, text)'
361
+ ];
362
+ -- The writes the backoffice makes, as the signed-in editor.
363
+ _writers text[] := ARRAY[
364
+ 'public.cms_save_page_version(uuid, jsonb, text, boolean, text)',
365
+ 'public.cms_set_page_status(uuid, text, jsonb, text)',
366
+ 'public.cms_schedule_page_reveal(text, timestamptz, jsonb, text)'
367
+ ];
368
+ BEGIN
369
+ FOREACH _role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] LOOP
370
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = _role) THEN
371
+ CONTINUE;
372
+ END IF;
373
+ FOREACH _signature IN ARRAY _internal || _writers LOOP
374
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM %I', _signature, _role);
375
+ END LOOP;
376
+ IF _role <> 'anon' THEN
377
+ FOREACH _signature IN ARRAY _writers LOOP
378
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO %I', _signature, _role);
379
+ END LOOP;
380
+ END IF;
381
+ END LOOP;
382
+ END
383
+ $$;