@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +16 -2
  3. package/docs/EDIT-LOCKS.md +213 -0
  4. package/docs/POPUPS.md +102 -0
  5. package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
  6. package/package.json +31 -3
  7. package/scripts/create-cms-site.mjs +5 -4
  8. package/scripts/sync-migrations.mjs +27 -6
  9. package/src/backoffice/ContactDetailsView.tsx +19 -5
  10. package/src/backoffice/DomainsView.tsx +152 -25
  11. package/src/backoffice/EditLock.tsx +513 -0
  12. package/src/backoffice/EmailSettingsView.tsx +99 -0
  13. package/src/backoffice/FormEditorView.tsx +531 -512
  14. package/src/backoffice/FormSettingsView.tsx +16 -4
  15. package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
  16. package/src/backoffice/NavigationView.tsx +35 -11
  17. package/src/backoffice/PageBuilderView.tsx +51 -16
  18. package/src/backoffice/RichTextEditor.tsx +50 -14
  19. package/src/backoffice/SectionEditorView.tsx +18 -6
  20. package/src/backoffice/SectionFormView.tsx +184 -31
  21. package/src/backoffice/SiteSettingsView.tsx +14 -15
  22. package/src/backoffice/TrackingView.tsx +247 -208
  23. package/src/backoffice/forms/VersionHistory.tsx +4 -2
  24. package/src/backoffice/inspectors/blocks.tsx +22 -1
  25. package/src/backoffice/inspectors/fields.tsx +21 -0
  26. package/src/backoffice/routes/host.ts +58 -7
  27. package/src/backoffice/routes/index.tsx +117 -15
  28. package/src/backoffice/routes/pages.tsx +10 -6
  29. package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
  30. package/src/backoffice/shell/domain-guard.ts +27 -14
  31. package/src/backoffice/shell/index.ts +5 -0
  32. package/src/backoffice/shell/nav-model.ts +32 -23
  33. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  34. package/src/backoffice/tracking-drafts.ts +150 -0
  35. package/src/blocks/components/CtaBannerBlock.tsx +18 -12
  36. package/src/blocks/components/HeroFullBlock.tsx +25 -12
  37. package/src/blocks/components/MediaSplitBlock.tsx +91 -41
  38. package/src/blocks/components/PriceTableBlock.tsx +123 -89
  39. package/src/blocks/components/RichTextBlock.tsx +26 -4
  40. package/src/blocks/index.ts +8 -0
  41. package/src/blocks/optional-content.ts +82 -0
  42. package/src/blocks/registry.tsx +1 -0
  43. package/src/blocks/types.ts +6 -0
  44. package/src/cms/edit-lock.ts +491 -0
  45. package/src/config/modules.ts +208 -0
  46. package/src/config/site-config.ts +9 -31
  47. package/src/forms/FormRenderer.tsx +62 -3
  48. package/src/forms/submission-result.ts +35 -0
  49. package/src/forms/success-listeners.ts +46 -0
  50. package/src/host/index.tsx +27 -1
  51. package/src/modules/index.ts +28 -0
  52. package/src/modules/public-runtime.tsx +159 -0
  53. package/src/navigation/index.ts +29 -0
  54. package/src/popups/admin.tsx +123 -0
  55. package/src/popups/cms-bridges.ts +240 -0
  56. package/src/popups/destinations.tsx +147 -0
  57. package/src/popups/host.ts +91 -0
  58. package/src/popups/media-bridge.tsx +84 -0
  59. package/src/popups/register.ts +23 -0
  60. package/src/popups/runtime.tsx +128 -0
  61. package/src/runtime/server-entry.ts +8 -2
  62. package/src/server/content-sections-core.ts +22 -5
  63. package/src/server/domains-core.ts +124 -107
  64. package/src/server/edit-lock-adapter.ts +91 -0
  65. package/src/server/edit-lock-supabase-adapter.ts +135 -0
  66. package/src/server/edit-locks-core.ts +286 -0
  67. package/src/server/forms-core.ts +37 -2
  68. package/src/server/injections-core.ts +230 -49
  69. package/src/server/page-seo-save-core.ts +27 -0
  70. package/src/server/pages-core.ts +108 -11
  71. package/src/server/popup-optin-trigger.ts +111 -0
  72. package/src/server/popup-optin-webhook-core.ts +363 -0
  73. package/src/server/provider-secrets.ts +100 -4
  74. package/src/server/schemas.ts +74 -9
  75. package/src/server/webhook-url-guard.ts +199 -0
  76. package/src/site/SiteHeader.tsx +11 -5
  77. package/src/site/host.ts +172 -0
  78. package/src/site/locale-domains.ts +500 -0
  79. package/src/site/routes.ts +206 -38
  80. package/src/site/site-url.ts +80 -17
  81. package/src/tracking/injections.ts +52 -9
  82. package/src/types/css-modules.d.ts +6 -0
  83. 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 audit({
109
- actorId: context.userId,
110
- actorEmail: actorEmail(context),
111
- action: "injection.update",
112
- entity: "site_injections",
113
- entityId: input.id,
114
- meta: { name: input.name, scope, enabled: input.enabled },
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
- await audit({
127
- actorId: context.userId,
128
- actorEmail: actorEmail(context),
129
- action: "injection.create",
130
- entity: "site_injections",
131
- entityId: id || null,
132
- meta: { name: input.name, scope },
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 audit({
147
- actorId: context.userId,
148
- actorEmail: actorEmail(context),
149
- action: "injection.delete",
150
- entity: "site_injections",
151
- entityId: id,
152
- meta: {},
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. Never throws: a backend hiccup
164
- * degrades to "no injection" so the site still renders.
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: { defaultLocale?: string } = {},
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
- let bundle = EMPTY_BUNDLE;
320
+ const log = options.logger ?? defaultLogger;
179
321
  try {
180
- const [{ data: rows }, { data: domains }] = await Promise.all([
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 (rows && rows.length > 0) {
186
- const list = (domains ?? []) as { locale: string; domain: string; is_default: boolean }[];
187
- const match = list.find((d) => normalizeHostname(d.domain) === key);
188
- const fallback = list.find((d) => d.is_default);
189
- const locale = match?.locale ?? fallback?.locale ?? options.defaultLocale ?? "en";
190
- const profiles = (rows as Record<string, unknown>[]).map(rowToInjectionProfile);
191
- bundle = mergeBundles(selectProfiles(profiles, locale, host));
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
- } catch {
194
- bundle = EMPTY_BUNDLE;
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
- cache.set(key, { at: Date.now(), bundle });
198
- return bundle;
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
- if (pathname.startsWith(backofficePath)) return false;
204
- if (pathname.startsWith("/preview")) return false;
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
- /** Insert the bundle's three chunks into a rendered HTML document. */
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 };
@@ -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: { pageId: string; blocks: BlockInstance[]; publish?: boolean; note?: string },
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(deps: PagesDeps, versionId: string) {
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({ context }: PagesDeps, pageId: string) {
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: { sectionId: string; kind: "block" | "section" },
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: { sectionId: string; blocks: BlockInstance[]; note?: string },
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({ context }: PagesDeps, sectionId: string) {
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: { versionId: string; publishAt: string; unpublishAt?: string | null; note?: string },
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({ context }: PagesDeps, publicationId: string) {
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")