@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10
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 +489 -0
- package/README.md +16 -2
- package/docs/EDIT-LOCKS.md +213 -0
- package/docs/POPUPS.md +102 -0
- package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
- package/package.json +31 -3
- package/scripts/create-cms-site.mjs +5 -4
- package/scripts/sync-migrations.mjs +27 -6
- package/src/backoffice/ContactDetailsView.tsx +19 -5
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/EditLock.tsx +513 -0
- package/src/backoffice/EmailSettingsView.tsx +99 -0
- package/src/backoffice/FormEditorView.tsx +531 -512
- package/src/backoffice/FormSettingsView.tsx +16 -4
- package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
- package/src/backoffice/NavigationView.tsx +35 -11
- package/src/backoffice/PageBuilderView.tsx +51 -16
- package/src/backoffice/RichTextEditor.tsx +50 -14
- package/src/backoffice/SectionEditorView.tsx +18 -6
- package/src/backoffice/SectionFormView.tsx +184 -31
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/forms/VersionHistory.tsx +4 -2
- package/src/backoffice/inspectors/blocks.tsx +22 -1
- package/src/backoffice/inspectors/fields.tsx +21 -0
- package/src/backoffice/routes/host.ts +58 -7
- package/src/backoffice/routes/index.tsx +117 -15
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/components/CtaBannerBlock.tsx +18 -12
- package/src/blocks/components/HeroFullBlock.tsx +25 -12
- package/src/blocks/components/MediaSplitBlock.tsx +91 -41
- package/src/blocks/components/PriceTableBlock.tsx +123 -89
- package/src/blocks/components/RichTextBlock.tsx +26 -4
- package/src/blocks/index.ts +8 -0
- package/src/blocks/optional-content.ts +82 -0
- package/src/blocks/registry.tsx +1 -0
- package/src/blocks/types.ts +6 -0
- package/src/cms/edit-lock.ts +491 -0
- package/src/config/modules.ts +208 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +62 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +27 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/navigation/index.ts +29 -0
- package/src/popups/admin.tsx +123 -0
- package/src/popups/cms-bridges.ts +240 -0
- package/src/popups/destinations.tsx +147 -0
- package/src/popups/host.ts +91 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +128 -0
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +22 -5
- package/src/server/domains-core.ts +124 -107
- package/src/server/edit-lock-adapter.ts +91 -0
- package/src/server/edit-lock-supabase-adapter.ts +135 -0
- package/src/server/edit-locks-core.ts +286 -0
- package/src/server/forms-core.ts +37 -2
- package/src/server/injections-core.ts +230 -49
- package/src/server/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +108 -11
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/provider-secrets.ts +100 -4
- package/src/server/schemas.ts +74 -9
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/site/SiteHeader.tsx +11 -5
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/tracking/injections.ts +52 -9
- package/src/types/css-modules.d.ts +6 -0
- package/src/ui/Reveal.tsx +74 -9
|
@@ -9,14 +9,19 @@
|
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
EMPTY_BUNDLE,
|
|
12
|
+
MISSING_CREATED_ID,
|
|
12
13
|
mergeBundles,
|
|
13
14
|
normalizeHostname,
|
|
14
15
|
selectProfiles,
|
|
15
16
|
type InjectionBundle,
|
|
16
17
|
type InjectionProfile,
|
|
17
18
|
} from "../tracking/injections";
|
|
19
|
+
import { resolveRequestLocale } from "../site/locale-domains";
|
|
18
20
|
import { actorEmail, assertRole, type PlatformAuthContext } from "./guards";
|
|
19
21
|
|
|
22
|
+
/** Re-exported so hosts that already import it from here keep working. */
|
|
23
|
+
export { MISSING_CREATED_ID };
|
|
24
|
+
|
|
20
25
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
21
26
|
/** Structural subset of a Supabase-shaped client used by this core. */
|
|
22
27
|
export type InjectionsClient = { from: (table: string) => any };
|
|
@@ -89,11 +94,76 @@ function toRow(input: InjectionInput) {
|
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
/** Where a failure gets reported. Defaults to `console.error`. */
|
|
98
|
+
export type InjectionsLogger = (message: string, error: unknown) => void;
|
|
99
|
+
|
|
100
|
+
const defaultLogger: InjectionsLogger = (message, error) => {
|
|
101
|
+
console.error(message, error);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Report a failure without letting the report become one.
|
|
106
|
+
*
|
|
107
|
+
* Everywhere this is called the outcome has already been decided: the row is
|
|
108
|
+
* written, or the request is about to be served an empty bundle so the page
|
|
109
|
+
* still renders. A logger that throws would undo that decision at the very
|
|
110
|
+
* last step — an injected reporter whose transport is down turns a completed
|
|
111
|
+
* create into a rejection the screen reads as "press Save again", and a
|
|
112
|
+
* degraded render into a 500 on a public page. The report is worth less than
|
|
113
|
+
* either.
|
|
114
|
+
*
|
|
115
|
+
* So the supplied logger is tried, then `console.error` on its own, and then
|
|
116
|
+
* the reporting failure is dropped, because at that point there is nothing
|
|
117
|
+
* left that can be told.
|
|
118
|
+
*/
|
|
119
|
+
function reportBestEffort(logger: InjectionsLogger, message: string, error: unknown): void {
|
|
120
|
+
try {
|
|
121
|
+
logger(message, error);
|
|
122
|
+
} catch (loggerError) {
|
|
123
|
+
try {
|
|
124
|
+
console.error(message, error);
|
|
125
|
+
console.error("[cms] injection logger failed", loggerError);
|
|
126
|
+
} catch {
|
|
127
|
+
// The console is gone too. Nothing here is worth the caller's outcome.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Write the audit entry, and let the mutation stand if that write fails.
|
|
134
|
+
*
|
|
135
|
+
* By the time this runs the row is already inserted, updated or deleted. A
|
|
136
|
+
* rejection here would hand the caller an ordinary failure for a change that
|
|
137
|
+
* did happen, and the obvious response to an ordinary failure — press Save
|
|
138
|
+
* again — inserts a second profile. Auditing is best-effort across this
|
|
139
|
+
* package for exactly that reason: the trail is worth less than the
|
|
140
|
+
* correctness of what it describes.
|
|
141
|
+
*
|
|
142
|
+
* The failure is reported rather than swallowed, so a host that has lost its
|
|
143
|
+
* audit table finds out from its logs instead of from the empty table.
|
|
144
|
+
*/
|
|
145
|
+
async function auditBestEffort(
|
|
146
|
+
audit: AuditWriter,
|
|
147
|
+
entry: Parameters<AuditWriter>[0],
|
|
148
|
+
logger: InjectionsLogger,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
try {
|
|
151
|
+
await audit(entry);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
reportBestEffort(
|
|
154
|
+
logger,
|
|
155
|
+
`[cms] injection audit failed for ${entry.action} ${entry.entityId}`,
|
|
156
|
+
error,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
92
161
|
/** Admin-only create/update of one profile, with an audit entry. */
|
|
93
162
|
export async function saveInjectionCore(
|
|
94
163
|
context: PlatformAuthContext & { supabase: InjectionsClient },
|
|
95
164
|
input: InjectionInput,
|
|
96
165
|
audit: AuditWriter,
|
|
166
|
+
logger: InjectionsLogger = defaultLogger,
|
|
97
167
|
): Promise<{ id: string }> {
|
|
98
168
|
await assertRole(context, "admin");
|
|
99
169
|
const row = toRow(input);
|
|
@@ -105,14 +175,19 @@ export async function saveInjectionCore(
|
|
|
105
175
|
.update(row)
|
|
106
176
|
.eq("id", input.id);
|
|
107
177
|
if (error) throw new Error(error.message);
|
|
108
|
-
await
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
178
|
+
await auditBestEffort(
|
|
179
|
+
audit,
|
|
180
|
+
{
|
|
181
|
+
actorId: context.userId,
|
|
182
|
+
actorEmail: actorEmail(context),
|
|
183
|
+
action: "injection.update",
|
|
184
|
+
entity: "site_injections",
|
|
185
|
+
entityId: input.id,
|
|
186
|
+
meta: { name: input.name, scope, enabled: input.enabled },
|
|
187
|
+
},
|
|
188
|
+
logger,
|
|
189
|
+
);
|
|
190
|
+
invalidateInjectionCache();
|
|
116
191
|
return { id: input.id };
|
|
117
192
|
}
|
|
118
193
|
|
|
@@ -122,15 +197,35 @@ export async function saveInjectionCore(
|
|
|
122
197
|
.select("id")
|
|
123
198
|
.single();
|
|
124
199
|
if (error) throw new Error(error.message);
|
|
125
|
-
const id = String(inserted?.id ?? "");
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
200
|
+
const id = String(inserted?.id ?? "").trim();
|
|
201
|
+
/*
|
|
202
|
+
* No id means the insert cannot be reported as a success.
|
|
203
|
+
*
|
|
204
|
+
* Whether the row exists is now unknown, and everything below this line
|
|
205
|
+
* states that it does: an audit entry naming no entity, a cache invalidation
|
|
206
|
+
* announcing a change, and a `{ id: "" }` the caller reads as "created".
|
|
207
|
+
* Throwing before any of them leaves one indeterminate insert to reconcile
|
|
208
|
+
* rather than a trail of confident records about a row nobody can find.
|
|
209
|
+
*/
|
|
210
|
+
if (!id) throw new Error(MISSING_CREATED_ID);
|
|
211
|
+
/*
|
|
212
|
+
* The row exists and its id is in hand. Nothing after this point may take
|
|
213
|
+
* that away from the caller: a create reported as failed is a create the
|
|
214
|
+
* administrator will send again, and the second one is a duplicate.
|
|
215
|
+
*/
|
|
216
|
+
await auditBestEffort(
|
|
217
|
+
audit,
|
|
218
|
+
{
|
|
219
|
+
actorId: context.userId,
|
|
220
|
+
actorEmail: actorEmail(context),
|
|
221
|
+
action: "injection.create",
|
|
222
|
+
entity: "site_injections",
|
|
223
|
+
entityId: id,
|
|
224
|
+
meta: { name: input.name, scope },
|
|
225
|
+
},
|
|
226
|
+
logger,
|
|
227
|
+
);
|
|
228
|
+
invalidateInjectionCache();
|
|
134
229
|
return { id };
|
|
135
230
|
}
|
|
136
231
|
|
|
@@ -139,29 +234,76 @@ export async function deleteInjectionCore(
|
|
|
139
234
|
context: PlatformAuthContext & { supabase: InjectionsClient },
|
|
140
235
|
id: string,
|
|
141
236
|
audit: AuditWriter,
|
|
237
|
+
logger: InjectionsLogger = defaultLogger,
|
|
142
238
|
): Promise<{ ok: true }> {
|
|
143
239
|
await assertRole(context, "admin");
|
|
144
240
|
const { error } = await context.supabase.from("site_injections").delete().eq("id", id);
|
|
145
241
|
if (error) throw new Error(error.message);
|
|
146
|
-
await
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
242
|
+
await auditBestEffort(
|
|
243
|
+
audit,
|
|
244
|
+
{
|
|
245
|
+
actorId: context.userId,
|
|
246
|
+
actorEmail: actorEmail(context),
|
|
247
|
+
action: "injection.delete",
|
|
248
|
+
entity: "site_injections",
|
|
249
|
+
entityId: id,
|
|
250
|
+
meta: {},
|
|
251
|
+
},
|
|
252
|
+
logger,
|
|
253
|
+
);
|
|
254
|
+
invalidateInjectionCache();
|
|
154
255
|
return { ok: true };
|
|
155
256
|
}
|
|
156
257
|
|
|
157
258
|
/* ---------------- Request-time resolution ---------------- */
|
|
158
259
|
|
|
159
260
|
const CACHE_TTL_MS = 30_000;
|
|
261
|
+
/**
|
|
262
|
+
* Bound on distinct hosts held at once. The cache key is a request header, so
|
|
263
|
+
* an unbounded map is something a stranger can grow; past the cap the oldest
|
|
264
|
+
* entries go, which for a real site is never reached.
|
|
265
|
+
*/
|
|
266
|
+
const CACHE_MAX_ENTRIES = 64;
|
|
160
267
|
const cache = new Map<string, { at: number; bundle: InjectionBundle }>();
|
|
161
268
|
|
|
269
|
+
/** Drop cached bundles — all of them, or just one host's. */
|
|
270
|
+
export function invalidateInjectionCache(host?: string): void {
|
|
271
|
+
if (host === undefined) {
|
|
272
|
+
cache.clear();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
cache.delete(normalizeHostname(host));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function remember(key: string, bundle: InjectionBundle): void {
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
for (const [host, entry] of cache) {
|
|
281
|
+
if (now - entry.at >= CACHE_TTL_MS) cache.delete(host);
|
|
282
|
+
}
|
|
283
|
+
while (cache.size >= CACHE_MAX_ENTRIES) {
|
|
284
|
+
const oldest = cache.keys().next();
|
|
285
|
+
if (oldest.done) break;
|
|
286
|
+
cache.delete(oldest.value);
|
|
287
|
+
}
|
|
288
|
+
cache.set(key, { at: now, bundle });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export type ResolveInjectionsOptions = {
|
|
292
|
+
defaultLocale?: string;
|
|
293
|
+
/** Reports a backend failure. Without one, failures reach `console.error`. */
|
|
294
|
+
logger?: InjectionsLogger;
|
|
295
|
+
};
|
|
296
|
+
|
|
162
297
|
/**
|
|
163
|
-
* Resolve the merged bundle for a request host.
|
|
164
|
-
*
|
|
298
|
+
* Resolve the merged bundle for a request host.
|
|
299
|
+
*
|
|
300
|
+
* Never throws: a backend hiccup degrades to "no injection" so the site still
|
|
301
|
+
* renders. It is not, however, allowed to do that *quietly* — a failed read is
|
|
302
|
+
* reported and is NOT cached, so the next request retries instead of serving
|
|
303
|
+
* a fabricated empty bundle for the rest of the TTL. A query that returns an
|
|
304
|
+
* error rather than throwing counts as a failure; that is the shape this
|
|
305
|
+
* backend uses, and treating it as "no profiles" is what made a broken
|
|
306
|
+
* deployment indistinguishable from an unconfigured one.
|
|
165
307
|
*
|
|
166
308
|
* `client` must be a privileged (service-role) client — `site_injections` and
|
|
167
309
|
* `locale_domains` are not readable anonymously.
|
|
@@ -169,55 +311,94 @@ const cache = new Map<string, { at: number; bundle: InjectionBundle }>();
|
|
|
169
311
|
export async function resolveInjectionsCore(
|
|
170
312
|
client: InjectionsClient,
|
|
171
313
|
host: string,
|
|
172
|
-
options:
|
|
314
|
+
options: ResolveInjectionsOptions = {},
|
|
173
315
|
): Promise<InjectionBundle> {
|
|
174
316
|
const key = normalizeHostname(host);
|
|
175
317
|
const hit = cache.get(key);
|
|
176
318
|
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.bundle;
|
|
177
319
|
|
|
178
|
-
|
|
320
|
+
const log = options.logger ?? defaultLogger;
|
|
179
321
|
try {
|
|
180
|
-
const [
|
|
322
|
+
const [profileResult, domainResult] = await Promise.all([
|
|
181
323
|
client.from("site_injections").select("*").eq("enabled", true),
|
|
182
324
|
client.from("locale_domains").select("locale, domain, is_default"),
|
|
183
325
|
]);
|
|
184
326
|
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
327
|
+
if (profileResult?.error) throw new Error(String(profileResult.error.message ?? profileResult.error));
|
|
328
|
+
if (domainResult?.error) throw new Error(String(domainResult.error.message ?? domainResult.error));
|
|
329
|
+
|
|
330
|
+
const rows = (profileResult?.data ?? []) as Record<string, unknown>[];
|
|
331
|
+
if (rows.length === 0) {
|
|
332
|
+
remember(key, EMPTY_BUNDLE);
|
|
333
|
+
return EMPTY_BUNDLE;
|
|
192
334
|
}
|
|
193
|
-
|
|
194
|
-
|
|
335
|
+
|
|
336
|
+
const list = ((domainResult?.data ?? []) as {
|
|
337
|
+
locale: string;
|
|
338
|
+
domain: string;
|
|
339
|
+
is_default: boolean;
|
|
340
|
+
}[]).map((d) => ({ locale: d.locale, domain: d.domain, isDefault: d.is_default }));
|
|
341
|
+
|
|
342
|
+
const { locale } = resolveRequestLocale(list, host, options.defaultLocale ?? "en");
|
|
343
|
+
const bundle = mergeBundles(selectProfiles(rows.map(rowToInjectionProfile), locale, host));
|
|
344
|
+
remember(key, bundle);
|
|
345
|
+
return bundle;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
reportBestEffort(log, "site code injection: could not resolve tracking profiles", error);
|
|
348
|
+
return EMPTY_BUNDLE;
|
|
195
349
|
}
|
|
350
|
+
}
|
|
196
351
|
|
|
197
|
-
|
|
198
|
-
|
|
352
|
+
/** A path reduced to `/a/b`: leading slash, no trailing one, no query. */
|
|
353
|
+
function normalizePath(value: string): string {
|
|
354
|
+
const path = (value ?? "").split(/[?#]/)[0] ?? "";
|
|
355
|
+
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
|
356
|
+
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Whether `pathname` is `prefix` itself or something beneath it.
|
|
361
|
+
*
|
|
362
|
+
* Compared segment by segment rather than by string prefix, because
|
|
363
|
+
* `/administrator` is a public page and `startsWith("/admin")` called it part
|
|
364
|
+
* of the backoffice — a site whose backoffice lives at `/admin` then silently
|
|
365
|
+
* lost tracking on every page whose path merely began with those letters.
|
|
366
|
+
*/
|
|
367
|
+
function isPathWithin(pathname: string, prefix: string): boolean {
|
|
368
|
+
const path = normalizePath(pathname);
|
|
369
|
+
const base = normalizePath(prefix);
|
|
370
|
+
if (base === "/") return true;
|
|
371
|
+
return path === base || path.startsWith(`${base}/`);
|
|
199
372
|
}
|
|
200
373
|
|
|
201
374
|
/** True for paths that must never receive tracking code. */
|
|
202
375
|
export function isInjectablePath(pathname: string, backofficePath = "/kontoret"): boolean {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (pathname.startsWith("/api")) return false;
|
|
206
|
-
if (pathname.startsWith("/_serverFn")) return false;
|
|
207
|
-
return true;
|
|
376
|
+
const excluded = [backofficePath, "/preview", "/api", "/_serverFn"];
|
|
377
|
+
return !excluded.some((prefix) => isPathWithin(pathname, prefix));
|
|
208
378
|
}
|
|
209
379
|
|
|
210
|
-
/**
|
|
380
|
+
/**
|
|
381
|
+
* Insert the bundle's three chunks into a rendered HTML document.
|
|
382
|
+
*
|
|
383
|
+
* Every insertion goes through a replacement *function*, never a replacement
|
|
384
|
+
* string. A string replacement is not a literal: `String.prototype.replace`
|
|
385
|
+
* reads `$&`, `` $` ``, `$'` and `$$` out of it and substitutes parts of the
|
|
386
|
+
* match. Administrator code is raw by design — a snippet containing `$&`
|
|
387
|
+
* (jQuery, a currency template, a regex in a tracker) would be silently
|
|
388
|
+
* rewritten into the matched tag, shipping something the administrator never
|
|
389
|
+
* wrote. A function's return value is used verbatim, so the injected bytes are
|
|
390
|
+
* exactly the bytes that were saved.
|
|
391
|
+
*/
|
|
211
392
|
export function applyInjections(html: string, bundle: InjectionBundle): string {
|
|
212
393
|
let out = html;
|
|
213
394
|
if (bundle.head && out.includes("</head>")) {
|
|
214
|
-
out = out.replace("</head>", `${bundle.head}</head>`);
|
|
395
|
+
out = out.replace("</head>", () => `${bundle.head}</head>`);
|
|
215
396
|
}
|
|
216
397
|
if (bundle.bodyStart) {
|
|
217
398
|
out = out.replace(/<body([^>]*)>/i, (m) => `${m}${bundle.bodyStart}`);
|
|
218
399
|
}
|
|
219
400
|
if (bundle.bodyEnd && out.includes("</body>")) {
|
|
220
|
-
out = out.replace("</body>", `${bundle.bodyEnd}</body>`);
|
|
401
|
+
out = out.replace("</body>", () => `${bundle.bodyEnd}</body>`);
|
|
221
402
|
}
|
|
222
403
|
return out;
|
|
223
404
|
}
|
|
@@ -5,12 +5,21 @@
|
|
|
5
5
|
* The site owns only its page registry, its locales and its authenticated
|
|
6
6
|
* client; every rule below is shared by every site.
|
|
7
7
|
*/
|
|
8
|
+
import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
|
|
8
9
|
import { stampManualEdits } from "../i18n/translation-meta";
|
|
10
|
+
import { assertEditable } from "./edit-locks-core";
|
|
11
|
+
import type { PlatformAuthContext } from "./guards";
|
|
9
12
|
import { slugRedirectRows, type SlugConflict } from "./page-seo-core";
|
|
10
13
|
|
|
11
14
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
12
15
|
type Client = {
|
|
13
16
|
from: (table: string) => any;
|
|
17
|
+
/**
|
|
18
|
+
* Present on every real Supabase client. Optional here only because this
|
|
19
|
+
* context predates the editing locks and some hosts pass a narrower stub;
|
|
20
|
+
* without it the lock check is skipped rather than the save failing.
|
|
21
|
+
*/
|
|
22
|
+
rpc?: (fn: any, args: any) => PromiseLike<{ data: any; error: any }>;
|
|
14
23
|
};
|
|
15
24
|
|
|
16
25
|
export type PageSeoWriteContext = { supabase: Client; userId: string };
|
|
@@ -19,6 +28,8 @@ export type PageSeoSaveInput = {
|
|
|
19
28
|
pageKey: string;
|
|
20
29
|
slugs: Record<string, unknown>;
|
|
21
30
|
seo: Record<string, unknown>;
|
|
31
|
+
/** Editing session that holds the lock, when the host tracks one. */
|
|
32
|
+
editSessionId?: string;
|
|
22
33
|
};
|
|
23
34
|
|
|
24
35
|
export type PageSeoSaveResult =
|
|
@@ -40,6 +51,14 @@ export async function savePageSeoCore(
|
|
|
40
51
|
): Promise<PageSeoSaveResult> {
|
|
41
52
|
const { context } = deps;
|
|
42
53
|
|
|
54
|
+
if (typeof context.supabase.rpc === "function") {
|
|
55
|
+
await assertEditable(context as unknown as PlatformAuthContext, {
|
|
56
|
+
resourceType: EDIT_LOCK_TYPES.pageSeo,
|
|
57
|
+
resourceId: data.pageKey,
|
|
58
|
+
sessionId: data.editSessionId,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
const conflicts = await deps.detectConflicts(data);
|
|
44
63
|
if (conflicts.length > 0) return { ok: false, reason: "slug_conflict", conflicts };
|
|
45
64
|
|
|
@@ -92,7 +111,15 @@ export async function savePageSeoCore(
|
|
|
92
111
|
export async function deletePageSeoOverrideCore(
|
|
93
112
|
context: PageSeoWriteContext,
|
|
94
113
|
pageKey: string,
|
|
114
|
+
editSessionId?: string,
|
|
95
115
|
): Promise<{ ok: true; pageKey: string }> {
|
|
116
|
+
if (typeof context.supabase.rpc === "function") {
|
|
117
|
+
await assertEditable(context as unknown as PlatformAuthContext, {
|
|
118
|
+
resourceType: EDIT_LOCK_TYPES.pageSeo,
|
|
119
|
+
resourceId: pageKey,
|
|
120
|
+
sessionId: editSessionId,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
96
123
|
const { error } = await context.supabase.from("page_seo").delete().eq("page_key", pageKey);
|
|
97
124
|
if (error) throw new Error(error.message);
|
|
98
125
|
return { ok: true, pageKey };
|
package/src/server/pages-core.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import type { BlockInstance } from "../blocks/types";
|
|
11
11
|
import { diffBlocks, summarizeChanges } from "../cms/version-diff";
|
|
12
12
|
import type { VersionChange } from "../cms/version-diff";
|
|
13
|
+
import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
|
|
14
|
+
import { assertEditable } from "./edit-locks-core";
|
|
13
15
|
import { actorEmail, assertStaff, assertSuperadmin } from "./guards";
|
|
14
16
|
import type { PlatformAuthContext } from "./guards";
|
|
15
17
|
import {
|
|
@@ -136,9 +138,21 @@ export async function getPageForEditCore({ context }: PagesDeps, key: string) {
|
|
|
136
138
|
|
|
137
139
|
export async function savePageVersionCore(
|
|
138
140
|
{ context }: PagesDeps,
|
|
139
|
-
input: {
|
|
141
|
+
input: {
|
|
142
|
+
pageId: string;
|
|
143
|
+
blocks: BlockInstance[];
|
|
144
|
+
publish?: boolean;
|
|
145
|
+
note?: string;
|
|
146
|
+
/** Editing session that holds the lock, when the host tracks one. */
|
|
147
|
+
editSessionId?: string;
|
|
148
|
+
},
|
|
140
149
|
) {
|
|
141
150
|
await assertStaff(context);
|
|
151
|
+
await assertEditable(context, {
|
|
152
|
+
resourceType: EDIT_LOCK_TYPES.page,
|
|
153
|
+
resourceId: input.pageId,
|
|
154
|
+
sessionId: input.editSessionId,
|
|
155
|
+
});
|
|
142
156
|
invalidatePublishedCache();
|
|
143
157
|
const { data: last } = await context.supabase
|
|
144
158
|
.from("page_versions")
|
|
@@ -302,7 +316,11 @@ export async function listPageVersionsCore(deps: PagesDeps, pageId: string) {
|
|
|
302
316
|
});
|
|
303
317
|
}
|
|
304
318
|
|
|
305
|
-
export async function restorePageVersionCore(
|
|
319
|
+
export async function restorePageVersionCore(
|
|
320
|
+
deps: PagesDeps,
|
|
321
|
+
versionId: string,
|
|
322
|
+
editSessionId?: string,
|
|
323
|
+
) {
|
|
306
324
|
await assertStaff(deps.context);
|
|
307
325
|
const { data: v, error } = await deps.context.supabase
|
|
308
326
|
.from("page_versions")
|
|
@@ -315,6 +333,7 @@ export async function restorePageVersionCore(deps: PagesDeps, versionId: string)
|
|
|
315
333
|
blocks: blocksOf(v.blocks),
|
|
316
334
|
publish: true,
|
|
317
335
|
note: "Restored from earlier version",
|
|
336
|
+
editSessionId,
|
|
318
337
|
});
|
|
319
338
|
}
|
|
320
339
|
|
|
@@ -341,9 +360,14 @@ export async function createPageCore({ context }: PagesDeps, input: { key: strin
|
|
|
341
360
|
|
|
342
361
|
export async function renamePageCore(
|
|
343
362
|
{ context }: PagesDeps,
|
|
344
|
-
input: { pageId: string; name: string },
|
|
363
|
+
input: { pageId: string; name: string; editSessionId?: string },
|
|
345
364
|
) {
|
|
346
365
|
await assertStaff(context);
|
|
366
|
+
await assertEditable(context, {
|
|
367
|
+
resourceType: EDIT_LOCK_TYPES.page,
|
|
368
|
+
resourceId: input.pageId,
|
|
369
|
+
sessionId: input.editSessionId,
|
|
370
|
+
});
|
|
347
371
|
invalidatePublishedCache();
|
|
348
372
|
const { error } = await context.supabase
|
|
349
373
|
.from("pages")
|
|
@@ -361,8 +385,17 @@ export async function renamePageCore(
|
|
|
361
385
|
}
|
|
362
386
|
|
|
363
387
|
/** Soft delete: moves the page to the trash bin. Never destroys data. */
|
|
364
|
-
export async function deletePageCore(
|
|
388
|
+
export async function deletePageCore(
|
|
389
|
+
{ context }: PagesDeps,
|
|
390
|
+
pageId: string,
|
|
391
|
+
editSessionId?: string,
|
|
392
|
+
) {
|
|
365
393
|
await assertStaff(context);
|
|
394
|
+
await assertEditable(context, {
|
|
395
|
+
resourceType: EDIT_LOCK_TYPES.page,
|
|
396
|
+
resourceId: pageId,
|
|
397
|
+
sessionId: editSessionId,
|
|
398
|
+
});
|
|
366
399
|
invalidatePublishedCache();
|
|
367
400
|
const email = actorEmail(context);
|
|
368
401
|
const { data: page } = await context.supabase
|
|
@@ -568,9 +601,18 @@ export async function createGlobalSectionCore(
|
|
|
568
601
|
|
|
569
602
|
export async function setGlobalSectionKindCore(
|
|
570
603
|
{ context }: PagesDeps,
|
|
571
|
-
input: {
|
|
604
|
+
input: {
|
|
605
|
+
sectionId: string;
|
|
606
|
+
kind: "block" | "section";
|
|
607
|
+
editSessionId?: string;
|
|
608
|
+
},
|
|
572
609
|
) {
|
|
573
610
|
await assertStaff(context);
|
|
611
|
+
await assertEditable(context, {
|
|
612
|
+
resourceType: EDIT_LOCK_TYPES.globalSection,
|
|
613
|
+
resourceId: input.sectionId,
|
|
614
|
+
sessionId: input.editSessionId,
|
|
615
|
+
});
|
|
574
616
|
invalidatePublishedCache();
|
|
575
617
|
if (input.kind === "block") {
|
|
576
618
|
const { data: gs } = await context.supabase
|
|
@@ -597,9 +639,20 @@ export async function setGlobalSectionKindCore(
|
|
|
597
639
|
|
|
598
640
|
export async function saveGlobalSectionVersionCore(
|
|
599
641
|
{ context }: PagesDeps,
|
|
600
|
-
input: {
|
|
642
|
+
input: {
|
|
643
|
+
sectionId: string;
|
|
644
|
+
blocks: BlockInstance[];
|
|
645
|
+
note?: string;
|
|
646
|
+
/** Editing session that holds the lock, when the host tracks one. */
|
|
647
|
+
editSessionId?: string;
|
|
648
|
+
},
|
|
601
649
|
) {
|
|
602
650
|
await assertStaff(context);
|
|
651
|
+
await assertEditable(context, {
|
|
652
|
+
resourceType: EDIT_LOCK_TYPES.globalSection,
|
|
653
|
+
resourceId: input.sectionId,
|
|
654
|
+
sessionId: input.editSessionId,
|
|
655
|
+
});
|
|
603
656
|
invalidatePublishedCache();
|
|
604
657
|
const { data: meta } = await context.supabase
|
|
605
658
|
.from("global_sections")
|
|
@@ -636,9 +689,14 @@ export async function saveGlobalSectionVersionCore(
|
|
|
636
689
|
|
|
637
690
|
export async function renameGlobalSectionCore(
|
|
638
691
|
{ context }: PagesDeps,
|
|
639
|
-
input: { sectionId: string; name: string },
|
|
692
|
+
input: { sectionId: string; name: string; editSessionId?: string },
|
|
640
693
|
) {
|
|
641
694
|
await assertStaff(context);
|
|
695
|
+
await assertEditable(context, {
|
|
696
|
+
resourceType: EDIT_LOCK_TYPES.globalSection,
|
|
697
|
+
resourceId: input.sectionId,
|
|
698
|
+
sessionId: input.editSessionId,
|
|
699
|
+
});
|
|
642
700
|
invalidatePublishedCache();
|
|
643
701
|
const { error } = await context.supabase
|
|
644
702
|
.from("global_sections")
|
|
@@ -648,8 +706,17 @@ export async function renameGlobalSectionCore(
|
|
|
648
706
|
return { ok: true as const };
|
|
649
707
|
}
|
|
650
708
|
|
|
651
|
-
export async function deleteGlobalSectionCore(
|
|
709
|
+
export async function deleteGlobalSectionCore(
|
|
710
|
+
{ context }: PagesDeps,
|
|
711
|
+
sectionId: string,
|
|
712
|
+
editSessionId?: string,
|
|
713
|
+
) {
|
|
652
714
|
await assertStaff(context);
|
|
715
|
+
await assertEditable(context, {
|
|
716
|
+
resourceType: EDIT_LOCK_TYPES.globalSection,
|
|
717
|
+
resourceId: sectionId,
|
|
718
|
+
sessionId: editSessionId,
|
|
719
|
+
});
|
|
653
720
|
invalidatePublishedCache();
|
|
654
721
|
const { error } = await context.supabase
|
|
655
722
|
.from("global_sections")
|
|
@@ -681,16 +748,29 @@ export async function getPageVersionPreviewCore(
|
|
|
681
748
|
|
|
682
749
|
export async function schedulePageVersionCore(
|
|
683
750
|
{ context }: PagesDeps,
|
|
684
|
-
input: {
|
|
751
|
+
input: {
|
|
752
|
+
versionId: string;
|
|
753
|
+
publishAt: string;
|
|
754
|
+
unpublishAt?: string | null;
|
|
755
|
+
note?: string;
|
|
756
|
+
editSessionId?: string;
|
|
757
|
+
},
|
|
685
758
|
) {
|
|
686
759
|
await assertStaff(context);
|
|
687
|
-
invalidatePublishedCache();
|
|
688
760
|
const { data: v, error: vErr } = await context.supabase
|
|
689
761
|
.from("page_versions")
|
|
690
762
|
.select("page_id")
|
|
691
763
|
.eq("id", input.versionId)
|
|
692
764
|
.single();
|
|
693
765
|
if (vErr) throw new Error(vErr.message);
|
|
766
|
+
// Scheduling changes what the public site will show, so it belongs to
|
|
767
|
+
// whoever holds the page — the same rule as saving it.
|
|
768
|
+
await assertEditable(context, {
|
|
769
|
+
resourceType: EDIT_LOCK_TYPES.page,
|
|
770
|
+
resourceId: v.page_id,
|
|
771
|
+
sessionId: input.editSessionId,
|
|
772
|
+
});
|
|
773
|
+
invalidatePublishedCache();
|
|
694
774
|
const { data: inserted, error } = await context.supabase
|
|
695
775
|
.from("page_publications")
|
|
696
776
|
.insert({
|
|
@@ -736,8 +816,25 @@ export async function listPagePublicationsCore(
|
|
|
736
816
|
return (rows ?? []) as PagePublicationRow[];
|
|
737
817
|
}
|
|
738
818
|
|
|
739
|
-
export async function cancelPagePublicationCore(
|
|
819
|
+
export async function cancelPagePublicationCore(
|
|
820
|
+
{ context }: PagesDeps,
|
|
821
|
+
publicationId: string,
|
|
822
|
+
editSessionId?: string,
|
|
823
|
+
) {
|
|
740
824
|
await assertStaff(context);
|
|
825
|
+
const { data: pub, error: pubErr } = await context.supabase
|
|
826
|
+
.from("page_publications")
|
|
827
|
+
.select("page_id")
|
|
828
|
+
.eq("id", publicationId)
|
|
829
|
+
.single();
|
|
830
|
+
if (pubErr) throw new Error(pubErr.message);
|
|
831
|
+
// Cancelling somebody else's scheduled publication is a change to their
|
|
832
|
+
// page, so it goes through the same lock as every other write to it.
|
|
833
|
+
await assertEditable(context, {
|
|
834
|
+
resourceType: EDIT_LOCK_TYPES.page,
|
|
835
|
+
resourceId: pub.page_id,
|
|
836
|
+
sessionId: editSessionId,
|
|
837
|
+
});
|
|
741
838
|
invalidatePublishedCache();
|
|
742
839
|
const { error } = await context.supabase
|
|
743
840
|
.from("page_publications")
|