@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
|
@@ -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
|
+
$$;
|