@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,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
|
+
$$;
|