@bygghammar/cms-platform 0.94.0 → 0.96.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 +146 -0
- package/docs/PAGES.md +72 -4
- package/package.json +8 -1
- package/src/admin/describe-log.ts +3 -0
- package/src/backoffice/BookingSettingsCard.tsx +252 -0
- package/src/backoffice/NavigationView.tsx +38 -0
- package/src/backoffice/PageBuilderView.tsx +149 -3
- package/src/backoffice/PageEditorView.tsx +47 -24
- package/src/backoffice/PagesListView.tsx +173 -57
- package/src/backoffice/SiteSettingsView.tsx +4 -0
- package/src/backoffice/StatusBadge.tsx +38 -2
- package/src/backoffice/media/ConfirmDialog.tsx +19 -1
- package/src/backoffice/routes/host.ts +24 -0
- package/src/backoffice/routes/index.tsx +54 -0
- package/src/backoffice/routes/pages.tsx +13 -0
- package/src/cms/booking-links.ts +176 -0
- package/src/cms/page-publication.ts +11 -6
- package/src/cms/page-visibility.ts +206 -0
- package/src/forms/form-audit.ts +22 -2
- package/src/html/html-tokenizer.ts +254 -0
- package/src/html/sanitize-html.ts +72 -37
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/runtime.tsx +155 -4
- package/src/server/forms-core.ts +9 -0
- package/src/server/forms-delivery-core.ts +29 -22
- package/src/server/forms-submissions-core.ts +8 -5
- package/src/server/media-usage-core.ts +53 -13
- package/src/server/outbound-address.ts +131 -0
- package/src/server/outbound-http.ts +565 -0
- package/src/server/page-visibility-core.ts +450 -0
- package/src/server/pages-core.ts +238 -9
- package/src/server/popup-optin-webhook-core.ts +79 -140
- package/src/server/published-cache.ts +23 -3
- package/src/server/schemas.ts +17 -0
- package/src/site/PageRenderer.tsx +34 -5
- package/src/site/SiteFooter.tsx +11 -2
- package/src/site/SiteHeader.tsx +41 -21
- package/src/site/link-visibility.tsx +28 -0
- package/src/site/preview-messaging.ts +232 -0
- package/src/site/route-aliases.ts +74 -0
- package/src/site/routes/preview-page.tsx +75 -31
- package/src/site/routes/public-page.tsx +81 -9
- package/src/util/redact-url.ts +18 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page listing, the booking switch and its exception pages: storage, staff
|
|
3
|
+
* writes and the public policy read.
|
|
4
|
+
*
|
|
5
|
+
* Stored in the existing `site_settings` key/value table, one row per decision,
|
|
6
|
+
* so no migration is needed and two editors changing two different pages never
|
|
7
|
+
* touch the same row:
|
|
8
|
+
*
|
|
9
|
+
* | key | value | absent means |
|
|
10
|
+
* |-------------------------------|--------------------------------|----------------|
|
|
11
|
+
* | `page.listing.<page uuid>` | `{ "listing": "link-only" }` | listed |
|
|
12
|
+
* | `booking` | `{ "enabled": false }` | booking on |
|
|
13
|
+
* | `booking.exception.<uuid>` | `{ "exception": true }` | no exception |
|
|
14
|
+
*
|
|
15
|
+
* Keys use the page's id, never its key or address, so every alias and locale
|
|
16
|
+
* of a page shares one decision, a restored page keeps its own, and a page
|
|
17
|
+
* purged and recreated under the same key starts fresh.
|
|
18
|
+
*
|
|
19
|
+
* Every write is a compare-and-swap on the row's `updated_at` (which the
|
|
20
|
+
* table's trigger moves on each update): the editor sends the version it was
|
|
21
|
+
* shown, and a write against anything else is refused as a conflict instead of
|
|
22
|
+
* silently replacing a colleague's change. A first write inserts and refuses
|
|
23
|
+
* if someone else's insert got there first.
|
|
24
|
+
*
|
|
25
|
+
* None of these rows is an `OVERRIDABLE_SETTING_KEYS` key, so the generic
|
|
26
|
+
* settings save and reset cannot read, overwrite or erase them.
|
|
27
|
+
*/
|
|
28
|
+
import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
|
|
29
|
+
import {
|
|
30
|
+
nextPublicationBoundary,
|
|
31
|
+
publicVersionId,
|
|
32
|
+
toPublicationWindows,
|
|
33
|
+
type PublicationWindow,
|
|
34
|
+
} from "../cms/page-publication";
|
|
35
|
+
import {
|
|
36
|
+
normalizePageListing,
|
|
37
|
+
type PageListing,
|
|
38
|
+
type PolicyPage,
|
|
39
|
+
type PublicPolicySnapshot,
|
|
40
|
+
} from "../cms/page-visibility";
|
|
41
|
+
import { auditAs } from "./audit-core";
|
|
42
|
+
import { assertEditable } from "./edit-locks-core";
|
|
43
|
+
import { assertStaff, type PlatformAuthContext } from "./guards";
|
|
44
|
+
import { invalidatePublishedCache, withPublishedCache } from "./published-cache";
|
|
45
|
+
|
|
46
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
47
|
+
|
|
48
|
+
export const SITE_SETTINGS = "site_settings";
|
|
49
|
+
export const PAGE_LISTING_PREFIX = "page.listing.";
|
|
50
|
+
export const BOOKING_SETTING_KEY = "booking";
|
|
51
|
+
export const BOOKING_EXCEPTION_PREFIX = "booking.exception.";
|
|
52
|
+
|
|
53
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
54
|
+
|
|
55
|
+
/** Request context of a signed-in staff user, as every page core takes it. */
|
|
56
|
+
export type VisibilityContext = PlatformAuthContext & { supabase: any };
|
|
57
|
+
|
|
58
|
+
export type VisibilityDeps = {
|
|
59
|
+
context: VisibilityContext;
|
|
60
|
+
/**
|
|
61
|
+
* Keys of pages that must always stay published and listed — for Voyara the
|
|
62
|
+
* start page and the contact page. Supplied by the host's server code, never
|
|
63
|
+
* by the browser.
|
|
64
|
+
*/
|
|
65
|
+
protectedPageKeys: readonly string[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Someone else changed the same decision since the editor last looked. */
|
|
69
|
+
export class VisibilityConflictError extends Error {
|
|
70
|
+
readonly code = "visibility_conflict";
|
|
71
|
+
constructor(what: string) {
|
|
72
|
+
super(
|
|
73
|
+
`Someone else changed ${what} a moment ago, so nothing was saved. ` +
|
|
74
|
+
"Reload to see the current setting, then try again.",
|
|
75
|
+
);
|
|
76
|
+
this.name = "VisibilityConflictError";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A version token as the client may send it back: a timestamp string, or null for "no row yet". */
|
|
81
|
+
export type VersionToken = string | null;
|
|
82
|
+
|
|
83
|
+
type SettingRow = { key: string; value: unknown; updated_at: string | null };
|
|
84
|
+
|
|
85
|
+
function record(value: unknown): Record<string, unknown> {
|
|
86
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
87
|
+
? (value as Record<string, unknown>)
|
|
88
|
+
: {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every visibility row, read in one query. A failed read throws; it is never "nothing stored". */
|
|
92
|
+
export async function readVisibilitySettingsCore(client: any): Promise<VisibilitySettings> {
|
|
93
|
+
const { data, error } = await client.from(SITE_SETTINGS).select("key, value, updated_at");
|
|
94
|
+
if (error) throw new Error(error.message);
|
|
95
|
+
const settings: VisibilitySettings = {
|
|
96
|
+
booking: { enabled: true, version: null },
|
|
97
|
+
listings: new Map(),
|
|
98
|
+
exceptions: new Map(),
|
|
99
|
+
};
|
|
100
|
+
for (const row of (data ?? []) as SettingRow[]) {
|
|
101
|
+
const version = row.updated_at ?? null;
|
|
102
|
+
if (row.key === BOOKING_SETTING_KEY) {
|
|
103
|
+
// Only an explicit `false` turns booking off; anything else is today's behaviour.
|
|
104
|
+
settings.booking = { enabled: record(row.value)["enabled"] !== false, version };
|
|
105
|
+
} else if (row.key.startsWith(PAGE_LISTING_PREFIX)) {
|
|
106
|
+
const pageId = row.key.slice(PAGE_LISTING_PREFIX.length);
|
|
107
|
+
if (UUID.test(pageId)) {
|
|
108
|
+
settings.listings.set(pageId.toLowerCase(), {
|
|
109
|
+
listing: normalizePageListing(record(row.value)["listing"]),
|
|
110
|
+
version,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
} else if (row.key.startsWith(BOOKING_EXCEPTION_PREFIX)) {
|
|
114
|
+
const pageId = row.key.slice(BOOKING_EXCEPTION_PREFIX.length);
|
|
115
|
+
if (UUID.test(pageId)) {
|
|
116
|
+
settings.exceptions.set(pageId.toLowerCase(), {
|
|
117
|
+
exception: record(row.value)["exception"] === true,
|
|
118
|
+
version,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return settings;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type VisibilitySettings = {
|
|
127
|
+
booking: { enabled: boolean; version: VersionToken };
|
|
128
|
+
listings: Map<string, { listing: PageListing; version: VersionToken }>;
|
|
129
|
+
exceptions: Map<string, { exception: boolean; version: VersionToken }>;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A version that differs from `expected` even on a table without the
|
|
134
|
+
* `updated_at` trigger. With the trigger the database replaces it with its own
|
|
135
|
+
* `now()`, which differs too.
|
|
136
|
+
*/
|
|
137
|
+
function nextVersion(expected: string): string {
|
|
138
|
+
const seen = Date.parse(expected);
|
|
139
|
+
return new Date(Math.max(Date.now(), Number.isFinite(seen) ? seen + 1 : 0)).toISOString();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Write one row only if it is still the version the editor saw. Returns the
|
|
144
|
+
* new version, or throws {@link VisibilityConflictError}.
|
|
145
|
+
*/
|
|
146
|
+
async function compareAndSwap(
|
|
147
|
+
client: any,
|
|
148
|
+
key: string,
|
|
149
|
+
value: Record<string, unknown>,
|
|
150
|
+
expected: VersionToken,
|
|
151
|
+
actorId: string,
|
|
152
|
+
what: string,
|
|
153
|
+
): Promise<string> {
|
|
154
|
+
if (expected === null) {
|
|
155
|
+
const { data, error } = await client
|
|
156
|
+
.from(SITE_SETTINGS)
|
|
157
|
+
.upsert({ key, value, updated_by: actorId }, { onConflict: "key", ignoreDuplicates: true })
|
|
158
|
+
.select("updated_at");
|
|
159
|
+
if (error) throw new Error(error.message);
|
|
160
|
+
const row = ((data ?? []) as SettingRow[])[0];
|
|
161
|
+
if (!row) throw new VisibilityConflictError(what);
|
|
162
|
+
return String(row.updated_at ?? "");
|
|
163
|
+
}
|
|
164
|
+
const { data, error } = await client
|
|
165
|
+
.from(SITE_SETTINGS)
|
|
166
|
+
.update({ value, updated_by: actorId, updated_at: nextVersion(expected) })
|
|
167
|
+
.eq("key", key)
|
|
168
|
+
.eq("updated_at", expected)
|
|
169
|
+
.select("updated_at");
|
|
170
|
+
if (error) throw new Error(error.message);
|
|
171
|
+
const row = ((data ?? []) as SettingRow[])[0];
|
|
172
|
+
if (!row) throw new VisibilityConflictError(what);
|
|
173
|
+
return String(row.updated_at ?? "");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type PageRow = { id: string; key: string; name: string };
|
|
177
|
+
|
|
178
|
+
async function readLivePage(client: any, pageId: string): Promise<PageRow> {
|
|
179
|
+
if (!UUID.test(pageId)) throw new Error("Page not found");
|
|
180
|
+
const { data, error } = await client
|
|
181
|
+
.from("pages")
|
|
182
|
+
.select("id,key,name")
|
|
183
|
+
.eq("id", pageId)
|
|
184
|
+
.is("deleted_at", null)
|
|
185
|
+
.maybeSingle();
|
|
186
|
+
if (error) throw new Error(error.message);
|
|
187
|
+
if (!data) throw new Error("Page not found");
|
|
188
|
+
return data as PageRow;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Thrown when an action would take a protected page off the site or out of its lists. */
|
|
192
|
+
export class ProtectedPageError extends Error {
|
|
193
|
+
readonly code = "protected_page";
|
|
194
|
+
constructor(name: string, action: string) {
|
|
195
|
+
super(
|
|
196
|
+
`“${name}” is protected and cannot be ${action}: the start page and the contact page ` +
|
|
197
|
+
"always stay published and listed.",
|
|
198
|
+
);
|
|
199
|
+
this.name = "ProtectedPageError";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Throws when `key` is one of the host's protected pages. */
|
|
204
|
+
export function assertNotProtected(
|
|
205
|
+
protectedPageKeys: readonly string[] | undefined,
|
|
206
|
+
page: { key: string; name: string },
|
|
207
|
+
action: string,
|
|
208
|
+
): void {
|
|
209
|
+
if ((protectedPageKeys ?? []).includes(page.key)) throw new ProtectedPageError(page.name, action);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Make a page link-only, or list it again.
|
|
214
|
+
*
|
|
215
|
+
* Changes listing and nothing else: no publication, version, schedule or
|
|
216
|
+
* status is read for writing, let alone changed — so "Show on website" cannot
|
|
217
|
+
* publish an offline page, and a link-only page stays link-only through every
|
|
218
|
+
* later save, publish or scheduled replacement.
|
|
219
|
+
*/
|
|
220
|
+
export async function setPageListingCore(
|
|
221
|
+
deps: VisibilityDeps,
|
|
222
|
+
input: {
|
|
223
|
+
pageId: string;
|
|
224
|
+
listing: PageListing;
|
|
225
|
+
expectedVersion: VersionToken;
|
|
226
|
+
editSessionId?: string;
|
|
227
|
+
},
|
|
228
|
+
): Promise<{ listing: PageListing; version: string }> {
|
|
229
|
+
const { context } = deps;
|
|
230
|
+
await assertStaff(context);
|
|
231
|
+
await assertEditable(context, {
|
|
232
|
+
resourceType: EDIT_LOCK_TYPES.page,
|
|
233
|
+
resourceId: input.pageId,
|
|
234
|
+
sessionId: input.editSessionId,
|
|
235
|
+
});
|
|
236
|
+
const page = await readLivePage(context.supabase, input.pageId);
|
|
237
|
+
const listing = normalizePageListing(input.listing);
|
|
238
|
+
if (listing === "link-only") assertNotProtected(deps.protectedPageKeys, page, "made link-only");
|
|
239
|
+
|
|
240
|
+
invalidatePublishedCache();
|
|
241
|
+
const version = await compareAndSwap(
|
|
242
|
+
context.supabase,
|
|
243
|
+
`${PAGE_LISTING_PREFIX}${page.id.toLowerCase()}`,
|
|
244
|
+
{ listing },
|
|
245
|
+
input.expectedVersion,
|
|
246
|
+
context.userId,
|
|
247
|
+
`whether “${page.name}” is listed`,
|
|
248
|
+
);
|
|
249
|
+
invalidatePublishedCache();
|
|
250
|
+
await auditAs(context, {
|
|
251
|
+
action: "page.listing",
|
|
252
|
+
entity: "pages",
|
|
253
|
+
entityId: page.id,
|
|
254
|
+
meta: {
|
|
255
|
+
key: page.key,
|
|
256
|
+
listing,
|
|
257
|
+
description:
|
|
258
|
+
listing === "link-only"
|
|
259
|
+
? `Made the page "${page.name}" link-only — it is left out of menus, footer and sitemap and marked noindex.`
|
|
260
|
+
: `Listed the page "${page.name}" on the website again.`,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
return { listing, version };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Switch booking links on or off for the whole public site. */
|
|
267
|
+
export async function setBookingEnabledCore(
|
|
268
|
+
deps: Pick<VisibilityDeps, "context">,
|
|
269
|
+
input: { enabled: boolean; expectedVersion: VersionToken },
|
|
270
|
+
): Promise<{ enabled: boolean; version: string }> {
|
|
271
|
+
const { context } = deps;
|
|
272
|
+
await assertStaff(context);
|
|
273
|
+
invalidatePublishedCache();
|
|
274
|
+
const version = await compareAndSwap(
|
|
275
|
+
context.supabase,
|
|
276
|
+
BOOKING_SETTING_KEY,
|
|
277
|
+
{ enabled: input.enabled },
|
|
278
|
+
input.expectedVersion,
|
|
279
|
+
context.userId,
|
|
280
|
+
"the booking setting",
|
|
281
|
+
);
|
|
282
|
+
invalidatePublishedCache();
|
|
283
|
+
await auditAs(context, {
|
|
284
|
+
action: "booking.settings",
|
|
285
|
+
entity: SITE_SETTINGS,
|
|
286
|
+
entityId: BOOKING_SETTING_KEY,
|
|
287
|
+
meta: {
|
|
288
|
+
enabled: input.enabled,
|
|
289
|
+
description: input.enabled
|
|
290
|
+
? "Switched booking on — booking links and the header booking button show on the website again."
|
|
291
|
+
: "Switched booking off — booking links and the header booking button are hidden on the website.",
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
return { enabled: input.enabled, version };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Add a page to, or remove it from, the pages that keep booking while it is off. */
|
|
298
|
+
export async function setBookingExceptionCore(
|
|
299
|
+
deps: Pick<VisibilityDeps, "context">,
|
|
300
|
+
input: { pageId: string; exception: boolean; expectedVersion: VersionToken },
|
|
301
|
+
): Promise<{ exception: boolean; version: string }> {
|
|
302
|
+
const { context } = deps;
|
|
303
|
+
await assertStaff(context);
|
|
304
|
+
if (!UUID.test(input.pageId)) throw new Error("Page not found");
|
|
305
|
+
// Adding needs a page that exists; removing works even for one in the trash.
|
|
306
|
+
let name = "this page";
|
|
307
|
+
if (input.exception) name = (await readLivePage(context.supabase, input.pageId)).name;
|
|
308
|
+
invalidatePublishedCache();
|
|
309
|
+
const version = await compareAndSwap(
|
|
310
|
+
context.supabase,
|
|
311
|
+
`${BOOKING_EXCEPTION_PREFIX}${input.pageId.toLowerCase()}`,
|
|
312
|
+
{ exception: input.exception },
|
|
313
|
+
input.expectedVersion,
|
|
314
|
+
context.userId,
|
|
315
|
+
`the booking exception for ${input.exception ? `“${name}”` : name}`,
|
|
316
|
+
);
|
|
317
|
+
invalidatePublishedCache();
|
|
318
|
+
await auditAs(context, {
|
|
319
|
+
action: "booking.exception",
|
|
320
|
+
entity: "pages",
|
|
321
|
+
entityId: input.pageId,
|
|
322
|
+
meta: {
|
|
323
|
+
exception: input.exception,
|
|
324
|
+
description: input.exception
|
|
325
|
+
? `Kept booking links on the page "${name}" while booking is switched off.`
|
|
326
|
+
: "Removed a page from the pages that keep booking links while booking is switched off.",
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
return { exception: input.exception, version };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Booking setting and its exception pages, for the settings screen. */
|
|
333
|
+
export type BookingSettingsView = {
|
|
334
|
+
enabled: boolean;
|
|
335
|
+
version: VersionToken;
|
|
336
|
+
exceptions: Array<{
|
|
337
|
+
pageId: string;
|
|
338
|
+
key: string | null;
|
|
339
|
+
name: string | null;
|
|
340
|
+
/** The page is in the trash or gone: the exception does nothing until restored. */
|
|
341
|
+
missing: boolean;
|
|
342
|
+
version: VersionToken;
|
|
343
|
+
}>;
|
|
344
|
+
/**
|
|
345
|
+
* The version of every stored exception row by page id, including removed
|
|
346
|
+
* ones: adding a page back has to send the version of the row it left.
|
|
347
|
+
*/
|
|
348
|
+
rowVersions: Record<string, VersionToken>;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export async function readBookingSettingsCore(
|
|
352
|
+
deps: Pick<VisibilityDeps, "context">,
|
|
353
|
+
): Promise<BookingSettingsView> {
|
|
354
|
+
const { context } = deps;
|
|
355
|
+
await assertStaff(context);
|
|
356
|
+
const settings = await readVisibilitySettingsCore(context.supabase);
|
|
357
|
+
const ids = [...settings.exceptions].filter(([, row]) => row.exception).map(([id]) => id);
|
|
358
|
+
const pages = new Map<string, PageRow>();
|
|
359
|
+
if (ids.length > 0) {
|
|
360
|
+
const { data, error } = await context.supabase
|
|
361
|
+
.from("pages")
|
|
362
|
+
.select("id,key,name")
|
|
363
|
+
.in("id", ids)
|
|
364
|
+
.is("deleted_at", null);
|
|
365
|
+
if (error) throw new Error(error.message);
|
|
366
|
+
for (const row of (data ?? []) as PageRow[]) pages.set(row.id.toLowerCase(), row);
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
enabled: settings.booking.enabled,
|
|
370
|
+
version: settings.booking.version,
|
|
371
|
+
exceptions: ids
|
|
372
|
+
.map((pageId) => {
|
|
373
|
+
const page = pages.get(pageId);
|
|
374
|
+
return {
|
|
375
|
+
pageId,
|
|
376
|
+
key: page?.key ?? null,
|
|
377
|
+
name: page?.name ?? null,
|
|
378
|
+
missing: !page,
|
|
379
|
+
version: settings.exceptions.get(pageId)?.version ?? null,
|
|
380
|
+
};
|
|
381
|
+
})
|
|
382
|
+
.sort((a, b) => (a.name ?? "~").localeCompare(b.name ?? "~")),
|
|
383
|
+
rowVersions: Object.fromEntries([...settings.exceptions].map(([id, row]) => [id, row.version])),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The public policy: every page's publication and listing, the booking switch
|
|
389
|
+
* and its exceptions, from one consistent set of reads.
|
|
390
|
+
*
|
|
391
|
+
* Runs with the host's trusted server client, because `pages` and
|
|
392
|
+
* `page_publications` are not readable anonymously. Cached like published
|
|
393
|
+
* pages: dropped by every page, publication or visibility write, and never
|
|
394
|
+
* held past the next publication boundary, so a scheduled publish or expiry
|
|
395
|
+
* changes the policy at its instant without anybody saving anything. A read
|
|
396
|
+
* that fails throws and is not cached; the caller decides how to fail closed.
|
|
397
|
+
*/
|
|
398
|
+
export async function loadPublicPolicyCore(client: any): Promise<PublicPolicySnapshot> {
|
|
399
|
+
return withPublishedCache(
|
|
400
|
+
"policy",
|
|
401
|
+
"site",
|
|
402
|
+
() => readPublicPolicy(client),
|
|
403
|
+
(policy) => policy.revalidateAt,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function readPublicPolicy(client: any): Promise<PublicPolicySnapshot> {
|
|
408
|
+
const settings = await readVisibilitySettingsCore(client);
|
|
409
|
+
|
|
410
|
+
const { data: pubs, error: pubError } = await client
|
|
411
|
+
.from("page_publications")
|
|
412
|
+
.select("page_id, version_id, publish_at, unpublish_at, published_at")
|
|
413
|
+
.eq("is_active", true)
|
|
414
|
+
.order("publish_at", { ascending: false });
|
|
415
|
+
if (pubError) throw new Error(pubError.message);
|
|
416
|
+
|
|
417
|
+
const { data: pageRows, error: pageError } = await client
|
|
418
|
+
.from("pages")
|
|
419
|
+
.select("id,key")
|
|
420
|
+
.is("deleted_at", null)
|
|
421
|
+
.order("key");
|
|
422
|
+
if (pageError) throw new Error(pageError.message);
|
|
423
|
+
|
|
424
|
+
const byPage = new Map<string, PublicationWindow[]>();
|
|
425
|
+
for (const row of (pubs ?? []) as Array<Record<string, string | null>>) {
|
|
426
|
+
const pageId = row.page_id;
|
|
427
|
+
if (!pageId) continue;
|
|
428
|
+
byPage.set(pageId, [...(byPage.get(pageId) ?? []), ...toPublicationWindows([row])]);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const now = Date.now();
|
|
432
|
+
let revalidateAt: number | null = null;
|
|
433
|
+
for (const windows of byPage.values()) {
|
|
434
|
+
const boundary = nextPublicationBoundary(windows, now);
|
|
435
|
+
if (boundary !== null && (revalidateAt === null || boundary < revalidateAt)) revalidateAt = boundary;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const pages: PolicyPage[] = ((pageRows ?? []) as Array<{ id: string; key: string }>).map((row) => {
|
|
439
|
+
const id = String(row.id).toLowerCase();
|
|
440
|
+
return {
|
|
441
|
+
id,
|
|
442
|
+
key: row.key,
|
|
443
|
+
live: publicVersionId(byPage.get(row.id) ?? [], now) !== null,
|
|
444
|
+
listing: settings.listings.get(id)?.listing ?? "listed",
|
|
445
|
+
bookingException: settings.exceptions.get(id)?.exception === true,
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
return { known: true, bookingEnabled: settings.booking.enabled, pages, revalidateAt };
|
|
450
|
+
}
|